Performance · 09

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.

12
Chapters
4
Measured labs
14
Diagrams
2
Programs to run
01
Chapter One - The Problem

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.

The cache layers of OrderService on AWS
Where a copy of GET /orders/42 can live - each layer is closer, smaller and harder to invalidate Browser / client Cache-Control, ETag CDN edge CloudFront API gateway / ALB rarely a cache App memory Caffeine, per pod Distributed ElastiCache Database buffer pool Typical cost of a hit (order of magnitude; only the in-process figure was measured here): Browser / CDN no request reaches your servers at all App memory (Caffeine) about a microsecond or less per lookup, no serialization Redis same AZ network round trip, ~0.5 ms (illustrative) + serialization Database milliseconds to tens of ms; 20 ms in our simulated backend Closer layers remove more work per hit but give less sharing: a per-pod Caffeine cache is warm per pod, and each of 20 pods misses on its own first. Invalidating one key everywhere gets harder as you move left (CDN purge, pub/sub for L1).

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.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

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).

Step 2 · Hypothesis

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.

Step 3 · Measurement

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.

Step 4 · Diagnosis

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).

Step 5 · Optimization

One variable at a time: cache size, then single-flight, then TTL jitter.

Step 6 · Verification

Backend calls and latency percentiles before/after, each table from a real run.

Decision flow
Should this data be cached? (decision flow) Read far more often than written? same answer for many callers? No: caching adds invalidation work for little reuse - skip it Can it be stale? for how long? (TTL) who invalidates? Is the load skewed? hot keys (Zipf) = small cache, big win no yes Then pick the layer: HTTP/CDN if the response is public and identical; in-process (Caffeine) for tiny hot sets; Redis if many pods need a shared view; always measure hit ratio and what happens on a miss storm

Judgement framework.

Key insight: A cache is a desk drawer: pulling a pen from it is instant, but you must keep it stocked (invalidation), size it (eviction) and know what to do when ten people open it empty at the same time (stampede).
02
Chapter Two - Mental Model

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.

A read arrives and the app checks the cache by key. Hit: return in microseconds (in-process) or about one round trip (Redis). The hit path should allocate almost nothing.
▶
A hit costs microseconds or one hop
Miss: someone must load the value. Cache-aside: your code. Read-through: the cache's loader. If many callers miss the same key simultaneously they must share one load (single-flight).
▶
Concurrent misses must share one load
The loaded value is stored with a TTL and/or size bound. Size bound evicts by policy (W-TinyLFU in Caffeine, LRU/LFU in Redis); TTL bounds staleness.
▶
Bounds memory and staleness
A write happens to the source of truth. You must evict/update the cache entry, or accept staleness up to the TTL. Write-through/behind change who writes to the DB.
▶
Stale until you evict or expire
Entries expire or are evicted. The next read is a miss: if thousands of keys expire together the database sees a wall of misses at once (chapter 5).
▶
Misses arrive in waves
Four patterns
The four classic cache patterns (arrows = who talks to whom) Cache-aside (lazy) App Cache DB 1 app asks cache; 2 miss: app reads DB; 3 app fills cache Most common. App owns the logic; stale until TTL/evict. Read-through App Cache DB app asks cache only; the cache loads from DB on miss Cache owns loading (Caffeine LoadingCache, Hazelcast). Write-through App Cache DB write goes to cache, cache writes DB synchronously Reads always fresh; every write pays both latencies. Write-behind (write-back) App Cache DB write hits cache; DB updated later in batches Fast writes; risk: lost data if cache dies before flush.

Structure diagrams.

Why Caffeine admission works
Caffeine's W-TinyLFU: why it beats plain LRU on skewed traffic new entry Window small LRU (~1 %) admits everything TinyLFU filter frequency sketch candidate vs victim: higher frequency wins Protected segment hot, frequently reused Probation segment main space, eviction victims A one-off scan of cold keys fills the window, loses the frequency contest and is evicted without flushing the hot set; plain LRU would let that scan evict everything useful. Frequency is stored in a compact counting sketch with periodic aging. Configuration surface you will actually use: maximumSize(n) or maximumWeight | expireAfterWrite / expireAfterAccess | refreshAfterWrite | recordStats() Mechanism per the Caffeine design docs (Window TinyLFU); details such as the 1 % window are defaults and may change.

Mechanism per Caffeine design documentation.

Patterns compared
PatternRead pathWrite pathStalenessFailure mode
Cache-asideapp: cache, else DB, then fillwrite DB, delete cache keyuntil evict/TTL; race windows (ch. 7)stampede on cold key
Read-throughcache loads on missas abovesameloader errors must not be cached
Write-throughalways from cachecache writes DB synchronouslynone if all writes go throughslower writes; cache outage blocks writes
Write-behindalways from cachecache buffers, flushes laterDB lags cachedata loss on crash; ordering
03
Chapter Three - Lab One - Hit Ratio, Cache Size and Effective Latency

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.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

We need to decide how big a cache to buy (memory per pod) and what effect to expect. "Cache more" is not an answer.

Step 2 · Hypothesis

(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.

Step 3 · Measurement

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.

shell
java -Dbackend.cap=1000 -cp out:caffeine-3.1.8.jar CacheLab ratio # healthy backend, 200,000 requests java -Dbackend.cap=50 -Drequests=30000 -cp out:caffeine-3.1.8.jar CacheLab ratio # saturated backend: 50 concurrent calls
Step 4 · Diagnosis
real output - healthy backend
100,000 distinct orders, Zipf(s=1.0) traffic, 200,000 requests, 200 concurrent callers, backend = 20 ms, capacity = 1000 concurrent calls cache size hit ratio measured avg formula h*t_c+(1-h)*20ms backend calls 0 0.0% 21.11 ms 20.00 ms 200000 100 38.2% 12.80 ms 12.36 ms 123548 1000 58.0% 8.48 ms 8.39 ms 83930 5000 70.3% 6.01 ms 5.95 ms 59479 20000 77.7% 4.52 ms 4.46 ms 44551 100000 80.8% 3.88 ms 3.85 ms 38457
Hit ratio vs cache size
Hit ratio vs cache size: diminishing returns (Zipf s=1.0, 100,000 keys, measured) no cache 0.0 % (200,000 backend calls of 200,000) size 100 38.2 % (123,548 backend calls of 200,000) size 1,000 58.0 % (83,930 backend calls of 200,000) size 5,000 70.3 % (59,479 backend calls of 200,000) size 20,000 77.7 % (44,551 backend calls of 200,000) size 100,000 80.8 % (38,457 backend calls of 200,000) Growing the cache 20x (5,000 to 100,000 keys, i.e. all of them) buys only 10 more points: the tail of rarely read keys.

Measured; the ~19 % of misses at 100,000 keys are first-touch (compulsory) misses of 200,000 requests over 100,000 keys.

Average latency tracks the formula
Effective latency vs hit ratio: formula curves and measured points (backend 20 ms) 0% 25% 50% 75% 100% 0 5 10 15 20 hit ratio h ms dashed gold: t_cache = 0.5 ms (remote cache, illustrative) dashed green: t_cache about 0 (in-process) blue dots: measured, Caffeine + 20 ms backend every measured dot sits on the green line: the formula predicts the average well

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 hRemote cache (t_c 0.5 ms) avgIn-process (t_c ~ 0) avgDB load vs no cacheComment
0 %20.00 ms20.00 ms100 %baseline
50 %10.25 ms10.00 ms50 %halved everything
80 %4.40 ms4.00 ms20 %
90 %2.45 ms2.00 ms10 %
95 %1.48 ms1.00 ms5 %p95 is now a MISS
99 %0.70 ms0.20 ms1 %p99 = miss latency
99.9 %0.52 ms0.02 ms0.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.

Step 5 · Optimization
Java - Caffeine, sized and with stats
Cache<Long, OrderDto> orders = Caffeine.newBuilder() .maximumSize(5_000) // ~70 % hit ratio in our Zipf run; sizing = hot set, not data set .expireAfterWrite(Duration.ofSeconds(60)) .recordStats() // exposes hit/miss/eviction counts to Micrometer .build(); OrderDto get(long id) { return orders.get(id, this::loadFromDatabase); // single-flight per key (chapter 4) }

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).

Step 6 · Verification
Why a saturated backend gains more
Same cache, saturated vs healthy backend: measured average latency (ms) size healthy backend (capacity 1000) saturated backend (capacity 50) none 21.11 84.03 100 12.80 49.82 1,000 8.48 37.00 5,000 6.01 30.51 20,000 4.52 28.06 100,000 3.88 28.33 No cache + saturated backend: 84 ms for a 20 ms service time. Cache reduces LOAD, so the queue shrinks too.

Measured: same cache sizes, backend limited to 50 concurrent calls vs 1,000.

real output - saturated backend (50 concurrent calls, 30,000 requests)
100,000 distinct orders, Zipf(s=1.0) traffic, 30000 requests, 200 concurrent callers, backend = 20 ms, capacity = 50 concurrent calls cache size hit ratio measured avg formula h*t_c+(1-h)*20ms backend calls 0 0.0% 84.03 ms 20.00 ms 30000 100 39.3% 49.82 ms 12.15 ms 18222 1000 54.1% 37.00 ms 9.17 ms 13756 5000 61.9% 30.51 ms 7.61 ms 11419 20000 65.0% 28.06 ms 7.01 ms 10509 100000 64.9% 28.33 ms 7.01 ms 10517

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.

⚠ Production gotchas: sizing and hit ratio
  • Measuring cache size, not hit ratio. 1 GB of cache that holds cold keys helps no one. Alert on hit ratio (cache.gets by 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).
04
Chapter Four - Lab Two - The Stampede

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.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

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.

Step 2 · Hypothesis

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.

Step 3 · Measurement

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.

shell
java -cp out:caffeine-3.1.8.jar CacheLab stampede java -Dbackend.ms=100 -cp out:caffeine-3.1.8.jar CacheLab stampede
Step 4 · Diagnosis
real output
backend.ms=20 naive backend calls= 500 latency p50= 112.4 ms p99= 203.2 ms max= 203.3 ms single-flight (computeIfAbsent future) backend calls= 1 latency p50= 19.4 ms p99= 24.7 ms max= 27.3 ms caffeine get(key, loader) backend calls= 1 latency p50= 0.0 ms p99= 27.4 ms max= 28.9 ms backend.ms=100 naive backend calls= 500 latency p50= 595.6 ms p99=1018.7 ms max=1022.0 ms single-flight (computeIfAbsent future) backend calls= 1 latency p50= 98.1 ms p99= 132.3 ms max= 133.3 ms caffeine get(key, loader) backend calls= 1 latency p50= 0.0 ms p99= 119.7 ms max= 123.4 ms
One expiry, three strategies
A hot key expires: 500 concurrent readers (backend 20 ms, 50 concurrent calls) - measured backend calls caused by ONE expiry naive cache-aside 500 calls single-flight (future map) 1 call Caffeine get(key, loader) 1 call latency p99 of the 500 readers (ms) naive cache-aside 203.2 ms single-flight (future map) 24.7 ms Caffeine get(key, loader) 27.4 ms Naive: 500 identical database calls queue behind 50 slots (10 waves x 20 ms): p50 112 ms, p99 203 ms.

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.

Mechanism
Why single-flight works: a map of in-flight loads (mechanism) reader 1 reader 2 readers 3..500 inflight map key 7 -> CompletableFuture computeIfAbsent: ONE winner database 1 call (20 ms) winner loads The other 499 callers receive the SAME future and wait for the one load; when it completes they all return. Remember to remove the entry when done, or to put the value in the real cache, and never cache a failed future. Across pods, each pod does its own single-flight: N pods = N loads. Use a lock/lease in Redis if that still hurts.

Illustration of the in-flight map used in our CacheLab (a ConcurrentHashMap of CompletableFuture).

Step 5 · Optimization
Java - single-flight in three flavours
// 1) Caffeine: get(key, loader) already does it per key OrderDto dto = cache.get(id, this::loadFromDatabase); // 2) Hand-rolled with futures (what CacheLab does) CompletableFuture<OrderDto> f = inflight.computeIfAbsent(id, k -> CompletableFuture.supplyAsync(() -> loadFromDatabase(k))); OrderDto dto = f.join(); // everybody shares one load; remove the entry when it completes // 3) Spring's cache abstraction: sync = true (provider must support it) @Cacheable(cacheNames = "orders", key = "#id", sync = true) public OrderDto find(long id) { ... }

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).

Step 6 · Verification
Strategy (500 readers, 20 ms backend)Backend callsp50p99
Naive cache-aside500112.4 ms203.2 ms
Single-flight (future map)119.4 ms24.7 ms
Caffeine get(key, loader)1see note27.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.)

⚠ Production gotchas: stampedes
  • 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).
05
Chapter Five - Lab Three - Synchronised Expiry and Jitter

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.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

Database CPU shows a sawtooth with exactly the cache TTL as its period: calm, then a 2,000-query burst, then calm again.

Step 2 · Hypothesis

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.

Step 3 · Measurement

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.

shell
java -cp out:caffeine-3.1.8.jar CacheLab jitter
Step 4 · Diagnosis
real output - loads per 250 ms bucket (first 24 buckets)
TTL 2 s, jitter +/- 0% backend loads per 250 ms: 2000 0 0 0 0 0 0 0 2000 0 0 0 0 0 0 0 2000 0 0 0 0 0 0 0 peak after warm-up=2000 TTL 2 s, jitter +/-20% backend loads per 250 ms: 2000 0 0 0 0 0 261 638 606 495 0 0 0 63 284 441 576 385 205 46 21 93 243 381 peak after warm-up=638
Synchronised expiry vs jittered expiry
2,000 keys loaded together, TTL 2 s: backend loads per 250 ms bucket (measured, 6 s) TTL 2 s exactly (no jitter) 0 2000 time (24 buckets of 250 ms) TTL 2 s +/- 20 % jitter 0 2000 time (24 buckets of 250 ms) peak 2000 loads in 250 ms, three times peak after warm-up: 638 (3.1x lower), spread over ~1.5 s

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.

Step 5 · Optimization
Java - jittered TTL
Duration base = Duration.ofSeconds(60); long jitterMs = (long) (base.toMillis() * 0.2); Duration ttl = base.plusMillis(ThreadLocalRandom.current().nextLong(-jitterMs, jitterMs + 1)); redis.opsForValue().set(key, json, ttl); // Spring Data Redis: per-key TTL

(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.)

Step 6 · Verification
No jitter±20 % jitter
Peak backend loads in one 250 ms bucket (after cold start)2,000638
Shapethree spikes then silenceoverlapping, lower waves
Cold start (first bucket)2,0002,000 (jitter does not help)

Cold start needs different medicine: warm gradually, or keep serving stale while refreshing (refreshAfterWrite). Jitter only fixes repeating synchronisation.

⚠ Production gotchas: TTL
  • TTL = time since write, not since access. expireAfterAccess keeps 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.
06
Chapter Six - Lab Four - Redis, ElastiCache and Serialization

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.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

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.

Step 2 · Hypothesis

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.

Step 3 · Measurement

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.

Step 4 · Diagnosis
real output - SerLab (two runs)
one order (4 items): JSON 264 bytes, Java serialization 392 bytes Jackson JSON : write 2.50 us read 3.05 us (round trip 5.55 us) Java serialization: write 4.67 us read 16.32 us (round trip 20.99 us) one order (4 items): JSON 264 bytes, Java serialization 392 bytes Jackson JSON : write 1.86 us read 3.27 us (round trip 5.13 us) Java serialization: write 3.37 us read 16.16 us (round trip 19.53 us)
Serialization is part of the cost of a remote hit
Serializing one order (4 items) for a cache - measured bytes in the cache JSON (Jackson) 264 B Java serialization 392 B CPU per round trip (write + read), microseconds JSON (Jackson) 5.1 us Java serialization 19.5 us Best of two runs: JSON 5.1-5.6 us, Java serialization 19.5-21.0 us. Both small next to a ~0.5 ms (illustrative) network hop, but not free at 50,000 ops/s: 20 us x 50,000 = one full core on serialization alone.

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.

Hot keys and shards
ElastiCache for Redis cluster mode (illustrative topology): where a hot key hurts 20 OrderService pods each: L1 Caffeine + Redis client pool Shard 1 primary slots 0-5460 Shard 2 primary HOT KEY order:42 one core: 100 % CPU Shard 3 primary slots 10923-16383 replica read scaling replica cannot split one key replica read scaling Fixes for hot keys 1 L1 near-cache 2 key shards order:42#0..7 3 replicas for reads Keys map to slots (CRC16 mod 16384); one key = one slot = one shard primary. Redis executes commands on one thread per shard.

Illustrative topology; behaviour per Redis cluster documentation.

Step 5 · Optimization
Java - two-level cache with Spring Data Redis, per-cache TTL and JSON values
@Bean RedisCacheManager cacheManager(RedisConnectionFactory cf) { RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .disableCachingNullValues() // or cache a short-lived sentinel (negative caching) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(cf) .cacheDefaults(base) .withCacheConfiguration("orders", base.entryTtl(Duration.ofMinutes(5))) .build(); }

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.

Step 6 · Verification

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.

Redis vs Caffeine - choosing
Caffeine (in-process)Redis / ElastiCache
Hit costabout a microsecond or less, no serializationnetwork round trip + serialization
Sharingper pod; each warms separatelyshared by all pods; survives pod restarts
Capacityheap (competes with the app; GC pressure)dedicated memory; scale by shards
Invalidationhard across pods (needs pub/sub)one place
Failuredies with the podoutage = stampede on DB; needs a plan
Typical usetiny hot sets, config, reference data; L1 in front of Redissessions, shared lookups, rate limits, large hot sets
⚠ Production gotchas: distributed caches
  • 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.
07
Chapter Seven - Lab Five - Invalidation, Races and Negative Caching

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.

1 · Problemsymptom + number
2 · Hypothesisone suspect
3 · Measurementtool + command
4 · Diagnosiswhat the data says
5 · OptimizationONE variable
6 · Verificationbefore / after
Step 1 · Problem

Support reports orders showing "PAYMENT_PENDING" for ten minutes after payment succeeded. The database is right; the cache is wrong.

Step 2 · Hypothesis

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.

Step 3 · Measurement

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.

Step 4 · Diagnosis
The race
The stale-cache race with cache-aside (why deleting on write is not always enough) Reader A Writer B Cache DB A: cache miss; reads DB: value v1 B: UPDATE order to v2 B: DELETE cache key A: SET cache = v1 (stale!) Cache now holds v1 although DB has v2; stays wrong until TTL expires Window is small (A's read-to-fill gap) but real at scale. TTL bounds the damage; versioned keys, leases or delayed re-delete close it. Prefer DELETE over UPDATE on write: re-deriving the value on next read avoids writing a value that raced.

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.

Step 5 · Optimization
StrategyHowConsistencyCost / risk
TTL onlyevery entry expires after N secondsstale up to Nsimplest; safety net for all other strategies
Evict on writeafter commit, delete the key (@CacheEvict)near-fresh; race abovemiss after every write; do it AFTER the transaction commits (@TransactionalEventListener(AFTER_COMMIT))
Delayed double deletedelete, then delete again after ~1 scloses most racesextra work; picks a magic delay
Versioned keysorder:42:v17; readers learn the version from a cheap sourcestrong per versionextra lookup for the version
Event-drivenDB change stream / outbox publishes "order 42 changed"; consumers evicteventual, decoupledinfrastructure; events can be lost/reordered
Write-throughwrites update cache and DB in one pathfresh if all writers use itevery 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.

Step 6 · Verification

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).

Negative caching and penetration

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.

⚠ Production gotchas: invalidation
  • 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 @CacheEvict works 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.
08
Chapter Eight - Lab Six - HTTP and CDN Caching

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.

Four layers in series
Layered caches multiply: what the database actually sees (ILLUSTRATIVE arithmetic) 1,000 requests from users CDN 60 % hit 400 pass L1 Caffeine 70 % 120 pass Redis 75 % 30 pass Database 30 queries Hit ratios (60 %, 70 %, 75 %) are example values. Combined: 1 - 0.4 x 0.3 x 0.25 = 97 % of requests never reach the DB. But each layer's MISS is the next layer's load, and invalidation has to reach every layer (CDN purge, L1 pub/sub, Redis delete). Caution: a user-specific or authenticated response must NOT be cached by a shared CDN (Cache-Control: private / Vary).

Illustrative arithmetic; not measured.

The headers that matter
Header / directiveMeaningUse for OrderService
Cache-Control: max-age=60any cache may reuse the response for 60 s without askingpublic reference data (product catalogue)
Cache-Control: privateonly the browser may cache; shared caches must notper-user data such as GET /orders/{id} behind auth
s-maxage=300like max-age but for shared caches (CDN) onlylet the CDN keep it longer than browsers
ETag + If-None-Match → 304conditional request: server skips the body when unchangedbig, rarely changing responses; still costs a request
stale-while-revalidate=30, stale-if-error=600serve stale while refreshing / when origin errors (RFC 5861)smooths expiry and survives origin trouble; CloudFront honours these directives (check current docs)
Vary: Accept-Encoding, Authorizationcache key includes these request headerswrong Vary = serving user A's response to user B, or no hits at all
Cache-Control: no-storenever write to any cachesensitive payloads (tokens, PII)
Java - Spring MVC response with explicit cache policy and an ETag
@GetMapping("/products/{id}") ResponseEntity<ProductDto> product(@PathVariable long id) { ProductDto dto = service.find(id); return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(Duration.ofSeconds(60)).cachePublic().staleWhileRevalidate(Duration.ofSeconds(30))) .eTag("\"" + dto.version() + "\"") // strong validator derived from a version column .body(dto); }

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.

⚠ Production gotchas: HTTP caching
  • Caching authenticated responses on a shared cache. Missing private/Vary leaks 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-Encoding together.
09
Chapter Nine - Spring Recap

@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.

Java - the performance-relevant options
@Service class OrderQueries { @Cacheable(cacheNames = "orders", key = "#id", sync = true, // single-flight per key (stampede control) unless = "#result == null") // do not cache "not found" by accident public OrderDto find(long id) { return load(id); } @CacheEvict(cacheNames = "orders", key = "#cmd.orderId()") // evict by key; run AFTER commit (see ch. 7) public void apply(UpdateOrder cmd) { ... } } # application.yml - Caffeine as the CacheManager spring.cache.type: caffeine spring.cache.caffeine.spec: maximumSize=5000,expireAfterWrite=60s,recordStats management.endpoints.web.exposure.include: health,metrics,prometheus # cache.gets{result=hit|miss} appears under metrics
ConcernAnnotation / settingPage section
Stampedesync = true or Caffeine loaderchapter 4
Hit ratio visibilityrecordStats + Micrometer cache.getschapter 3
StalenessTTL + evict-after-commitchapter 7
SerializationJSON serializer for Redis valueschapter 6
Negative cachingunless / short-TTL sentinelchapter 7
Self-invocation trapcall through the proxy / move method to another beanAOP
10
Chapter Ten - Try It Yourself

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.

shell
javac -cp caffeine-3.1.8.jar -d out CacheLab.java javac -cp $JACKSON -d out SerLab.java java -Dbackend.cap=1000 -cp out:caffeine-3.1.8.jar CacheLab ratio java -Drequests=30000 -Dbackend.cap=50 -cp out:caffeine-3.1.8.jar CacheLab ratio java -cp out:caffeine-3.1.8.jar CacheLab stampede java -Dbackend.ms=100 -cp out:caffeine-3.1.8.jar CacheLab stampede java -cp out:caffeine-3.1.8.jar CacheLab jitter java -cp out:$JACKSON SerLab
CacheLab.java - hit ratio, stampede and jitter
Java - ran in chapters 3, 4 and 5
import com.github.benmanes.caffeine.cache.*; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; /** * Lab 9: cache-aside in front of a simulated 20 ms backend (the database behind GET /orders/{id}). * ratio : Zipf traffic, Caffeine maximumSize sweep -> measured hit ratio and average latency vs the formula * stampede : one hot key expires, 500 concurrent readers: naive vs single-flight (backend capacity = 50 concurrent calls) * jitter : 2,000 keys loaded together with the same TTL vs TTL +/- 20 %: backend calls per 250 ms bucket */ public class CacheLab { static final AtomicLong backendCalls = new AtomicLong(); static final Semaphore BACKEND_CAPACITY = new Semaphore(Integer.getInteger("backend.cap", 50)); // the DB can serve 50 queries at once /** The "database": 20 ms per call, at most 50 in flight. */ static String backend(long key) { backendCalls.incrementAndGet(); try { BACKEND_CAPACITY.acquire(); try { Thread.sleep(Integer.getInteger("backend.ms", 20)); } finally { BACKEND_CAPACITY.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return "order-" + key; } public static void main(String[] a) throws Exception { switch (a[0]) { case "ratio" -> ratio(); case "stampede" -> stampede(); case "jitter" -> jitter(); } System.exit(0); } // ---------------------------------------------------------------- ratio static void ratio() throws Exception { int keys = 100_000, requests = Integer.getInteger("requests", 200_000), workers = 200; double[] cdf = zipfCdf(keys, 1.0); System.out.println("100,000 distinct orders, Zipf(s=1.0) traffic, " + requests + " requests, 200 concurrent callers, backend = 20 ms, capacity = " + BACKEND_CAPACITY.availablePermits() + " concurrent calls"); System.out.println("cache size hit ratio measured avg formula h*t_c+(1-h)*20ms backend calls"); for (int size : new int[]{0, 100, 1_000, 5_000, 20_000, 100_000}) { Cache<Long, String> cache = size == 0 ? null : Caffeine.newBuilder().maximumSize(size).build(); backendCalls.set(0); AtomicLong hits = new AtomicLong(), totalNanos = new AtomicLong(), hitNanos = new AtomicLong(); AtomicInteger next = new AtomicInteger(); Random seedRnd = new Random(42); long[] seq = new long[requests]; for (int i = 0; i < requests; i++) seq[i] = sample(cdf, seedRnd.nextDouble()); try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) { for (int w = 0; w < workers; w++) ex.submit(() -> { int i; while ((i = next.getAndIncrement()) < requests) { long k = seq[i], t0 = System.nanoTime(); String v = cache == null ? null : cache.getIfPresent(k); if (v != null) { hits.incrementAndGet(); hitNanos.addAndGet(System.nanoTime() - t0); } else { v = backend(k); if (cache != null) cache.put(k, v); } totalNanos.addAndGet(System.nanoTime() - t0); } return null; }); } double h = hits.get() / (double) requests; double tc = hits.get() == 0 ? 0 : hitNanos.get() / (double) hits.get() / 1e6; double measured = totalNanos.get() / (double) requests / 1e6; double formula = h * tc + (1 - h) * 20.0; System.out.printf("%9d %8.1f%% %9.2f ms %17.2f ms %17d%n", size, h * 100, measured, formula, backendCalls.get()); } } static double[] zipfCdf(int n, double s) { double[] c = new double[n]; double sum = 0; for (int i = 1; i <= n; i++) { sum += 1.0 / Math.pow(i, s); c[i - 1] = sum; } for (int i = 0; i < n; i++) c[i] /= sum; return c; } static long sample(double[] cdf, double u) { int lo = 0, hi = cdf.length - 1; while (lo < hi) { int m = (lo + hi) >>> 1; if (cdf[m] < u) lo = m + 1; else hi = m; } return lo; } // ---------------------------------------------------------------- stampede record Entry(String value, long expiresNanos) {} static void stampede() throws Exception { System.out.println("hot key (a popular order) just expired; 500 requests arrive within ~10 ms; backend: 20 ms, 50 concurrent"); for (String mode : new String[]{"naive", "single-flight (computeIfAbsent future)", "caffeine get(key, loader)"}) { backendCalls.set(0); ConcurrentHashMap<Long, CompletableFuture<String>> inflight = new ConcurrentHashMap<>(); Cache<Long, String> caf = Caffeine.newBuilder().maximumSize(10).build(); long[] lat = new long[500]; CountDownLatch go = new CountDownLatch(1); try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 500; i++) { final int id = i; ex.submit(() -> { go.await(); long t0 = System.nanoTime(); long key = 7; if (mode.startsWith("naive")) backend(key); else if (mode.startsWith("single")) { CompletableFuture<String> f = inflight.computeIfAbsent(key, k -> CompletableFuture.supplyAsync(() -> backend(k))); f.join(); } else caf.get(key, CacheLab::backend); lat[id] = System.nanoTime() - t0; return null; }); } Thread.sleep(200); go.countDown(); } Arrays.sort(lat); System.out.printf("%-40s backend calls=%4d latency p50=%6.1f ms p99=%6.1f ms max=%6.1f ms%n", mode, backendCalls.get(), lat[250] / 1e6, lat[495] / 1e6, lat[499] / 1e6); } } // ---------------------------------------------------------------- jitter static void jitter() throws Exception { int keys = 2000; for (double jit : new double[]{0.0, 0.2}) { ConcurrentHashMap<Long, Entry> map = new ConcurrentHashMap<>(); int buckets = 24; int[] perBucket = new int[buckets]; // 250 ms buckets = 6 s long start = System.nanoTime(); long ttlNanos = 2_000_000_000L; Random rnd = new Random(1); AtomicInteger counter = new AtomicInteger(); long end = start + 6_000_000_000L; try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) { for (long k = 0; k < keys; k++) { final long key = k; ex.submit(() -> { // every key is read every ~50 ms (a hot working set) Random r = ThreadLocalRandom.current(); while (System.nanoTime() < end) { Entry e = map.get(key); long now = System.nanoTime(); if (e == null || e.expiresNanos() < now) { // load from "DB" (no capacity limit here: we only count calls) then set TTL int b = (int) ((now - start) / 250_000_000L); if (b < buckets) synchronized (perBucket) { perBucket[b]++; } try { Thread.sleep(20); } catch (InterruptedException ie) { } double factor = 1.0 + (r.nextDouble() * 2 - 1) * jit; map.put(key, new Entry("v", System.nanoTime() + (long) (ttlNanos * factor))); } try { Thread.sleep(50); } catch (InterruptedException ie) { } } return null; }); } } StringBuilder sb = new StringBuilder(); int peak = 0; for (int v : perBucket) peak = Math.max(peak, v); for (int v : perBucket) sb.append(String.format("%5d", v)); int peak2 = 0; for (int i = 1; i < buckets; i++) peak2 = Math.max(peak2, perBucket[i]); System.out.printf("TTL 2 s, jitter +/-%2.0f%% backend loads per 250 ms:%s peak after warm-up=%d%n", jit * 100, sb, peak2); } } }
SerLab.java - serialization cost of a cache value
Java - ran in chapter 6
import com.fasterxml.jackson.databind.ObjectMapper; import java.io.*; import java.util.*; /** Lab 9: what does it cost to put an order into a distributed cache? Bytes and CPU for three serialisers. */ public class SerLab { public static class Item implements Serializable { public String sku; public int qty; public double unitPrice; public Item() {} Item(String s, int q, double p) { sku = s; qty = q; unitPrice = p; } } public static class Order implements Serializable { public long id; public long customerId; public String status; public String createdAt; public List<Item> items; public Order() {} } static Order order(long i) { Order o = new Order(); o.id = i; o.customerId = 7; o.status = "PAID"; o.createdAt = "2026-10-30T10:15:30Z"; o.items = new ArrayList<>(); for (int k = 0; k < 4; k++) o.items.add(new Item("SKU-" + (1000 + i * 7 + k), 1 + k, 9.99 + k)); return o; } static byte[] javaSer(Order o) throws Exception { ByteArrayOutputStream bo = new ByteArrayOutputStream(); try (ObjectOutputStream oo = new ObjectOutputStream(bo)) { oo.writeObject(o); } return bo.toByteArray(); } static Order javaDeser(byte[] b) throws Exception { try (ObjectInputStream oi = new ObjectInputStream(new ByteArrayInputStream(b))) { return (Order) oi.readObject(); } } public static void main(String[] a) throws Exception { ObjectMapper om = new ObjectMapper(); Order o = order(42); byte[] j = om.writeValueAsBytes(o), s = javaSer(o); System.out.printf("one order (4 items): JSON %d bytes, Java serialization %d bytes%n", j.length, s.length); int warm = 20000, runs = 100000; for (int i = 0; i < warm; i++) { om.readValue(om.writeValueAsBytes(o), Order.class); javaDeser(javaSer(o)); } long t0 = System.nanoTime(); for (int i = 0; i < runs; i++) om.writeValueAsBytes(o); double jw = (System.nanoTime() - t0) / 1e3 / runs; t0 = System.nanoTime(); for (int i = 0; i < runs; i++) om.readValue(j, Order.class); double jr = (System.nanoTime() - t0) / 1e3 / runs; t0 = System.nanoTime(); for (int i = 0; i < runs; i++) javaSer(o); double sw = (System.nanoTime() - t0) / 1e3 / runs; t0 = System.nanoTime(); for (int i = 0; i < runs; i++) javaDeser(s); double sr = (System.nanoTime() - t0) / 1e3 / runs; System.out.printf("Jackson JSON : write %6.2f us read %6.2f us (round trip %6.2f us)%n", jw, jr, jw + jr); System.out.printf("Java serialization: write %6.2f us read %6.2f us (round trip %6.2f us)%n", sw, sr, sw + sr); System.out.println("A same-AZ cache round trip on the network is hundreds of microseconds (illustrative) - so serialization is not free relative to it."); } }
Make it yours: Replace the Zipf sampler with a replay of your real key log (sample from access logs), set the backend delay and capacity to your database's p50 and connection budget, and re-run chapter 3 to size the cache from your knee. Run each experiment several times: the machine used here was shared and absolute latencies varied by 2x between runs.
11
Chapter Eleven - Interview Angles

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.
12
Chapter Twelve - Summary

Cheat Sheet and Recap

Cheat sheet
SymptomCauseFixMeasured here
DB overloaded by repeated identical readsno cache / skewed trafficCaffeine sized from the hit-ratio knee58 % hit ratio at 1,000 entries; avg 8.5 ms vs 21 ms
Latency spike when a hot key expiresstampedesingle-flight, refresh-ahead500 calls to 1
DB sawtooth with period = TTLsynchronised expiryjittered TTLpeak 2000 to 638
Stale data after writesrace / missed invalidationevict after commit, versions, TTLrace reproduced as mechanism
Remote cache slower than expectedserialization + RTTJSON/binary format, L1 in frontJSON 264 B / 5 us vs Java 392 B / 20 us
One shard at 100 % CPUhot keynear-cache, key shardingnot measured
Cache outage takes the service downno fallbacktimeouts, fail-open + limiter, stalenot measured
∑ Caching - what to remember
  • 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.
Related pages: