Staff-level treatment: strategy selection under real constraints, multi-tier topology, invalidation correctness, and the failure modes that page you at 2 AM. All implementations in Java 17 / Spring Boot 3 against Redis + RabbitMQ.
Compresses this module to ~90–120 min. Sections marked SKIM collapse to a one-paragraph takeaway (expandable per section); MANDATORY sections stay in full — they carry the failure modes and correctness details that bite even experienced engineers.
Read the §01–§03 takeaways; run the request-flow sim once · 10 min
§04 Invalidation in full + race-timeline sim · 25 min · MANDATORY
§05 Failure modes in full + stampede sim · 25 min · MANDATORY
Code B (AFTER_COMMIT relay) and C (single-flight) only · 10 min
All five self-check questions, answered aloud before expanding · 10 min
STEP 01
Why caching exists: the latency hierarchy
SKIM
Fast-track takeawayMemorize ratios, not digits: in-JVM cache is ~1,000× faster than Redis; Redis is ~10–30× faster than an indexed DB query. The staff-level skill is hit-ratio arithmetic — at 50k RPS, a hit ratio slipping 95%→70% is a 6× step change in DB load. Capacity planning is a caching question.
Every cache exploits one asymmetry: memory access is orders of magnitude cheaper than network or disk access, and access patterns are skewed — a small working set absorbs most reads. On an OTT platform the skew is extreme: one live match manifest or trending series can be >10% of total read traffic.
Latency hierarchy · log scale
Log scale — each equal step is ~10×. Internalize the ratios, not the digits: L1-in-JVM is ~1,000× faster than Redis; Redis is ~10–30× faster than an indexed DB query.
Staff expectation
You're expected to do back-of-envelope math from these numbers without looking them up. Example: 50k RPS on catalog reads at 95% Redis hit ratio → 2,500 RPS reach the DB. If hit ratio degrades to 70%, DB load is 15,000 RPS — a 6× step change from a "small" cache regression. Capacity planning is a caching question.
STEP 02
When to cache — and when not to
SKIM
Fast-track takeawayOne rule: if you can't state an acceptable staleness window for a dataset, don't cache it — that window is your TTL. Glance at the dataset table once; the trap row is entitlement immediately after purchase, where a stale read blocks a paying user (your 428 incident class) and the read path must bypass the cache.
The decision is a one-line inequality:
Decision rule
Cache when (miss cost × achievable hit ratio) outweighs (staleness risk + operational complexity). If you cannot state an acceptable staleness window for the data, don't cache it yet — that window is your TTL, and "I don't know" means you haven't classified the data.
OTT dataset
Cache?
Why
Catalog / series metadata
✅ Aggressively
Read:write ≥ 1000:1; staleness of minutes is invisible to users
EPG, plans, pricing, feature flags
✅
Reference data; changes are rare and event-driven
Recommendation rails
✅
Expensive to compute; per-user TTL of minutes acceptable
Auth tokens / device sessions
✅ with care
Cache validity, but revocation must be near-instant → separate revocation set
Entitlement immediately after purchase
⚠️ Bypass
Stale read = paying user blocked (your 428 / license-not-granted incident class). Read-your-writes required on this path.
Playback heartbeat position
❌ as read cache
Mutates every few seconds; write-behind buffer instead (§03)
Long-tail search queries
❌
Hit ratio ≈ 0; you pay memory for nothing
STEP 03
Strategies & the multi-tier topology
SKIM
Fast-track takeawayYou know the four strategies; strategy selection is per-dataset, not per-system. Two nuggets worth 60 seconds each: Caffeine's refreshAfterWrite refreshes hot keys asynchronously before expiry so they never miss, and write-behind consumers must be idempotent with version/timestamp ordering because MQ redelivery reorders writes. Run the request-flow sim once to calibrate the L1/L2/DB gap.
First, feel the difference between a hit and a miss. Step a request through the production topology:
Request flow simulator · L1 → L2 → DB
Client
request
L1 · Caffeine
in-JVM · ~100 ns
L2 · Redis
network · ~300 µs
Database
indexed · ~5 ms
TOTAL LATENCY —
VS L1 HIT —
Pick a scenario. Watch where the time goes.
Now the four write/read strategies. The tabs matter because strategy selection is per-dataset, not per-system — a real platform runs all four simultaneously.
App ──1──▶ Cache ── hit? ──▶ return
│ │ miss
└──2──▶ DB ──3──▶ Cache.set(ttl) ──▶ return
The default. App owns the logic; cache is a passive store. On Redis outage you degrade to the DB instead of failing — the resilience property that makes this the baseline choice.
Strengths
Only requested data cached (memory-efficient)
Cache failure ≠ request failure
Trivially reasoned about
Costs
First read pays full miss (3 hops)
Stampede-prone on hot-key expiry (§05)
Every service reimplements the dance → drift
App ──▶ Cache (LoadingCache) ── miss ──▶ loader ──▶ DB
└────────── always answers ──────────┘
The cache owns loading; the app only ever talks to the cache. In Java this is Caffeine's LoadingCache — and its killer feature is refreshAfterWrite: hot keys refresh asynchronously before expiry, serving the stale value during reload. Hot keys never see a miss.
LoadingCache<String, ContentMeta> cache = Caffeine.newBuilder()
.maximumSize(50_000) // W-TinyLFU eviction — beats LRU and LFU
.expireAfterWrite(Duration.ofMinutes(10))
.refreshAfterWrite(Duration.ofMinutes(5)) // async refresh; also coalesces loads per key
.build(repository::loadById);
App ──write──▶ Cache ──sync──▶ DB (both or neither)
Every write updates cache and DB synchronously. You pay write latency to buy read-your-writes on the cached path — the property the post-purchase entitlement flow needs. Use narrowly: it caches data that may never be read and couples write availability to the cache.
Acknowledge on cache write; persist asynchronously, batched. This is the correct shape for playback heartbeats: absorb a write storm in Redis, drain to the DB through RabbitMQ at a rate the DB tolerates. You already run this topology — "write to Redis, event to MQ, consumer persists" is write-behind by its formal name.
Strengths
Write latency ≈ cache latency
Batching flattens DB write spikes
Costs
Loss window if buffer dies pre-flush
Reordering: consumer must be idempotent & last-write-wins by timestamp/version
Staff expectation
The interview-and-design-review question is never "which strategy is best" — it's "show me your dataset → strategy → staleness-budget mapping, and defend the entitlement row." The table in §02 plus one strategy per row is a complete answer.
STEP 04
Invalidation: the correctness problem
MANDATORY
Fast trackRead in full. The publish-before-commit race below is the highest-value five minutes in this module — it's subtle, common in event-driven estates like yours, and invisible in code review unless you know to look.
Ordered by sophistication. Rule zero: always set a TTL even when using explicit invalidation — it bounds the blast radius of every invalidation bug you will ever ship.
TTL only. TTL = maximum acceptable staleness. Sufficient for reference data.
Evict-on-write (@CacheEvict). Only reliable when writer and cacher are the same service. In a 200-microservice estate, they usually aren't.
Event-driven invalidation. Owner publishes ContentUpdated to RabbitMQ; every caching consumer evicts/refreshes. The correct pattern for your estate — with one deadly ordering rule, below.
Versioned keys. Write content:123:v42, swap a version pointer. Eviction becomes atomic; old versions age out via TTL. Eliminates delete-vs-concurrent-read races entirely.
Pub/Sub L1 broadcast. Redis PUBLISH invalidation content:123; every pod evicts its Caffeine entry. The only way to invalidate in-JVM caches across hundreds of pods.
The publish-before-commit race
The single most common event-driven invalidation bug. Watch both orderings:
Race timeline · invalidation vs. transaction commit
writer svc
consumer svc
cache state
The broken ordering caches the OLD value until TTL expiry — the classic "it fixed itself after 30 minutes" incident.
Fix: @TransactionalEventListener(phase = AFTER_COMMIT) — or, when you need a delivery guarantee that survives a crash between commit and publish, the transactional outbox (event row written in the same DB transaction, relayed to MQ by a poller/CDC). Outbox gets its own module in Week 3.
Eviction policy (when memory is full)
Distinct from invalidation. For a catalog with stable popularity, Redis maxmemory-policy allkeys-lfu usually beats LRU — one viral title shouldn't flush your steady-state working set. Caffeine's W-TinyLFU already approximates the best of both, which is a real reason to prefer it over hand-rolled maps.
STEP 05
Failure modes & trade-offs
MANDATORY
Cache stampede (thundering herd)
A hot key expires; every concurrent request misses simultaneously and lands on the DB. Run it:
Stampede simulator · 200 concurrent requests, hot key just expired
hit DBcoalesced (waited on lock)served from repopulated cache
DB QUERIES —
OF 200 REQUESTS —
But the stampede alone isn't what kills you. Watch what it triggers:
expiry ─▶ 1000s of misses ─▶ connection pool full (100) ─▶ queueing
─▶ p99: 20 ms → 2 s → timeout ─▶ clients & upstreams RETRY
─▶ retry storm ≫ original load ─▶ DB unavailable ─▶ every retry loops back
The cache miss is the spark; the retry feedback loop is the fire. Any complete answer mitigates both the stampede and the amplification.
Five mitigation families, cheapest first:
Jittered TTL — ttl = base + rand(0, 20%). Prevents synchronized expiry waves after deploys/warm-ups (the avalanche case). Costs one line. Limit: does nothing for a single extremely hot key — that one key still expires at some instant.
Single-flight — one loader per key, others wait. Per-JVM: Caffeine does it automatically. Cross-pod: Redis lock SET k v NX PX 3000 with a double-check of the cache after acquiring the lock (code in §06). Trade-off: losers block for the load duration, so you need lock TTLs against crashed loaders and bounded waits.
Stale-while-revalidate (soft/hard TTL) — store a logical expiry inside the value, keep the physical Redis TTL longer. Past soft expiry: serve the stale value instantly, let exactly one request refresh in the background. Losers never block — often the best production answer for catalog-class data. Same idea at the CDN via Cache-Control: stale-while-revalidate, stale-if-error plus request collapsing — for OTT manifests behind a CDN, this outermost layer matters most of all.
Proactive / probabilistic early refresh — refresh known hot keys before expiry. XFetch: refresh when now − delta·β·ln(rand()) ≥ expiry (delta = last recompute time, β≈1) — refresh probability rises smoothly as expiry nears, so the hot key never actually expires. Trade-off: wasted work on keys nobody reads anymore; reserve for known-hot data.
Protect the database itself — the cache is not the last line of defense. Bounded connection pools, short DB timeouts, exponential backoff with retry budgets, circuit breakers, and load shedding stop the feedback loop when everything above fails. Rejecting some requests beats losing all of them. (Circuit breakers, retries, and rate limiting each get a full Week 3 module.)
The composite read path
These compose into one algorithm — this is the design-review answer:
1 read cache
2 fresh? ─▶ return
3 stale-but-usable? ─▶ return stale + trigger ONE background refresh
4 missing? ─▶ single-flight: one loader, double-check under lock
5 write result with jittered TTL
6 DB path behind timeout + circuit breaker + load shed
The rest of the incident catalog
Failure
Mechanism
Mitigation
Penetration
Requests for nonexistent keys (bad IDs, scraping) bypass cache every time
Cache negative results with short TTL; Bloom filter of valid IDs for hostile traffic
Avalanche
Redis restart → total miss → DB collapses
L1 tier keeps serving; cache warming on deploy; circuit breaker on the DB path; staggered TTLs
Hot key / hot shard
One key (live match) saturates a single Redis shard's CPU
L1 absorbs per-pod; replicate key to N suffixed copies, read random; push value to pods via pub/sub
Large values
2 MB blob serialized per read burns network + CPU
Redis hashes + HMGET only needed fields; compress (LZ4); cap value size in review
Silent hit-ratio decay
Ratio drifts 95→70% — DB load 6×, nobody notices until it tips
Alert on hit ratio per key-pattern, not just Redis health
Staff expectation
Own the observability contract, not just the design: every cache ships with hit ratio per key-pattern, misses-per-key (surfaces hot keys), concurrent-loader count, cache load duration, p99 read latency, eviction rate, retry volume on the DB path, and memory fragmentation on a dashboard, with alerts on hit-ratio slope and expiry-correlated DB traffic spikes. A cache without metrics is a latent incident. And remember the systemic cost: every cache is a second source of truth — "why did this user see X" now requires cache-state forensics, so log hit/miss per request.
STEP 06
Production code: the three pieces that matter
SKIM
Fast-track takeawaySkip snippet A (two-tier config — boilerplate you've written before). Read B — the @TransactionalEventListener(AFTER_COMMIT) relay plus dual-tier eviction with pub/sub L1 broadcast — and C, the cross-pod single-flight via SET NX PX. B and C are the executable forms of the §04 race fix and the §05 stampede fix.
A · Two-tier config — Caffeine L1 + Redis L2
@Configuration@EnableCachingpublic classCacheConfig {
@Bean// L1 — per-pod, absorbs hot keysCaffeineCacheManagerl1() {
var m = newCaffeineCacheManager("contentMeta");
m.setCaffeine(Caffeine.newBuilder()
.maximumSize(20_000).expireAfterWrite(Duration.ofMinutes(5)));
return m;
}
@Bean// L2 — cluster-wide, JSON values, 30m TTLRedisCacheManagerl2(RedisConnectionFactory cf) {
returnRedisCacheManager.builder(cf)
.withCacheConfiguration("contentMeta",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeValuesWith(SerializationPair.fromSerializer(
newGenericJackson2JsonRedisSerializer())))
.build();
}
}
B · Event-driven invalidation — note AFTER_COMMIT
// writer: content-service@Transactionalpublic voidupdateContent(String id, ContentUpdate req) {
repository.save(apply(req));
events.publishEvent(newContentUpdated(id)); // in-process, tx-bound
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // ← the fix from §04public voidrelay(ContentUpdated e) {
rabbit.convertAndSend("content.events", "content.updated", e);
}
// consumer: any caching service@RabbitListener(queues = "playback.content-invalidation")
public voidon(ContentUpdated e) {
redis.delete("content:meta:" + e.id()); // L2
l1.invalidate(e.id()); // this pod's L1
redis.convertAndSend("l1-invalidation", e.id()); // every other pod's L1
}
C · Cross-pod single-flight — stampede protection
publicContentMetaget(String id) {
String key = "content:meta:" + id;
String hit = redis.opsForValue().get(key);
if (hit != null) return deser(hit);
Boolean lock = redis.opsForValue() // SET NX PX — one loader wins
.setIfAbsent(key + ":lock", "1", Duration.ofSeconds(3));
if (Boolean.TRUE.equals(lock)) {
try {
String recheck = redis.opsForValue().get(key); // DOUBLE-CHECK: another pod may haveif (recheck != null) return deser(recheck); // loaded between our miss and this lockContentMeta m = repo.findById(id).orElseThrow();
redis.opsForValue().set(key, ser(m), jittered(30)); // base + rand(0,20%)return m;
} finally { redis.delete(key + ":lock"); } // PX expiry guards abandoned locks on crash
}
return retryCacheRead(key, 3, Duration.ofMillis(100)) // losers: bounded wait,
.orElseGet(() -> repo.findById(id).orElseThrow()); // then DB as last resort
}
STEP 07
Exercise: build it, break it, measure it
MANDATORY
Fast trackCondensed exercise (~45 min): do steps 5–6 only — the stampede counter and the break-it-on-purpose race — on any existing cached service or a throwaway app. These two produce the evidence and the scar tissue; steps 1–4 you've effectively built before.
One Spring Boot service, ~2 hours. The point is producing your own latency table and DB-hit counter — measured numbers, not remembered ones.
1
Baseline.GET /content/{id} from Postgres/H2 with a simulated 50 ms DB latency. Load-test with wrk/JMeter; record p50/p99.
2
Add L2 (Redis cache-aside, 60 s jittered TTL). Measure again.
3
Add L1 (Caffeine, 5 s TTL, 1k entries). Measure again — you should now reproduce the §01 hierarchy from your own machine.
4
Invalidation.PUT /content/{id} → RabbitMQ event → evict both tiers. Verify against a second running instance (that's what makes L1 invalidation real).
5
Stampede. Evict a key, fire 500 concurrent GETs, count DB hits with an AtomicInteger. Add the single-flight lock; DB hits should drop to ~1.
6
Break it on purpose. Publish the invalidation event before commit and reproduce the §04 stale-cache race. Fix with AFTER_COMMIT. Seeing this race once inoculates you forever.
Acceptance: your measured latency table (none / L2 / L1+L2), the stampede counter evidence, and a one-paragraph mapping of your platform's real datasets onto the §02 staleness table.
Stretch:maxmemory 50mb + allkeys-lfu, load 100k keys under a Zipfian access pattern, and observe which keys survive.
Self-check
Why is cache-aside more resilient than read-through when Redis is down?
The app owns the fallback: a cache exception degrades to a direct DB read. In read-through the cache is the only data path — its availability bounds yours unless the provider has explicit fallback behavior.
When would you deliberately cache a null result?
Cache-penetration defense: repeated lookups for nonexistent keys (bad IDs, scrapers) otherwise hit the DB every time. Cache the negative with a short TTL so a later legitimate create isn't masked for long.
Hit ratio fell 96% → 70% after a deploy that didn't touch cache code. Three causes?
(1) Rolling restart wiped every pod's L1 at once — cold caches. (2) A key-format or serializer change made old entries unreadable — every read misses. (3) New code path reads uncached data or changed key cardinality (e.g., added a per-device dimension to the key), fragmenting the working set.
Why must invalidation publish after commit, and what survives a crash between the two?
Published pre-commit, a consumer can evict and re-read the old value before commit lands — permanently caching stale data until TTL. AFTER_COMMIT fixes ordering; the transactional outbox (event row in the same DB tx, relayed asynchronously) additionally survives a crash between commit and publish.
Live match starts, one Redis shard at 100% CPU. Mitigation, fastest first?
(1) Ensure/extend L1 caching of that key — deploys in minutes, absorbs per-pod. (2) Lengthen its TTL + async refresh so it never expires under load. (3) Replicate the key to N suffixed copies read randomly, spreading shards. (4) Structural: push the value to pods via pub/sub so reads never touch Redis during the event.