System Design Masterclass · Module 01 / Week 1

Caching

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.

~3h study~2h build3 interactive simsJava · Spring Boot · Redis · RabbitMQ

Fast track — already know caching?

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.

  1. Read the §01–§03 takeaways; run the request-flow sim once · 10 min
  2. §04 Invalidation in full + race-timeline sim · 25 min · MANDATORY
  3. §05 Failure modes in full + stampede sim · 25 min · MANDATORY
  4. Code B (AFTER_COMMIT relay) and C (single-flight) only · 10 min
  5. Exercise steps 5–6 only: stampede counter + break-it-on-purpose · 45 min
  6. 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 datasetCache?Why
Catalog / series metadata✅ AggressivelyRead: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 careCache validity, but revocation must be near-instant → separate revocation set
Entitlement immediately after purchase⚠️ BypassStale read = paying user blocked (your 428 / license-not-granted incident class). Read-your-writes required on this path.
Playback heartbeat position❌ as read cacheMutates 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.

App ──write──▶ Cache/buffer ──ack ✓ └─ async batch ──▶ MQ ──▶ consumer ──▶ DB

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.

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 DB coalesced (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:

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

FailureMechanismMitigation
PenetrationRequests for nonexistent keys (bad IDs, scraping) bypass cache every timeCache negative results with short TTL; Bloom filter of valid IDs for hostile traffic
AvalancheRedis restart → total miss → DB collapsesL1 tier keeps serving; cache warming on deploy; circuit breaker on the DB path; staggered TTLs
Hot key / hot shardOne key (live match) saturates a single Redis shard's CPUL1 absorbs per-pod; replicate key to N suffixed copies, read random; push value to pods via pub/sub
Large values2 MB blob serialized per read burns network + CPURedis hashes + HMGET only needed fields; compress (LZ4); cap value size in review
Silent hit-ratio decayRatio drifts 95→70% — DB load 6×, nobody notices until it tipsAlert 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 @EnableCaching
public class CacheConfig {

  @Bean // L1 — per-pod, absorbs hot keys
  CaffeineCacheManager l1() {
    var m = new CaffeineCacheManager("contentMeta");
    m.setCaffeine(Caffeine.newBuilder()
        .maximumSize(20_000).expireAfterWrite(Duration.ofMinutes(5)));
    return m;
  }

  @Bean // L2 — cluster-wide, JSON values, 30m TTL
  RedisCacheManager l2(RedisConnectionFactory cf) {
    return RedisCacheManager.builder(cf)
      .withCacheConfiguration("contentMeta",
        RedisCacheConfiguration.defaultCacheConfig()
          .entryTtl(Duration.ofMinutes(30))
          .serializeValuesWith(SerializationPair.fromSerializer(
              new GenericJackson2JsonRedisSerializer())))
      .build();
  }
}

B · Event-driven invalidation — note AFTER_COMMIT

// writer: content-service
@Transactional
public void updateContent(String id, ContentUpdate req) {
  repository.save(apply(req));
  events.publishEvent(new ContentUpdated(id)); // in-process, tx-bound
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // ← the fix from §04
public void relay(ContentUpdated e) {
  rabbit.convertAndSend("content.events", "content.updated", e);
}

// consumer: any caching service
@RabbitListener(queues = "playback.content-invalidation")
public void on(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

public ContentMeta get(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 have
      if (recheck != null) return deser(recheck);    // loaded between our miss and this lock
      ContentMeta 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.