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 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 flagsReference data; changes are rare and event-driven
Recommendation railsExpensive 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 queriesHit 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.