S063 · Caching — Cache-Aside, Write-Through, TTLs, Invalidation
The oldest performance trick in the book, done right. Cache-aside vs write-through vs write-back, TTLs, invalidation patterns, and the two hardest problems in computer science.
🎯 Implement cache-aside against Redis in Python, defend it from stampedes and stale writes, and explain when NOT to cache.
Why this session exists
Caching is the single highest-leverage performance technique you own. A well-placed cache turns a 200 ms query into a 2 ms lookup — a 100× speedup with 20 lines of code. It's also the source of half the "why is production returning yesterday's data?" tickets you'll ever see. This session is both halves: how to reach for a cache, and how not to shoot your foot off.
- Choose between cache-aside, write-through, and write-back given a workload.
- Implement cache-aside with proper TTL, negative-cache, and stampede protection in Python + Redis.
- Explain cache invalidation patterns (TTL-only, event-driven, version-tag) and when each breaks.
- Diagnose the four canonical cache failure modes: stale reads, thundering herd, negative-cache poisoning, and cold-cache meltdown.
Prerequisites
- S015 · HTTP basics — request/response, headers, status codes.
- S062 · Load balancers — LBs often front the cache layer.
- S055 · Redis basics — you know
GET,SET,EXPIRE.
(a) Intuition · 5 min
Every day 200 students ask for the same three chapters of the same textbook. The librarian used to walk to the stacks 600 times a day.
Now she keeps a stack of pre-made photocopies of those chapters on the desk. First student of the day still triggers a walk to the stacks (+ a copy for the pile). Every subsequent student for the rest of the day gets served in 3 seconds instead of 3 minutes. At closing time she throws the photocopies away — tomorrow the textbook might be a new edition.
Same idea. Your database is the stacks. Your cache (Redis, Memcached, in-process LRU) is the desk. The first read of user:42 misses the cache, hits Postgres, and fills the cache. The next 10,000 reads for the next 10 minutes are 1 ms Redis GETs.
The "throw the photocopies away at closing time" is the TTL — the guarantee that stale data self-destructs after N seconds without you writing any invalidation code. It's the cheapest and most-used correctness knob in caching.
The two hard problems in computer science
The three primary patterns
- Cache-aside (lazy) — App reads cache, on miss it reads DB and populates cache. On write, app writes DB and invalidates (or updates) cache. Default choice; used by 90% of systems.
- Write-through — App writes to the cache; the cache synchronously writes to the DB. Reads are always served from the cache. Strong consistency, higher write latency.
- Write-back (write-behind) — App writes to the cache; the cache asynchronously flushes to the DB later. Fastest writes, but if the cache dies before flush you lose data. Used carefully (metrics buffers, click counters).
A quick history so the vocabulary makes sense
- 1965IBM System/360 Model 85First commercial CPU cache. The idea ‘keep hot data closer’ predates everything else here by 40 years.
- 2003Memcached · Brad Fitzpatrick / LiveJournalSimple in-memory key-value cache. Livejournal, Facebook, Twitter, YouTube all ran on it.
- 2009Redis 1.0 · Salvatore SanfilippoMemcached + real data structures + persistence + pub/sub. Becomes the default cache and message broker.
- 2013‘Scaling Memcache at Facebook’ paperThe reference architecture. Leases (stampede protection), gutter pool (failure isolation), regional replication.
- 2020Redis 6 · client-side caching (RESP3)Redis tells clients when their local cache is invalidated. Two-tier caching becomes trivial.
(b) Visual walkthrough · 15 min
Cache-aside — the pattern you'll use 90% of the time
The three write patterns, side-by-side
App is boss · lazy fill
- Read: cache first, DB on miss, backfill
- Write: write DB → invalidate (or update) cache
- Simple, resilient to cache outage (just slower)
- Risk: read-after-write race → stale cache
- Default choice for user profiles, product catalogues
Cache is boss · synchronous fill
- Every write hits cache first; cache writes DB
- Reads always cache-first (never miss for written data)
- Strong read-your-writes consistency
- Write latency = cache + DB. Cache outage = no writes
- Good fit: session store, shopping cart
Cache is buffer · async flush
- Write to cache; batched flush to DB every N seconds
- Lowest write latency, highest throughput
- Cache crash = data loss between flushes
- Needs durable cache (Redis AOF) or accept loss
- Good fit: metrics, view counters, likes
Cache invalidation strategies — from lazy to strict
Ordered from ‘easiest to reason about’ to ‘strongest guarantees’
Where a cache lives — pick the right tier
"Caching is a performance optimisation. Add a cache, everything gets faster, and the worst case is a cache miss."
A cache is a second copy of your data with its own consistency model, its own failure modes, and its own capacity limits. Adding one converts a latency problem into a correctness problem plus an availability dependency. The worst case is not a miss — it is a stampede on expiry, a cold cache after a restart taking down the database, or serving a stale value that causes a wrong decision.
The myth is sticky because the happy path is genuinely dramatic: a millisecond instead of a hundred, with three lines of code. Nothing in that experience hints at the failure modes, which only appear under conditions you cannot easily reproduce — a cache node failing under peak traffic, a popular key expiring while ten thousand requests are in flight, an invalidation that fires before the write commits. Every one of those requires load plus timing, so they arrive in production first.
Ask the three questions that decide whether a cache is safe, before writing any code:
1. Can the system survive the cache being entirely empty right now?
(restart, eviction, failover - if the DB cannot take 100% of
traffic, the cache is not a cache, it is a load-bearing tier)
2. What is the maximum staleness a consumer can tolerate?
(this number IS your TTL; if nobody can state it, do not cache)
3. What happens when 10,000 requests miss the same key at once?
(without single-flight, they all hit the database simultaneously)Why is cache invalidation famously hard? It sounds mechanical: the data changed, so delete the cache entry.
- 1A cache entry is a claim that some derived value was correct at some past instant.forced by · it was computed from a snapshot of the source, then stored — the source moved on independently
- 2The source of truth can change at any moment, and it has no inherent knowledge of which caches hold which derived values.forced by · caches are added by consumers; the database was not designed to track its readers
- 3Therefore invalidation requires someone to know the mapping from "this data changed" to "these cache keys are now wrong", and that mapping is application knowledge that lives only in a developer's head.forced by · derived values may aggregate many rows, so the dependency graph is implicit in the code that computed them
- 4That mapping is never complete. A new feature adds a cached view, and the invalidation code for the write path is in a different file written by a different person months earlier.forced by · the write path and the caching decision are separated in both code and time, so nothing forces them to be updated together
- 5And even with a perfect mapping, invalidation and the write are two operations that cannot be made atomic across two systems, so there is always a window — and a crash between them leaves permanent staleness.forced by · there is no distributed transaction between your database and your cache, and adding one would cost more than the cache saves
Therefore explicit invalidation cannot be made complete or atomic in a real codebase. This is a structural property, not a discipline failure.
And note what this predicts: TTL-based expiry is not the lazy option — it is the only mechanism that bounds staleness without requiring a complete dependency map, because it converges regardless of what invalidation you forgot. That is why mature systems use both: TTL as the correctness floor that guarantees convergence, and explicit invalidation as a latency optimisation on the paths you did remember. It also predicts that the right question is never "how do I invalidate everything correctly" but "how stale can this value be", which is answerable and testable.
A cache pays memory to avoid work, and the bet only pays off if the same items are requested repeatedly within the TTL. Hit rate is the odds on that bet, and it is entirely determined by your access distribution — no amount of cache size fixes a workload with no repetition.
Picture it as a layer with a hole in it: everything that misses falls through to the origin. Your origin must be sized for the traffic that comes through that hole at its widest, which is when the cache is empty.
- Cache-aside (read, miss, fetch, populate) is the default and puts you in control. Write-through keeps the cache warm at the cost of write latency; write-behind is fast and can lose data on failure. Pick deliberately — the default is a choice too.
- Always add jitter to TTLs. Identical TTLs on items populated together expire together, producing a synchronised stampede at a predictable moment. Randomising by ±10% costs nothing and removes an entire failure class.
- Use single-flight (request coalescing) on every expensive key: the first miss fetches, concurrent misses wait for that result. Without it, cache expiry on a hot key delivers your full request rate to the database in one instant.
- Hit rate alone is a misleading metric. Measure origin load and p99 latency as well — a 95% hit rate on a system that cannot survive the remaining 5% is a system that is one eviction away from an outage.
Fire this model when you see: a database spike at a round-numbered interval · a service that cannot restart under load · users reporting they saw an old value after saving · latency that is fine at p50 and terrible at p99 · a Redis outage taking down a service that "only used it for caching".
A popular cache entry has just expired and requests are arriving. Do you block them while recomputing, serve the stale value, or recompute in advance?
Stale-while-revalidate should be your default for read paths, because it is the only option that decouples user-visible latency from origin latency entirely. State the acceptable staleness explicitly and make it visible in the code — a named constant with a comment, not a magic number.
Reserve blocking recomputation for values where being wrong is worse than being slow, and be honest about which those are: most teams classify far more data as "must be fresh" than the business actually requires. The question to ask a stakeholder is not "do you want fresh data" — everyone says yes — but "is a value from thirty seconds ago acceptable if it means the page always loads instantly". That reframing usually settles it.
(c) Hands-on · 25 min
Cache-aside against Redis, in Python, with the three defences you'll always want: TTL, negative cache, and stampede protection (via a short lock).
"""s063_cache.py — production-shaped cache-aside in ~100 lines.
Run: docker run -d --name s063-redis -p 6379:6379 redis:7-alpine
pip install redis
python s063_cache.py
"""
import json
import random
import time
from contextlib import contextmanager
import redis
# ---- 1. wire up ----
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
# ---- 2. the "slow" data source we're caching ----
_call_count = {"db": 0}
def slow_db_lookup(user_id: int) -> dict | None:
"""Simulates a 200 ms DB query. Returns None for user_id > 1000 (not found)."""
_call_count["db"] += 1
time.sleep(0.2)
if user_id > 1000:
return None
return {"id": user_id, "name": f"user-{user_id}", "email": f"u{user_id}@ex.com"}
# ---- 3. constants ----
TTL_SECONDS = 300 # 5 min for real data
NEGATIVE_TTL = 30 # 30 s for "does not exist" — shorter, so a newly-created row shows up quickly
LOCK_TTL = 5 # stampede lock lives 5 s max
NEGATIVE_SENTINEL = "__MISS__" # marker for "we looked, it wasn't there"
# ---- 4. the stampede lock ----
@contextmanager
def redis_lock(key: str, ttl: int = LOCK_TTL):
"""SET NX EX — classic Redis lock. If we don't get it, we block briefly."""
token = f"{time.time()}-{random.random()}"
got = r.set(key, token, nx=True, ex=ttl)
try:
yield bool(got)
finally:
# Only delete if we still own it (avoid stealing another holder's lock)
if got and r.get(key) == token:
r.delete(key)
# ---- 5. the cache-aside read ----
def get_user(user_id: int) -> dict | None:
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached == NEGATIVE_SENTINEL:
return None # negative-cache hit
if cached is not None:
return json.loads(cached) # positive hit
# ---- miss path with stampede protection ----
lock_key = f"lock:{cache_key}"
with redis_lock(lock_key) as got_lock:
if not got_lock:
# Someone else is filling. Wait briefly, then re-check the cache.
time.sleep(0.05)
cached = r.get(cache_key)
if cached == NEGATIVE_SENTINEL:
return None
if cached is not None:
return json.loads(cached)
# Fall through — worst case we do the DB call ourselves
value = slow_db_lookup(user_id)
if value is None:
r.set(cache_key, NEGATIVE_SENTINEL, ex=NEGATIVE_TTL)
return None
r.set(cache_key, json.dumps(value), ex=TTL_SECONDS)
return value
# ---- 6. the write path (invalidate on write) ----
def update_user(user_id: int, patch: dict) -> None:
# In real life this would write to Postgres. We just invalidate.
print(f"[write] updating user {user_id} in DB (imagined)")
r.delete(f"user:{user_id}") # simplest safe invalidation
# ---- 7. demo ----
if __name__ == "__main__":
r.flushdb()
print("--- cold cache: first call hits DB ---")
t0 = time.perf_counter()
print(get_user(42))
print(f"took {(time.perf_counter()-t0)*1000:.1f} ms db_calls={_call_count['db']}")
print("\n--- warm cache: 100 calls, ~1 ms each ---")
t0 = time.perf_counter()
for _ in range(100):
get_user(42)
print(f"took {(time.perf_counter()-t0)*1000:.1f} ms total, db_calls={_call_count['db']}")
print("\n--- write invalidates the cache ---")
update_user(42, {"name": "renamed"})
t0 = time.perf_counter()
print(get_user(42))
print(f"took {(time.perf_counter()-t0)*1000:.1f} ms db_calls={_call_count['db']}")
print("\n--- negative cache: not-found result is remembered ---")
print(get_user(9999)); print(get_user(9999)); print(get_user(9999))
print(f"db_calls after 3 not-found reads: {_call_count['db']}")What each block does
Anatomy of the module
Fire 50 concurrent requests at a cold cache and count DB calls with the lock enabled vs disabled:
import threading
r.flushdb()
_call_count["db"] = 0
threads = [threading.Thread(target=get_user, args=(42,)) for _ in range(50)]
for t in threads: t.start()
for t in threads: t.join()
print(f"db_calls after 50 concurrent cold reads: {_call_count['db']}")With the lock: ~1–3 DB calls (usually 1). Without: 50 — every thread raced past the empty cache and hit the DB at once. Multiply that by every popular key on a real product and you understand why caches without stampede protection make DBs melt.
Bonus — what to cache and what NOT to cache
- Cache: read-heavy, expensive to compute, tolerant of some staleness (product pages, user profiles, search results, aggregations).
- Do NOT cache: write-heavy tables, personalised data with strict freshness (bank balances, unread-message counters that must be exact), anything where staleness can cause security or safety bugs (permission checks, feature flags for security features).
(d) Production reality · 15 min
Facebook ran into ‘thundering herd’ on hot keys — a celebrity's profile expired, and 100k requests raced to backfill the same key, all hitting the DB. MySQL fell over regularly.
They also saw ‘stale sets’ — client A reads DB, gets old value; client B writes DB + invalidates cache; client A writes its stale value back into the cache. Cache now holds pre-write data indefinitely.
user:profile (missing the user ID) because a template-string bug. Cache is now a single global mutable variable serving everyone's last-written profile.cache_key(entity_type, entity_id, version=1) helper that fails if entity_id is None.The five failure modes worth memorising
- Cache stampede (thundering herd) — many concurrent misses hit the origin simultaneously. Fix: locks / leases / probabilistic early expiration.
- Cache penetration — repeated queries for keys that don't exist bypass the cache and hit the origin. Fix: negative cache, or a bloom filter in front.
- Cache avalanche — mass simultaneous expiry (fixed TTL) or a cold-cache restart. Fix: TTL jitter, warm-up on deploy, gutter pool.
- Stale writes / read-then-write races — cache-aside without coordination lets an old value overwrite a newer one. Fix: version tags, CAS (compare-and-swap), or write-through.
- Hot key overload — one key gets 10× the traffic of any other (celebrity, trending post). Even Redis can bottleneck. Fix: L1 in-process cache, or shard by key + random suffix (`user:42:{shard}`).
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- What is cache-aside, and what step is easy to forget? (invalidation on write)
- Name three cache failure modes and one fix for each.
- When would you NOT cache something? (write-heavy, strict-freshness, security-sensitive)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.