Source: lessons/day-06-cache-strategies-deep-dive.md

Day 6 — Cache Strategies Deep Dive

Goal: Go beyond the "which write strategy" from Day 5 into how real FAANG-scale caches actually work. By the end you should be able to: - Design cache invalidation at fleet scale using CDC (Change Data Capture) patterns - Reason about multi-region caches and cache consistency across geographies - Choose refresh patterns (refresh-ahead, background refresh, stale-while-revalidate) - Design tiered caches (L1 in-process + L2 distributed) for the best latency + cost trade-off - Read the classic cache case studies (Facebook Memcache, Netflix EVCache) and know what they got right - Instrument caches for observability (hit rate, latency, eviction, hot keys) - Avoid the top cache anti-patterns

Estimated time: 50–60 min read + reflection.


1. Recap and go beyond

Day 5 gave you the fundamentals — cache placement (6-layer cake), 4 write strategies (cache-aside / read-through / write-through / write-back), eviction (LRU/LFU/TTL), stampede prevention (single-flight), and hot keys.

Today we zoom into the harder parts of cache operations at scale: 1. Fleet-wide invalidation without race conditions 2. Multi-region cache consistency 3. Refresh patterns to preempt cache misses 4. Tiered caches for the best latency-per-dollar 5. Real systems: Memcache / EVCache 6. Observability — what to measure 7. Anti-patterns — what to never do


2. Fleet-wide invalidation — the CDC pattern

The problem. You have a Postgres primary, 5 replicas, 100 app servers, a distributed cache with 20 Redis nodes, an internal CDN, and a browser cache. When someone updates user:42's profile, how does every one of those layers know to invalidate the stale value?

Naive answer: "the app that wrote the DB also deletes the cache key." Fine for one process. Doesn't scale when: - Writes come from many services (batch jobs, admin tools, third-party webhooks). - You want to invalidate multiple derived caches (user:42 profile, user:42:friends, feed:for:42). - You need cross-region invalidation.

Change Data Capture (CDC) — the FAANG-scale pattern

Rather than each writer sending invalidations, the DB itself emits a stream of every change. Downstream consumers subscribe and invalidate.

    Postgres primary                         Kafka                                  Consumers
    ────────────────────                     ─────                                  ─────────
    ┌──────────────┐    WAL / logical repl   ┌───────┐    fan-out to consumers    ┌───────────────┐
    │  writes...   │─────────────────────────▶│ topic │─────────────────────────▶│ cache-inval    │
    │              │      (via Debezium /     │       │                            │ consumer       │
    │              │       Debezium-style)    └───────┘                            └───────────────┘
    └──────────────┘                              │                                ┌───────────────┐
                                                  ├───────────────────────────────▶│ search-index   │
                                                  │                                │ consumer       │
                                                  └───────────────────────────────▶│ analytics ETL  │
                                                                                   └───────────────┘
  • Debezium (or AWS DMS, GCP Datastream) reads the DB's WAL (write-ahead log) or binlog.
  • Emits every INSERT/UPDATE/DELETE as an event to Kafka (or another log).
  • Downstream consumers subscribe and react:
  • Cache invalidator deletes the affected keys from Redis.
  • Search-index consumer updates Elasticsearch.
  • Analytics consumer writes to a warehouse.
  • Feed builder recomputes derived data.

Why it works so well: - Atomicity: every DB write is guaranteed to appear in the stream. No race. - Decoupling: writers don't need to know about caches, search, or feeds. - Replay: if a consumer bugs out, you can rewind Kafka and reprocess. - Multi-target: the same event triggers many downstream updates.

Trade-offs: - Adds latency to invalidation (~10–100 ms typical). - Adds an entire streaming stack you have to operate. - Non-trivial to get exactly-once semantics right (Day 21 will cover).


3. Multi-region caches

Once your users span continents, you have two choices for how caches behave across regions:

3.1 Cache-per-region (independent)

Each region has its own Redis / Memcached cluster. No cross-region cache traffic. Simplest.

  • Pros: minimal latency (all cache hits are in-region, <1 ms); no cross-region bandwidth; blast radius contained (one region's cache outage doesn't affect others).
  • Cons: cache is warmed independently per region; less efficient (you cache the same thing 5×); cross-region invalidation still requires coordination.

When to use: most read-heavy apps. This is the default.

3.2 Geo-replicated cache

The cache itself replicates across regions (Redis Cluster with cross-region replication, or Aerospike / DynamoDB Global Tables + DAX).

  • Pros: consistent view; a write in us-east eventually shows up in eu-west.
  • Cons: replication lag (~100 ms cross-region best case); more complex; cross-region bandwidth cost.

When to use: when a stale cache in another region would be user-visible and problematic (e.g. financial pricing, inventory that must be strictly consistent globally).

3.3 The classic pattern: per-region cache + CDC-based invalidation

       us-east                                              eu-west
    ┌─────────────────┐                                 ┌─────────────────┐
    │ Postgres primary│                                 │ Read replica   │
    └────────┬────────┘                                 └────────┬────────┘
             │ WAL                                                │
             ▼                                                    ▼
    ┌─────────────────┐          global Kafka topic     ┌─────────────────┐
    │ CDC (Debezium)  │═══════════════════════════════▶│ CDC consumer    │
    └────────┬────────┘                                 └────────┬────────┘
             ▼                                                    ▼
      ┌───────────┐                                          ┌───────────┐
      │ us-east   │                                          │ eu-west   │
      │ Redis     │  (in-region only)                        │ Redis     │
      └───────────┘                                          └───────────┘

Writes go to the primary; CDC propagates events globally; each region's cache invalidator listens and evicts the affected keys locally. Reads are always in-region and fast.


4. Refresh patterns — preempt the miss

The problem: even with a hot cache, keys expire → the next read is a slow miss → user sees latency spike. Can we refresh before the key expires?

4.1 Refresh-ahead

When a cache read notices the key is close to expiry (say, within 10% of its TTL), it serves the value AND fires an async refresh in the background.

  • Great for hot keys with predictable access.
  • Only refreshes keys that are actually read (no wasted work on cold keys).
  • Standard in Caffeine (Java), some Redis client libraries.

4.2 Background refresh (scheduled)

A separate worker periodically refreshes hot keys unconditionally (before their TTL expires).

  • Good for a small set of always-hot keys (e.g. feature flags, top-level catalog).
  • Wasteful for large key spaces.

4.3 Stale-while-revalidate (HTTP / CDN)

The Cache-Control: stale-while-revalidate=30 header (RFC 5861) tells caches: "if the entry is within 30 s past its TTL, serve it stale AND fetch a fresh copy in the background."

  • Great for HTTP / CDN caches. Absorbs bursts of traffic on freshly-expired keys.
  • Users always get a fast response; the freshness bound is soft.

4.4 Comparison

Pattern Trigger Serves stale? Best for
Refresh-ahead Read close to TTL No (has fresh) Cache-level, hot reads
Background refresh Scheduler No Small fixed hot set
Stale-while-revalidate Read after TTL Yes CDN, HTTP, tolerable staleness

5. Tiered caches — the L1 + L2 pattern

Adding an in-process cache in front of a distributed cache is one of the highest-leverage optimizations in a real system.

                                Read path:
   ┌───────────────┐            1. Check L1 (in-process). Hit? Return in ~500 ns.
   │  App process  │            2. Miss? Check L2 (Redis). Hit? Return in ~1 ms.
   │               │            3. Miss? Query DB. Return in ~50 ms.
   │  ┌─────────┐  │
   │  │ L1 cache│  │  ← in-process (Caffeine, Node lru-cache, Python functools.lru_cache)
   │  │ ~500 ns │  │
   │  └─────────┘  │
   └───────┬───────┘
           │
           ▼
   ┌───────────────┐
   │ L2: Redis     │  ← distributed cache, shared across app fleet
   │ ~1 ms         │
   └───────┬───────┘
           │
           ▼
   ┌───────────────┐
   │ Postgres      │
   │ ~50 ms        │
   └───────────────┘

Why the L1 tier matters

  • L1 hit is ~2000× faster than L2. For very hot keys, this dwarfs the L2 speedup.
  • L1 has no network hop. Cheaper on the network layer; the cache node isn't a bottleneck.
  • L1 is per-process — no shared load, no contention.

The tricky part: L1 invalidation

Each app process has its own L1 → invalidating across N processes is coordination-heavy. Options: - Short TTL on L1 (say, 10 s). Accept staleness for cheapness. Most-common in practice. - Pub/sub invalidation — a Redis channel broadcasts invalidations; each app subscribes and drops the L1 key. Extra complexity. - Version keys — store user:42:v in L2; L1 checks the version on every access (cheap; still 1 network call, but avoids DB).

Rule of thumb: L1 tier makes sense when the top-1% of keys account for >50% of traffic (heavy skew).


6. Real case studies

6.1 Facebook Memcache (2013 paper — go read it, seriously)

Facebook runs the world's largest deployment of Memcached. Key insights:

  • Cache-aside with delete-on-write (not write-through).
  • Leases to prevent stampedes: when a cache miss happens, the cache returns a special "lease token." Only the holder of the lease is allowed to write back — everyone else must wait or re-fetch.
  • Separate cache pools for different workloads (news feed, chat, ads) so a bad workload doesn't evict good data.
  • Warm-up region for new clusters: a new cluster pulls values from an existing warm cluster instead of directly from the DB.
  • Regional cache with a shared canonical region (the "master" region) that CDC-invalidates to secondaries.
  • Insight: most user requests are read-heavy (~99%), so cache is life-or-death.

6.2 Netflix EVCache

Netflix built EVCache on top of Memcached with:

  • Multi-region replication built in (the cache itself, not just CDC).
  • Client-side sharding (no cluster proxy).
  • Bloom filters to avoid futile cache lookups for keys that definitely don't exist.
  • Regional failover — if a client's local region is down, transparently fall back to another region.
  • Insight: cache tier is treated as a first-class service, not just a "hopefully warm" optimization.

6.3 Discord's cache invalidation

Discord uses CDC from PostgreSQL → Redis invalidation for user-visible caches like guild membership. Their post-mortems describe: - Debezium reading Postgres WAL. - Kafka topic per table. - Consumer service that deletes the right Redis keys. - Median invalidation lag: ~15 ms globally.


7. Cache observability — what to measure

If you can't see it, you can't tune it. Every serious cache deployment measures:

Metric What it tells you Alert if
Hit rate Effectiveness Drops > X% (something's cold or invalidation storm)
Miss rate Backing store load Spikes (thundering herd, key eviction wave)
Latency (P50 / P99) Client experience P99 > network baseline (cache node saturated)
Eviction rate Cache under memory pressure Sustained high evictions (need more memory or smaller values)
Key count / memory used Capacity headroom Approaching maxmemory
Connection count Client pool health Spikes (connection storm, retry storm)
Hot key detection Skew Any key > 1% of total ops
Replication lag (multi-region) Consistency risk > freshness SLA

Redis has INFO, MONITOR (careful, expensive), CLIENT LIST, and SLOWLOG for these. Prometheus exporters make this easy.


8. Cache anti-patterns — what to never do

1. Caching everything by default. If read/write ratio is close to 1:1, or keys are almost never re-read, the cache is pure overhead. Measure first.

2. Storing writes in the cache before the DB confirms. Unless you're deliberately doing write-back with durability, this is a data-loss bug waiting to happen.

3. Using in-process cache for session state in a horizontally-scaled fleet. Different app servers = different session state. Users see phantom logins. Use a distributed cache.

4. Long TTLs on data with strong-consistency needs. 5-minute stale price on a checkout page is a lawsuit waiting to happen. Match TTL to consistency SLA.

5. Cache keys with unbounded cardinality. Caching per-user-per-hour-per-region timeseries → billions of keys → cache is useless (nothing gets re-hit).

6. Ignoring the cold-cache problem. A cache restart / cluster resize with no warm-up plan → traffic spike hits the DB → cascading failure. (Day 5 Q9 scenario.)

7. Reading through the cache in a hot loop without single-flight. Every request creates its own stampede. Coalesce.

8. Storing gigantic values in Redis. Redis is single-threaded per shard; a single 10 MB GET blocks the shard for tens of ms. Break big values into chunks or use a different store (S3 for blobs, cache the pointer).


9. Interview angle (what FAANG asks)

Common flavors:

  • "How would you invalidate the cache at your scale?" → CDC + Kafka + consumers. Show you know that "the writer deletes the key" doesn't scale to many services.
  • "You have data centers in 3 regions — how do caches work?" → Per-region cache + CDC-based cross-region invalidation is the standard answer. Only geo-replicate if consistency SLA requires it.
  • "Would you use in-process cache?" → Yes, if there's heavy skew. Combined with short TTL or pub/sub invalidation. Show you know the trade-off.
  • "Read Facebook's Memcache paper, what's clever about it?" → Leases for stampede prevention; warm-up regions; separate pools per workload.
  • "How do you know your cache is working?" → Hit rate, eviction rate, latency, hot-key detection. Alert when hit rate drops.

Trap they set: they'll say "we have consistent user experience globally." A weak candidate says "cache everything." A strong candidate asks: "What's the freshness SLA per data type? Auth tokens must be strong; product catalog can be 5 min stale; user profile is ~30 s tolerable. That drives whether we per-region-cache or geo-replicate."


10. Quick reference card

  • Fleet-wide invalidation → CDC (Debezium + Kafka). Not per-writer deletes.
  • Multi-region → per-region cache + CDC invalidation. Geo-replicate only if you must.
  • Refresh patterns: refresh-ahead (cache tier), background refresh (fixed hot set), stale-while-revalidate (CDN/HTTP).
  • Tiered cache (L1 + L2) wins when top-1% of keys = >50% of traffic.
  • L1 invalidation is the hard part — usually short TTL or pub/sub.
  • Case studies: Memcache (leases, warm regions, pool separation), EVCache (multi-region, Bloom filters).
  • Observe: hit rate, latency, eviction rate, hot keys, replication lag.
  • Anti-patterns: caching everything, unbounded cardinality, gigantic values, no cold-start plan.

Ready for the Day 6 quiz? Open it in the sidebar. Includes: - One spaced-repetition callback on bandwidth vs latency (High severity — from Day 4 Q5) - One fresh tail-amp application question (to maintain your newly-locked skill)

Answer in chat when done.