Caching: Latency, Load and Staleness
The cheapest query is the one you never run. This lab puts a cache in front of a simulated 20 ms backend and measures what the textbook formulas promise: hit ratio vs cache size under skewed traffic, a stampede of 500 database calls from one expiry, synchronised expiry across 2,000 keys, and the byte and CPU cost of putting values in a shared cache. Redis, ElastiCache and CDN behaviour are explained from documented behaviour and labelled illustrative.
Every Read Hits the Database
OrderService serves GET /orders/{id}. Order details change rarely after payment but are read constantly (status pages, support tools, polling clients). Every read costs a database call of about 20 ms in our lab and 200,000 of them arrive per test run. The cheapest query is the one you never run.
Structure diagram; per-layer costs are orders of magnitude, not measurements except where stated.
The experiments on this page are plain-Java programs run in this sandbox: a simulated backend (20 ms per call, capacity configurable) behind Caffeine 3.1.8 and hand-written caches, driven by 200 concurrent virtual threads with Zipf-distributed keys. They ran on a 12-core laptop-class machine shared with other jobs, so latency values are noisy; hit ratios, call counts and the shape of every curve are deterministic enough to trust. Redis/ElastiCache and CDN behaviour are explained from documented behaviour and were not run here - such numbers are labelled illustrative.
p50 of GET /orders/{id} is 20+ ms because every call goes to the database, and during a promotion the same 1,000 popular orders account for most traffic. The database is the shared bottleneck (page 08).
Traffic is highly skewed (a few keys get most reads). A small in-process cache holding the hot keys can absorb most reads, and the database sees only the misses. Expected: effective latency = h × tcache + (1 − h) × tdb.
Measure the hit ratio, not the cache size: Micrometer exposes cache.gets{result=hit|miss}, cache.puts, cache.evictions for Caffeine/Spring caches (enable recordStats()); Redis exposes keyspace_hits / keyspace_misses (ElastiCache: CacheHitRate). Offline, simulate your own key distribution as we do in chapter 3.
Chapters 3 to 5 show what the data says: the formula holds when the backend is not saturated; hit ratio saturates with cache size (long tail of cold keys); and a naive cache fails in two famous ways (stampede, synchronised expiry).
One variable at a time: cache size, then single-flight, then TTL jitter.
Backend calls and latency percentiles before/after, each table from a real run.
Judgement framework.
Patterns, Eviction and Admission
A cache is only a pair of decisions: who fills it and when (the pattern), and who removes entries and when (eviction and invalidation). Every cache bug is one of those two decisions made badly.
Structure diagrams.
Mechanism per Caffeine design documentation.
| Pattern | Read path | Write path | Staleness | Failure mode |
|---|---|---|---|---|
| Cache-aside | app: cache, else DB, then fill | write DB, delete cache key | until evict/TTL; race windows (ch. 7) | stampede on cold key |
| Read-through | cache loads on miss | as above | same | loader errors must not be cached |
| Write-through | always from cache | cache writes DB synchronously | none if all writes go through | slower writes; cache outage blocks writes |
| Write-behind | always from cache | cache buffers, flushes later | DB lags cache | data loss on crash; ordering |
The Formula, Zipf Traffic and a Saturated Backend
The first lab proves the arithmetic of caching and shows how hit ratio and cache size relate on realistic (skewed) traffic, plus a surprise: why real systems gain more than the formula predicts.
We need to decide how big a cache to buy (memory per pod) and what effect to expect. "Cache more" is not an answer.
(a) The average latency follows h · t_cache + (1 − h) · t_db. (b) With Zipf-distributed keys, hit ratio grows quickly with the first few thousand entries and then flattens. (c) If the backend is saturated, a cache gains more than the formula says because it also removes queueing.
CacheLab ratio (chapter 10): 100,000 keys, Zipf exponent 1.0, 200 virtual-thread callers, a backend that sleeps 20 ms and admits a configurable number of concurrent calls. Caffeine maximumSize is the only variable between rows.
Measured; the ~19 % of misses at 100,000 keys are first-touch (compulsory) misses of 200,000 requests over 100,000 keys.
Dots measured; dashed lines computed from the formula with example t_cache values.
The measured average matches the formula to within a few percent in every row (for instance 8.48 ms measured vs 8.39 ms formula at 58 % hit ratio): the model is right when the backend latency does not change with load.
| Hit ratio h | Remote cache (t_c 0.5 ms) avg | In-process (t_c ~ 0) avg | DB load vs no cache | Comment |
|---|---|---|---|---|
| 0 % | 20.00 ms | 20.00 ms | 100 % | baseline |
| 50 % | 10.25 ms | 10.00 ms | 50 % | halved everything |
| 80 % | 4.40 ms | 4.00 ms | 20 % | |
| 90 % | 2.45 ms | 2.00 ms | 10 % | |
| 95 % | 1.48 ms | 1.00 ms | 5 % | p95 is now a MISS |
| 99 % | 0.70 ms | 0.20 ms | 1 % | p99 = miss latency |
| 99.9 % | 0.52 ms | 0.02 ms | 0.1 % | remote hit cost dominates |
(Table computed from the formula with t_db = 20 ms; the 0.5 ms remote-cache cost is an illustrative same-AZ figure, not measured.) Note the last rows: past about 95 % hit ratio the average barely moves while the tail is exactly the miss latency - the p99 of a service with 99 % hit ratio is a database call plus whatever queueing misses suffer.
Pick maximumSize from the measured hit-ratio curve: the knee (5,000 keys here) is where each extra entry stops paying. Memory = entries × average entry size (measure with a heap histogram, see JVM Memory & GC).
Measured: same cache sizes, backend limited to 50 concurrent calls vs 1,000.
With the backend saturated, "no cache" costs 84 ms for a 20 ms service time (200 callers / 50 slots = 4 waves of queueing). A 5,000-entry cache reduces that to 30.5 ms. The formula would predict 7.6 ms: it is wrong here because t_db itself was a function of load. The cache removed 62 % of the calls and the queue they formed. This is why caching in front of a database that is hot looks magical, and why you must re-measure the database after the cache goes live rather than assume the number from a quiet benchmark.
- Measuring cache size, not hit ratio. 1 GB of cache that holds cold keys helps no one. Alert on hit ratio (
cache.getsby result) and evictions. - Uniform traffic assumption. The Zipf skew is what makes small caches work; for uniformly random access (hash-spread IDs scanned by a batch job) the hit ratio is roughly cache size / key count.
- Per-pod caches multiply memory and cold-start misses. 20 pods each need to warm up; after a deploy the hit ratio dips and the database sees a wave (see warm-up on the Scaling page).
- Caching entities that are mutated in place. Return immutable DTOs; a mutable cached object shared between threads is a data race.
- Benchmarks on warm caches only. Always test the cold-start and failure case (chapter 4).
One Expiry, 500 Database Calls
The famous failure of caches: the moment a popular entry expires, every concurrent request misses together and they all hit the database for the same value. A cache that was protecting the database turns into the weapon that kills it.
Every 60 s the dashboard shows a latency spike and a burst of 500 identical queries for the hottest order, exactly when its TTL expires. The database connection pool saturates for a moment and unrelated requests fail.
500 concurrent readers miss simultaneously and each calls the database. A per-key "single-flight" - the first caller loads, the rest wait for its result - would turn 500 calls into 1.
CacheLab stampede releases 500 virtual threads together on an empty cache for key 7 and counts backend calls, with three strategies. Backend: 20 ms per call, 50 concurrent. Also run with 100 ms (a slow database) to see the effect grow.
Measured.
Naive: 500 backend calls for one key; with 20 ms calls and 50 slots the last reader waits 203 ms, and with a 100 ms backend it waits over a second - and in production those 500 calls would also hold 500 pooled connections. Both single-flight variants make exactly 1 call.
Illustration of the in-flight map used in our CacheLab (a ConcurrentHashMap of CompletableFuture).
Two companions that remove the cause: refresh before expiry so readers never see a miss (refreshAfterWrite in Caffeine serves the old value while one thread reloads asynchronously - the in-process version of HTTP "stale-while-revalidate"), and probabilistic early expiration for distributed caches (each reader may refresh a bit early, with probability rising as expiry nears).
| Strategy (500 readers, 20 ms backend) | Backend calls | p50 | p99 |
|---|---|---|---|
| Naive cache-aside | 500 | 112.4 ms | 203.2 ms |
| Single-flight (future map) | 1 | 19.4 ms | 24.7 ms |
Caffeine get(key, loader) | 1 | see note | 27.4 ms |
Note on the Caffeine row: its p50 printed as 0.0 ms. We believe this is an artefact of the test, not a feature: Caffeine runs the loader inside ConcurrentHashMap.compute, which holds a monitor, and on JDK 21 a virtual thread blocking inside synchronized pins its carrier; most of the 500 test threads could not even start timing until the load had finished. Read only the call count and p99 for that row. (The pinning discussion is on the Concurrency page; JEP 491 in JDK 24 removes this pinning.)
- Caching the exception or the empty result of a failed load. One transient database error then poisons the key for the whole TTL. Don't cache failures (or cache them for 1-2 seconds at most).
- Single-flight with no timeout. If the winner hangs, 499 callers hang with it. Give the load its own deadline.
- Cold start after a deploy or a cache flush. The whole hot set misses together: warm the cache before taking traffic, stagger rollout, or keep a stale copy to serve while refreshing.
- Per-pod single-flight is not global. 50 pods = 50 loads of the same key. Acceptable normally; for very expensive loads use a distributed lock with a short lease or request coalescing in front.
- Redis down = stampede on the database. Decide in advance: fail open to the DB with a rate limiter, serve stale, or shed load (see Capacity & Failure).
When Thousands of Keys Expire Together
A cache stampede on one key is dramatic; a stampede on thousands of keys at once is just as bad and much more common after a deploy: everything was warmed in the same few seconds with the same TTL, so everything expires in the same few seconds.
Database CPU shows a sawtooth with exactly the cache TTL as its period: calm, then a 2,000-query burst, then calm again.
Keys filled at the same instant share an expiry instant. Randomising each TTL by ±20 % ("jitter") spreads expiries over a window instead of a spike.
CacheLab jitter: 2,000 keys, each read every ~50 ms by its own virtual thread, hand-rolled TTL cache, reload = 20 ms. Count backend loads per 250 ms bucket for 6 s. Only the jitter factor differs between runs.
Measured.
Without jitter the database sees 2,000 loads in a single 250 ms bucket, every 2 s, and nothing in between. With ±20 % jitter the first wave (cold start - unavoidable) is the same, but afterwards the worst bucket is 638 loads: 3.1x lower, and the waves widen and eventually blur as the keys desynchronise.
(Caffeine expireAfterWrite is one duration per cache; for per-entry TTLs use Expiry or add the jitter to the stored deadline. Spring's RedisCacheManager takes a TTL per cache, so jitter needs a custom RedisCacheWriter / TTL function or a custom wrapper.)
| No jitter | ±20 % jitter | |
|---|---|---|
| Peak backend loads in one 250 ms bucket (after cold start) | 2,000 | 638 |
| Shape | three spikes then silence | overlapping, lower waves |
| Cold start (first bucket) | 2,000 | 2,000 (jitter does not help) |
Cold start needs different medicine: warm gradually, or keep serving stale while refreshing (refreshAfterWrite). Jitter only fixes repeating synchronisation.
- TTL = time since write, not since access.
expireAfterAccesskeeps hot keys forever (and stale forever). Pick on purpose. - A TTL of "forever" as a design. Without invalidation the data is wrong the moment the source changes; without TTL you have no recovery from a missed invalidation.
- All-pods-restart-together deploys reproduce the cold-start spike: use rolling updates with readiness gates.
- Time-aligned TTLs ("expire at midnight") are synchronised by construction; add jitter to the boundary.
The Price of a Shared Cache
An in-process cache is fast but private to each pod, lost on restart, and limited by heap. A distributed cache (Redis on ElastiCache, Memcached) is shared and survives pod restarts, at the price of a network hop, serialization and a new thing to operate. No Redis or AWS service was run for this page; the behaviour below is documented/standard and the numbers are labelled. The serialization numbers were measured.
Per-pod Caffeine caches give a 70 % hit ratio but each of 20 pods warms separately and an eviction in one pod is invisible to the others. Product wants a shared cache so that a deploy does not zero the hit ratio.
A shared Redis layer (L2) behind the Caffeine L1 keeps hit ratio through deploys and gives one place to invalidate, with an added cost per L2 hit of one network round trip plus (de)serialization.
Measure (a) the serialization cost here with SerLab (chapter 10), and (b) in your VPC, the real Redis round trip: redis-cli --latency -h <endpoint> and the ElastiCache CloudWatch metrics CacheHitRate, Evictions, EngineCPUUtilization, CurrConnections, DatabaseMemoryUsagePercentage, ReplicationLag.
Measured with Jackson 2.17.2 and java.io serialization on a shared machine.
JSON is smaller and faster than Java serialization here, and is readable and language-neutral. Java serialization is also a security liability (deserialisation gadgets) - do not use it for cache values. For hot paths consider a compact binary format (protobuf, MessagePack) - not measured here.
Illustrative topology; behaviour per Redis cluster documentation.
Operational settings: client pool sized like any pool (Lettuce shares one connection per thread-safe client by default; set timeouts), maxmemory and an eviction policy (allkeys-lru/allkeys-lfu for a pure cache; Redis OSS defaults to noeviction and ElastiCache's default parameter group differs - check yours), and SCAN instead of KEYS.
Verify with the metrics above that: L1+L2 combined hit ratio holds across a rolling deploy; EngineCPUUtilization stays well under 100 % on every shard (a single hot key shows as one shard pinned while others idle); p99 for an L2 hit stays near your measured round trip. None of this could be measured in this sandbox.
| Caffeine (in-process) | Redis / ElastiCache | |
|---|---|---|
| Hit cost | about a microsecond or less, no serialization | network round trip + serialization |
| Sharing | per pod; each warms separately | shared by all pods; survives pod restarts |
| Capacity | heap (competes with the app; GC pressure) | dedicated memory; scale by shards |
| Invalidation | hard across pods (needs pub/sub) | one place |
| Failure | dies with the pod | outage = stampede on DB; needs a plan |
| Typical use | tiny hot sets, config, reference data; L1 in front of Redis | sessions, shared lookups, rate limits, large hot sets |
- Hot key. One key at 50,000 reads/s saturates one shard's single command thread while the rest idle; add an L1 near-cache with a few seconds TTL, or spread the key (
order:42#0..7). - Big values / big keys (megabytes) block the shard and the network; cap value size, compress rarely-read blobs, never cache whole result sets.
- Timeouts too long. A Redis hiccup with a 5 s read timeout parks every request thread; use ~tens of ms and fall back to L1 or the database with a rate limit.
- Failover. Primary promotion drops connections for seconds and DNS/endpoint caching delays recovery (see the DNS note on the Spring page).
- Java serialization of cache values. Slower, bigger, and a security risk.
Keeping the Copy Honest
"There are only two hard things in computer science: cache invalidation and naming things." The difficulty is not removing an entry - it is knowing when the source changed and doing it before someone reads the stale copy. Choose the weakest consistency your users can live with.
Support reports orders showing "PAYMENT_PENDING" for ten minutes after payment succeeded. The database is right; the cache is wrong.
Either the invalidation on payment never ran for this path (another service wrote the row), or it ran and then a racing reader repopulated the old value.
Reproduce with a log of cache operations per key (ids, version/updatedAt, source) and compare to the DB row's updated_at. Log the version in the cached value and expose it in a debug endpoint.
Mechanism diagram. Fully reproducible with two threads and a sleep between read and fill.
Reader A read the old row, writer B committed and deleted the key, then A filled the cache with the old value. The delete came before the stale fill. Any cache-aside read-then-write sequence has this window.
| Strategy | How | Consistency | Cost / risk |
|---|---|---|---|
| TTL only | every entry expires after N seconds | stale up to N | simplest; safety net for all other strategies |
| Evict on write | after commit, delete the key (@CacheEvict) | near-fresh; race above | miss after every write; do it AFTER the transaction commits (@TransactionalEventListener(AFTER_COMMIT)) |
| Delayed double delete | delete, then delete again after ~1 s | closes most races | extra work; picks a magic delay |
| Versioned keys | order:42:v17; readers learn the version from a cheap source | strong per version | extra lookup for the version |
| Event-driven | DB change stream / outbox publishes "order 42 changed"; consumers evict | eventual, decoupled | infrastructure; events can be lost/reordered |
| Write-through | writes update cache and DB in one path | fresh if all writers use it | every writer must go through it |
Rules that prevent most trouble: evict after commit, not before; evict by delete; keep a TTL even when you evict; make cached values carry a version so you can detect staleness; and treat "another service writes this table" as an invalidation source you must subscribe to.
Write a test that interleaves reader and writer with explicit latches (the race above), assert the final cache value equals the DB value, and run it in CI. Watch the cache hit ratio and a "stale reads" counter (compare cached version to DB version on a sample of reads).
Requests for IDs that do not exist (bots scanning /orders/1..N, deleted orders) always miss and always hit the database. Cache the absence for a short TTL (for example 30-60 s) - a sentinel such as Optional.empty() in Caffeine, or disableCachingNullValues() turned off with a short TTL in Spring Data Redis - and cap the key space (a Bloom filter in front of the cache is the next step). Keep negative TTLs short: a just-created order must not be "not found" for a minute; evict the key on create.
- Evicting inside the transaction. A reader can repopulate the old value before commit. Evict after commit.
- Caching derived data without tracking its inputs. A cached "customer order count" is invalidated by order writes, refunds and deletes - list every writer.
- Assuming
@CacheEvictworks on self-invocation. It is a proxy feature; calling the method from the same class skips it (AOP & Proxies). - No way to flush. Provide an admin path to evict a key or a namespace; you will need it during an incident.
Caches You Do Not Operate
The cheapest cache hit is the one that never reaches your servers: the browser or CDN. HTTP caching is a contract expressed in headers; get the headers right and caches you do not operate do the work.
Illustrative arithmetic; not measured.
| Header / directive | Meaning | Use for OrderService |
|---|---|---|
Cache-Control: max-age=60 | any cache may reuse the response for 60 s without asking | public reference data (product catalogue) |
Cache-Control: private | only the browser may cache; shared caches must not | per-user data such as GET /orders/{id} behind auth |
s-maxage=300 | like max-age but for shared caches (CDN) only | let the CDN keep it longer than browsers |
ETag + If-None-Match → 304 | conditional request: server skips the body when unchanged | big, rarely changing responses; still costs a request |
stale-while-revalidate=30, stale-if-error=600 | serve stale while refreshing / when origin errors (RFC 5861) | smooths expiry and survives origin trouble; CloudFront honours these directives (check current docs) |
Vary: Accept-Encoding, Authorization | cache key includes these request headers | wrong Vary = serving user A's response to user B, or no hits at all |
Cache-Control: no-store | never write to any cache | sensitive payloads (tokens, PII) |
On AWS: put CloudFront in front of the ALB for cacheable GETs; use a cache policy that includes only the headers/query strings that really vary the response (fewer cache-key dimensions = higher hit ratio), forward Authorization only for private paths, and watch the CloudFront CacheHitRate metric. We did not run CloudFront here; the arithmetic above is illustrative. Spring's ShallowEtagHeaderFilter computes ETags from the response body: it saves bandwidth (304) but not the work of building the response.
- Caching authenticated responses on a shared cache. Missing
private/Varyleaks data between users. Review every cacheable route for auth. - Cache-key explosion (tracking query parameters, random cache-busters) drives CDN hit ratio to zero; normalise or ignore irrelevant params in the cache policy.
- Long max-age with no purge plan. You cannot recall a response from a million browsers; use short max-age plus validation, or fingerprinted URLs for static assets.
- Forgetting compression and
Vary: Accept-Encodingtogether.
@Cacheable and the Page's Knobs
Spring's cache abstraction (@Cacheable, @CachePut, @CacheEvict) is a proxy-based wrapper around the patterns above; the mechanics (key generation, proxy self-invocation, CacheManager choices, Resilience4j companions) are covered in Caching & Resilience and the proxy details in AOP & Proxies. This chapter only ties the knobs on this page to the annotations.
| Concern | Annotation / setting | Page section |
|---|---|---|
| Stampede | sync = true or Caffeine loader | chapter 4 |
| Hit ratio visibility | recordStats + Micrometer cache.gets | chapter 3 |
| Staleness | TTL + evict-after-commit | chapter 7 |
| Serialization | JSON serializer for Redis values | chapter 6 |
| Negative caching | unless / short-TTL sentinel | chapter 7 |
| Self-invocation trap | call through the proxy / move method to another bean | AOP |
The Complete Programs
All numbers on this page came from the programs below, run in a scratch directory outside the repository (Java 21.0.2). Prerequisites: caffeine-3.1.8.jar (Maven Central com.github.ben-manes.caffeine:caffeine:3.1.8) for CacheLab; Jackson 2.17.2 for SerLab.
Questions You Should Be Able to Answer
Scenario questions with the answers interviewers look for.
- Q: Explain cache-aside, read-through, write-through and write-behind.
Cache-aside: app checks cache, loads and fills on miss. Read-through: the cache loads on miss. Write-through: writes go through the cache to the DB synchronously (fresh, slower writes). Write-behind: cache buffers writes and flushes later (fast, risk of loss). - Q: How do you compute the benefit of a cache?
Effective latency = h · tcache + (1 − h) · tdb, and DB load falls to (1 − h). We measured 8.48 ms vs 8.39 ms predicted at 58 % hit ratio. If the DB was saturated the gain is larger because queueing disappears (84 ms to 30 ms in our run). - Q: Why is average latency a poor metric for a cache?
Above ~95 % hit ratio the mean barely moves but p99 equals the miss path (a DB call plus any queueing). Track percentiles and the miss path separately. - Q: What is a cache stampede and how do you prevent it?
Many concurrent misses for one key all hit the backend (we measured 500 calls for one expiry). Fixes: single-flight/locking per key (Caffeine loader,sync=true), refresh-ahead/stale-while-revalidate, jittered TTLs, request coalescing. - Q: Why add jitter to TTLs?
Keys filled together expire together. We saw 2,000 loads in one 250 ms bucket without jitter and a 3.1x lower peak with ±20 %. - Q: How do you invalidate safely with cache-aside?
Delete after commit, keep a TTL, consider delayed double-delete or versioned keys. The race: reader reads old value, writer updates and deletes, reader fills the old value. - Q: Caffeine or Redis?
Caffeine for tiny hot sets with no network or serialization cost; Redis when many pods share data or must survive restarts. Often both: L1 + L2. Redis adds a hop, serialization, a failure mode (outage = stampede). - Q: What is a hot key and how do you handle it?
One key receives a disproportionate share and saturates one shard (one command thread). Use an L1 near-cache with short TTL, key sharding (key#N), or read replicas. - Q: What do Cache-Control: private vs s-maxage mean?
private = only the browser may cache; s-maxage = lifetime for shared caches (CDN). Cache authenticated responses as private or not at all. - Q: The cache is down. What happens to your service?
Design for it: short client timeouts, fail open to the DB with a rate limiter/bulkhead, serve stale from L1, or shed load. Otherwise the cache outage becomes a database outage. - Q: What is negative caching?
Caching "not found" for a short TTL to stop repeated misses for nonexistent keys (cache penetration); evict on create.
Cheat Sheet and Recap
| Symptom | Cause | Fix | Measured here |
|---|---|---|---|
| DB overloaded by repeated identical reads | no cache / skewed traffic | Caffeine sized from the hit-ratio knee | 58 % hit ratio at 1,000 entries; avg 8.5 ms vs 21 ms |
| Latency spike when a hot key expires | stampede | single-flight, refresh-ahead | 500 calls to 1 |
| DB sawtooth with period = TTL | synchronised expiry | jittered TTL | peak 2000 to 638 |
| Stale data after writes | race / missed invalidation | evict after commit, versions, TTL | race reproduced as mechanism |
| Remote cache slower than expected | serialization + RTT | JSON/binary format, L1 in front | JSON 264 B / 5 us vs Java 392 B / 20 us |
| One shard at 100 % CPU | hot key | near-cache, key sharding | not measured |
| Cache outage takes the service down | no fallback | timeouts, fail-open + limiter, stale | not measured |
- Caching trades correctness for speed. Decide the staleness you accept, then pick TTL and invalidation.
- Latency = h · tcache + (1 − h) · tdb; DB load = (1 − h). Measured to within a few percent.
- Hit ratio, not cache size, is the KPI; skewed traffic gives diminishing returns (58 % at 1k, 81 % at 100k entries).
- A saturated backend gains more from a cache than the formula says (84 ms to 30 ms) - re-measure after launch.
- Stampedes: single-flight (500 calls to 1); synchronised expiry: jitter (peak 2000 to 638).
- Evict after commit, by delete, with a TTL safety net.
- Redis, CloudFront and cross-AZ numbers here are illustrative; only in-process behaviour and serialization were measured.
- 08 Database Performance - the backend you are protecting.
- 07 Spring, HTTP & Network - HTTP headers and compression.
- 10 Distributed Systems - retries and breakers when the cache or DB misbehaves.
- 11 Capacity & Failure - "Redis down" as a scenario.
- 13 Scaling & Observability - hit-ratio dashboards and cold-start warm-up.
- Spring: Caching & Resilience, AOP.
- AWS ElastiCache, CloudFront.