Module 03: Multi-Level Caching
Why this matters
Module 00 laid out the latency ladder: an in-process dictionary lookup is
~100ns, a Redis round-trip is ~0.5–1ms, a Postgres query is milliseconds to
hundreds of milliseconds. So far the track has treated "the cache" as a single
thing — Redis. But there's a rung above Redis you're leaving on the table: the
memory of your own application process. A value you can serve from a local dict
never even pays the network hop to Redis.
Multi-level caching stacks these: a small, ultra-fast L1 cache inside each process in front of a larger, shared L2 cache (Redis) in front of the database. Done right, it turns your hottest keys into ~100ns hits while keeping the sharing and capacity of Redis for everything else. Done wrong, it becomes the single hardest caching bug to debug — because now you have N+1 copies of each value (one per process's L1, plus L2) that can all disagree, and invalidating L2 does nothing to the stale copy sitting in some replica's L1.
This module is also where you learn to measure caching honestly. The hit ratio — and specifically the hit ratio at each level — is the number that tells you whether a cache is earning its complexity. An engineer who adds an L1 without measuring its hit ratio has just added a distributed-consistency bug in exchange for a speedup they can't prove.
Concepts
L1 vs L2: two caches with opposite tradeoffs
- L1 — in-process / in-memory cache. A data structure (a bounded dict, an
functools.lru_cache, a small LRU library likecachetools) living inside a single application process's own heap. Fastest possible hit (~100ns, no serialization, no network) and free of any external dependency. But: it is per-process — every worker/replica has its own separate L1, none of them see each other's entries; it is small (bounded by that one process's memory, and you don't want a runaway cache OOM-killing your app); and it is ephemeral — a deploy, restart, or crash wipes it. Its capacity is measured in thousands of entries, not millions. - L2 — distributed cache (Redis). A separate service every process shares
over the network. Shared (a value written by one process is visible to all —
one source of cached truth), large (gigabytes, tunable via
maxmemory), and durable across app restarts (a redeploy doesn't cold-start it). But every hit costs a network round-trip (~1ms) and serialization/deserialization.
They are complementary because their strengths and weaknesses are mirror images. L1 is fast-but-unshared-and-tiny; L2 is shared-and-big-but-slower. A hierarchy uses L1 for the hottest handful of keys (where the ~100ns vs ~1ms difference, multiplied by huge request volume, matters) and L2 for the long tail and for sharing.
The hierarchical read path
A two-level read is the module 01 lookup, nested:
request
-> check L1 (in-process dict) hit? return (~100ns) [L1 hit]
-> miss: check L2 (Redis) hit? populate L1, return [L2 hit]
-> miss: query the database populate L2 and L1, return [full miss]
Each level populates the levels above it on the way back, so a value fetched from the DB warms both L2 (for other processes) and L1 (for this process's next request). Code:
import json, time, redis
from cachetools import TTLCache
r = redis.Redis(decode_responses=True)
l1 = TTLCache(maxsize=1_000, ttl=5) # tiny, short-lived, per-process
def get_product(pid: int) -> dict:
key = f"product:{pid}"
if key in l1: # L1: in-process, ~100ns
return l1[key]
if (raw := r.get(key)) is not None: # L2: Redis, ~1ms
value = json.loads(raw)
l1[key] = value # warm L1 on the way back
return value
value = load_product_from_db(pid) # full miss: the DB, ~ms
r.set(key, json.dumps(value), ex=300) # warm L2 (shared)
l1[key] = value # warm L1 (this process)
return value
The L1 TTL is deliberately short (seconds), much shorter than L2's. That's the central design tension of the next section.
The consistency cost: N stale L1 copies
Here is the danger that makes multi-level caching a genuine skill and not just a
speedup. With single-level Redis, an invalidation (DEL product:42) removes
the cached copy — the next read anywhere misses and reloads. With an L1 in
front, that same DEL removes only the L2 copy. Every process that has
product:42 in its L1 still serves the stale value until its L1 entry expires
or is evicted — and you cannot easily reach into 20 replicas' heaps to delete a
key.
writer does DEL product:42 ──► L2 (Redis): busted ✓
Process A Process B Process C
L1[42]=OLD ✗ L1[42]=OLD ✗ L1[42]=OLD ✗ ◄─ DEL didn't reach
│ │ │ any of these heaps
still serves still serves still serves → stale until each
stale value stale value stale value L1's own TTL expires
So multi-level caching reintroduces the stale-cache problem per process, and the usual explicit-invalidation fix doesn't cover it. The standard responses:
- Short L1 TTLs (the default answer). Keep L1 entries alive for only a few
seconds. Then the worst-case staleness from a stale L1 copy is bounded to that
few seconds — usually acceptable for the very hot, read-heavy data that
belongs in L1 in the first place. This is why the code above uses
ttl=5for L1 butex=300for L2: L1 trades away freshness for its ~100ns speed, and the short TTL bounds the trade. - Pub/sub invalidation of L1 (when seconds isn't good enough). On a write, publish an invalidation message on a Redis Pub/Sub channel; every process subscribes and deletes the key from its own L1 on receipt. This is the event-based invalidation from module 02, aimed specifically at the distributed L1 problem. It's more infrastructure (a subscriber thread/task per process, and messages can be missed on a disconnect) so you still keep the short L1 TTL as a backstop. Redis's client-side caching feature (RESP3 "tracking") is a productized version of this idea.
- Only L1-cache things that tolerate seconds of staleness. The cleanest response: don't put correctness-sensitive data in L1 at all. Reference data (product names, config flags, feature toggles) is ideal; anything where a few seconds stale is a bug stays in L2-only (or uncached).
The rule of thumb: L1 is for data that is extremely hot and tolerant of a few seconds of staleness. If it's not hot, L1's tiny capacity is wasted; if it's not staleness-tolerant, L1's per-process copies are a bug.
Hit ratio: the number that decides everything
The hit ratio is hits / (hits + misses) — the fraction of lookups served
from the cache instead of the source. It is the metric for caching, because it
directly determines the load reduction on the source and (with the latency
ladder) the average latency.
Make the payoff concrete. Suppose a DB query is 50ms and a cache hit is 1ms.
Average latency ≈ hit_ratio × 1ms + (1 − hit_ratio) × 50ms:
| Hit ratio | Avg latency | Load on DB (relative) |
|---|---|---|
| 0% (no cache) | 50 ms | 100% |
| 50% | 25.5 ms | 50% |
| 90% | 5.9 ms | 10% |
| 95% | 3.45 ms | 5% |
| 99% | 1.49 ms | 1% |
Two lessons jump out. First, the curve is nonlinear at the top: going from 90% to 99% hit ratio cuts DB load tenfold (10% → 1%) even though the ratio only moved 9 points. The last few percent of hit ratio are where the big wins are — which is exactly what an L1 in front of a good L2 buys you. Second, a low hit ratio means the cache is barely helping — at 50% you've halved load but every other request still pays full price, and you should ask whether the value was a good candidate (module 00) or your keys/TTLs are wrong.
Combined multi-level hit ratio. With two levels, a request is a full miss
only if it misses both. If L1 hits 60% of lookups and L2 hits 80% of the
remaining ones, the overall hit ratio is 0.60 + 0.40 × 0.80 = 0.92, and only
0.40 × 0.20 = 8% of lookups reach the database. Crucially, measure each level
separately: a high overall ratio with a near-zero L1 ratio means L1 is adding
consistency risk for no speed benefit and should be removed. Always be able to
answer "what is my L1 hit ratio and my L2 hit ratio, separately?"
Measuring hit ratio in practice
Three complementary sources:
- Redis server-side (
INFO stats):keyspace_hitsandkeyspace_missesgive L2's hit ratio across all keys and clients — coarse but zero-effort. A low server-wide ratio is a red flag; a high one doesn't prove your keys are hitting (someone else's might be). - Per-cache instrumentation (application-side): wrap your lookup to count hits and misses per logical cache (and per level), exported as metrics. This is the honest number — it's your cache's ratio, split L1 vs L2. Track 08 (observability) formalizes exporting these; for now a couple of counters is enough.
redis-cli --stat/MONITORfor live coarse observation while developing (neverMONITORin production — it firehoses every command and slows the server).
You optimize the ratio by: choosing genuinely reusable values (module 00), stable keys (module 00, exercise 7), TTLs long enough to get reuse but short enough to bound staleness (module 02), enough L2 memory that eviction isn't churning your working set (module 02), and an L1 sized/scoped to your actual hot set. If the ratio stays low after all that, the honest conclusion is often this value shouldn't be cached — not "add another cache layer."
Command reference
| API / command | Purpose | Example |
|---|---|---|
cachetools.TTLCache(maxsize, ttl) |
Bounded, TTL'd in-process L1 | TTLCache(1000, 5) |
cachetools.LRUCache(maxsize) |
In-process L1 with LRU eviction, no TTL | LRUCache(1000) |
functools.lru_cache(maxsize=) |
Simplest L1 for pure functions (no TTL, no invalidation) | @lru_cache(maxsize=512) |
INFO stats → keyspace_hits/keyspace_misses |
Server-wide L2 hit/miss counts | redis-cli INFO stats | grep keyspace |
redis-cli --stat |
Live throughput/hit stats, 1/sec | redis-cli --stat |
r.pubsub() / p.subscribe(ch) |
Subscribe to L1-invalidation events | see below |
r.publish(ch, msg) |
Broadcast an L1 invalidation on write | r.publish("inval", "product:42") |
INFO memory → used_memory |
L2 memory pressure (is eviction churning?) | redis-cli INFO memory |
A minimal instrumented two-level cache with pub/sub L1 invalidation:
import json, threading, redis
from cachetools import TTLCache
r = redis.Redis(decode_responses=True)
l1 = TTLCache(maxsize=1000, ttl=5)
stats = {"l1_hit": 0, "l2_hit": 0, "miss": 0}
def get(key: str, loader):
if key in l1:
stats["l1_hit"] += 1
return l1[key]
if (raw := r.get(key)) is not None:
stats["l2_hit"] += 1
val = json.loads(raw); l1[key] = val
return val
stats["miss"] += 1
val = loader()
r.set(key, json.dumps(val), ex=300); l1[key] = val
return val
def invalidate(key: str):
r.delete(key) # bust shared L2
l1.pop(key, None) # bust this process's L1
r.publish("cache:inval", key) # tell every other process to bust its L1
def _listen(): # run once per process, in a background thread
p = r.pubsub(); p.subscribe("cache:inval")
for msg in p.listen():
if msg["type"] == "message":
l1.pop(msg["data"], None) # drop the key from THIS process's L1
threading.Thread(target=_listen, daemon=True).start()
def hit_ratio():
total = sum(stats.values())
hits = stats["l1_hit"] + stats["l2_hit"]
return {"overall": hits / total if total else 0,
"l1": stats["l1_hit"] / total if total else 0, **stats}
Hands-on exercises
Redis running. pip install cachetools redis. For the multi-process exercises,
run two Python processes (two terminals) pointed at the same Redis.
1. Measure the L1 vs L2 vs DB latency gap
Time 10,000 lookups each of: a TTLCache hit, a Redis GET hit, and a fake
50ms DB call (fewer iterations). Print µs/op for each.
Expected: L1 ~sub-microsecond to low-µs, L2 ~hundreds of µs, DB ~50,000 µs. This is the ladder from module 00, now with the L1 rung you're adding — and it's why serving the hottest keys from L1 is worth the consistency risk.
2. Build the two-level read path
Implement get_product from Concepts against a fake 50ms DB. Call
get_product(1) three times and log which level served each.
Expected: call 1 = full miss (DB, warms L2 and L1), call 2 = L1 hit (this
process already has it — not even L2 is touched), call 3 = L1 hit. Now clear L1
only (l1.clear()) and call again: an L2 hit (repopulates L1 without the
DB). You've watched each level warm the ones above it.
3. Reproduce the stale-L1 bug across two processes
Run the instrumented cache in two processes, A and B, but without the
pub/sub listener. In both, get("product:1", loader) to populate each process's
L1. In A, call invalidate("product:1") (which only busts A's L1 and L2). Then
get("product:1") in B.
Expected: B still returns the stale value from its own L1 — A's invalidation
never reached B's process memory. This is the multi-level caching bug: DEL on
shared L2 does nothing to other processes' L1 copies. Confirm B keeps serving
stale until B's L1 TTL (5s) expires.
4. Fix it with pub/sub L1 invalidation
Enable the _listen() thread in both processes. Repeat exercise 3.
Expected: after A's invalidate, B's next get is a miss/L2 reload of the
fresh value — because A published product:1 on cache:inval and B's listener
popped it from B's L1. Then also confirm the short L1 TTL still matters: kill B's
listener, invalidate again, and watch B self-heal after 5s anyway (the TTL
backstop).
5. Instrument and compute per-level hit ratio
Using the stats/hit_ratio() helpers, generate a realistic mix: a few very
hot keys read constantly plus a long tail of one-off keys. Print hit_ratio().
Expected: a high overall ratio, with a meaningful L1 contribution from the
hot keys and L2 carrying the tail. Now shrink TTLCache(maxsize=2) so L1 can
barely hold the hot set and re-measure: the L1 ratio drops and load shifts to L2.
You've seen L1 sizing directly move the number that matters.
6. Prove the nonlinear top of the hit-ratio curve
Simulate 100,000 requests at hit ratios 0.5, 0.9, 0.95, 0.99 using the
hit_ratio × 1ms + (1−ratio) × 50ms model (just arithmetic, or a tiny loop).
Tabulate average latency and implied DB load.
Expected: your table matches Concepts — 90%→99% cuts DB load 10×. Internalize that chasing the last few points of hit ratio (what L1 does for hot keys) is where the disproportionate wins live, and that below ~80–90% you should question the whole cache.
7. Read the server-side hit ratio and reconcile it
While exercise 5 runs, in another terminal watch redis-cli --stat and grab
INFO stats | grep keyspace. Compare the server-wide L2 ratio to your
application's L2 ratio from hit_ratio().
Expected: they're related but not identical — the server-wide number includes
every key and client (and every full miss that then does a SET), while your
app-side counter is your specific cache. Conclude why the app-side, per-level
instrumentation is the number you actually tune against, and the server-wide one
is a coarse health check.
8. Diagnose and fix: the L1 that adds risk for no benefit
A service added an L1 (TTLCache(maxsize=10000, ttl=30)) in front of Redis.
After deploy, occasional stale-data complaints appeared, but the p99 latency
barely improved. Instrumentation shows: l1_hit ≈ 2% of lookups, l2_hit ≈
88%, miss ≈ 10%. The cached objects are per-user dashboards, keyed
dash:{user_id}, and there are millions of users. Diagnose and recommend.
Answer
The L1 hit ratio is ~2% — L1 is doing almost nothing useful, because the
keyspace (dash:{user_id}, millions of users) has no small hot set: any given
process rarely sees the same user twice before the 30s TTL, so L1 entries almost
never get a second hit within one process. Meanwhile the L1 is adding
consistency risk: with a 30s TTL and per-process copies, a dashboard update is
stale for up to 30s in every process that cached it (the source of the
complaints), and there's no pub/sub invalidation. So this L1 is pure downside:
negligible speedup (2%), real staleness bug. Recommendation: remove the L1
(or restrict it to genuinely hot, shared, staleness-tolerant reference data —
not per-user dashboards) and rely on the well-performing L2 (88% hit). If some
truly hot, shared keys exist (e.g. global config), L1-cache those with a short
TTL. The general lesson: an L1 only earns its consistency cost when it has a real
hot set and the data tolerates seconds of staleness — measure the L1 ratio
before keeping it.
Independent challenge
No code given. Extend the storefront app from 02-cache-eviction-and-
invalidation with a two-level cache for the single hottest, most-shared,
staleness-tolerant read you can identify (e.g. product reference data), keeping
per-user or correctness-sensitive reads out of L1. Instrument both levels so you
can report the L1 hit ratio and L2 hit ratio separately, and demonstrate — by
tuning the L1 maxsize and TTL — that your L1 actually earns its keep (a
non-trivial L1 hit ratio on your access pattern). Then reproduce the stale-L1-
across-processes bug from exercise 3 using two processes, and fix it with Redis
Pub/Sub L1 invalidation from module 02's event-based approach — proving both the
bug and the fix. Finally, state the max staleness a user could see for your
L1-cached value and argue why it's acceptable for that value specifically and
would not be for one correctness-sensitive read you name.
Hint
The right L1 candidate is data that's read by many requests (so a small L1
holds the whole hot set and gets repeated hits — a high L1 ratio) and where a few
seconds stale is harmless (product name/description, a category tree, feature
flags) — not dash:{user_id} or anything per-user, which has no hot set and
gives the ~2% L1 ratio of exercise 8. Use cachetools.TTLCache for L1 with a
short TTL (a few seconds) and Redis for L2 with a longer one. For the cross-
process fix, publish the busted key on a Redis Pub/Sub channel on every
invalidate, and run a subscriber thread per process that pops the key from that
process's L1 — keep the short L1 TTL as a backstop for missed messages. Your
staleness argument mirrors module 00: reference data tolerates seconds; a balance
or inventory count does not.
Common mistakes & troubleshooting
- Adding an L1 without measuring its hit ratio. If the L1 ratio is near zero (no hot set, or per-user keys), you've added distributed-consistency risk for no speedup. Measure L1 separately and remove it if it's not earning its keep.
- Invalidating only L2. A
DELon Redis leaves every process's L1 copy stale. Use short L1 TTLs, and pub/sub L1 invalidation when seconds isn't good enough. - Long L1 TTLs. L1 copies are per-process and unreachable; a long L1 TTL means long, widespread staleness. Keep L1 TTLs to seconds.
- L1-caching correctness-sensitive data. Per-process stale copies of balances/inventory/auth are bugs. Keep those in L2-only or uncached.
- Oversized L1. A huge in-process cache competes with your app for heap and can OOM the process; L1 is meant to be small (the hot set), with L2 for the tail.
- Trusting the server-wide Redis hit ratio as your app's ratio.
INFO statsis coarse and shared across all keys/clients. Instrument per-cache, per-level. - Chasing more cache layers instead of a better candidate. If the ratio stays low after stable keys, sane TTLs, and enough memory, the value probably shouldn't be cached — another layer won't fix a fundamentally low-reuse value.
functools.lru_cacheon impure or invalidatable data. It has no TTL and no way to invalidate a single entry (onlycache_clear()), so it's only safe for pure, effectively-immutable computations — not data that changes.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- Contrast L1 and L2 caches on four axes: latency, sharing across processes, capacity, and survival across restarts.
- Walk through the read path of a two-level cache on a full miss, and explain what gets populated on the way back and why.
- Why does adding an L1 reintroduce the stale-cache problem that a single Redis
DELnormally solves, and what are the two standard mitigations? - Given a 50ms DB and 1ms cache hit, why is improving hit ratio from 90% to 99% a bigger deal than it sounds, in terms of DB load?
- If L1 hits 60% of lookups and L2 hits 80% of the remainder, what fraction of lookups reach the database, and why must you also look at the L1 and L2 ratios separately?
- What kind of data belongs in L1, and give one concrete example of data that must not go in L1 and why.
Answers
- Latency: L1 ~100ns (in-heap, no network/serialization) vs L2 ~1ms (network + serialization). Sharing: L1 is per-process (each replica has its own, invisible to others); L2 is shared by all processes. Capacity: L1 is small (bounded by one process's memory, thousands of entries); L2 is large (gigabytes). Restart survival: L1 is wiped on any restart/deploy; L2 persists across app restarts.
- Check L1 (miss) → check L2 (miss) → query the DB → populate L2 (so other processes benefit and it survives this process's restart) → populate L1 (so this process's next read is a ~100ns hit) → return. Each level warms the levels above it so subsequent reads stop at the highest possible rung.
- A single-level
DEL product:42removes the only cached copy, so the next read anywhere misses and reloads. With an L1, thatDELonly clears L2; every process still holding the key in its own L1 keeps serving the stale value, unreachable by theDEL. Mitigations: short L1 TTLs (bound the staleness to a few seconds) and Pub/Sub L1 invalidation (broadcast the busted key so each process drops it from its own L1) — usually both. - Average latency drops modestly (5.9ms → 1.49ms), but the DB load drops from 10% of requests to 1% — a tenfold reduction — because load is proportional to the miss rate (1 − hit ratio), and 10%→1% is 10×. The top of the hit-ratio curve is nonlinear: the last few points remove most of the remaining source load, which is exactly what an L1 over a good L2 delivers.
- Full misses = miss both =
0.40 × 0.20 = 0.08, so 8% reach the DB (overall hit ratio 92%). You must look at each level separately because a high overall ratio can hide a near-zero L1 ratio — meaning L1 is adding consistency risk with no speed benefit and should be removed or re-scoped; the split tells you which level is doing the work. - Data that is extremely hot (a small set read very frequently, so a tiny L1 gets repeated hits — a high L1 ratio) and tolerant of a few seconds of staleness (reference data: product names, config/feature flags). Must not go in L1: correctness-sensitive per-entity data like an account balance, sellable inventory count, or an authorization decision — per-process stale copies would be a correctness bug, not a cosmetic one.
Further reading & sources
- Redis: client-side caching (tracking) - Redis's productized version of the L1-over-L2 pattern with server-driven L1 invalidation.
- cachetools documentation - the
TTLCache/LRUCacheused to build the in-process L1 here. - functools.lru_cache - the simplest in-process cache, and its no-TTL/no-per-key-invalidation limits.
- Redis: Pub/Sub - the broadcast channel used to invalidate every process's L1 on a write.
- Redis: INFO - reading
keyspace_hits/keyspace_missesfor the server-side L2 hit ratio.
Next
04-caching-for-web-apps-and-databases — you can now build and measure a multi-level cache. Next is applying caching to the two places web backends need it most: HTTP/CDN/browser caching for static assets and API responses (the headers from track 01, seen as a caching strategy), and query-result caching for expensive joins/aggregates — plus the cache stampede/thundering-herd problem and the standard defenses (locking, jittered TTLs, request coalescing).