Caching
Part 7 of 8 · Redis cacheCache Invalidation — TTL vs Event-Driven vs Versioned Keys
Invalidation is how you stop serving lies. TTL is the safety net, event-driven DEL tracks writes, and versions stop a slow reader from writing an old snapshot back after your DEL. Combine them and design for the invalidation path failing.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
How do you stop serving yesterday's price?
Prefer
Version floor plus an atomic fill, with TTL behind it
The invalidator records the highest invalidated version, then a Lua fill refuses anything older. TTL still bounds a lost event.
- Check-then-SET is still a race. The fill and the floor read share one script.
- DEL and raising the floor are idempotent, so at-least-once delivery is safe.
- In Cluster, hash-tag both keys so they share a slot.
Alternative
TTL-only, or a plain SET of the blob you just read
A slow reader loads v1, the writer deletes the key, and the reader writes v1 back. The cache lies until TTL.
- Oversell or a wrong price until expiry.
- A delayed second DEL is cheap and still probabilistic.
- L2 DEL that forgets L1 leaves the old value in process memory.
Stale resurrection, then the floor rejects it
The failure path is a plain SET in step 5. It puts v1 back until TTL.
- 1
GET misses
The reader asks Redis for user:42 and gets nothing. - 2
SELECT returns v1
The reader loads the row it will try to cache. It can stall here. - 3
Commit v2 with the outbox
The writer updates the row and the outbox row in one transaction. - 4
Raise the floor, then DEL
The consumer sets the floor to 2 and deletes user:42. Both are safe to repeat. - 5
Conditional fill of v1
The stalled reader tries to write the snapshot it loaded. - 6
Floor rejects v1
The Lua fill sees version 1 below floor 2 and writes nothing. - 7
Next read fills v2
The following miss loads v2. That version is at the floor, so the fill is accepted.
Overview
Cache invalidation is how you stop serving stale data after the source of truth changes. Three workhorses: TTL (time-based expiry), event-driven deletes (CDC or domain events), and versioning (a version in the key or in the cached payload). Interviews expect the tradeoffs, the race with cache-aside, and why "just set a TTL" is not a consistency strategy for money-moving paths.
TTL sizing (jitter, expiry cliffs, business tolerance) is the sibling lesson TTL + jitter. This page is about when a key should die.
Comparative: invalidation styles
| Style | Trigger | Freshness | Cost | Failure mode |
|---|---|---|---|---|
| TTL only | Clock | Bounded staleness | Low | Stale until expiry; synchronized expiry if no jitter |
| Event-driven DEL | Commit via outbox or CDC | Near real time | Medium to high | Lost or delayed events, ordering, consumer lag |
| Versioned key names | Version bump on write | Readers that know the new version go straight to the new key | Medium | Needs a pointer or a version lookup; old keys linger until TTL or eviction |
| Explicit DEL in the request | The writer itself | Fast if every cache hears it | Medium | Dual-write gap and the resurrection race |
What fails if you choose wrong
- TTL-only for inventory or checkout: oversell or a wrong price until expiry.
- Duplicate events: a duplicate
DELis harmless; a duplicate "rebuild and SET" without a version check can write an older value over a newer one. - Version read from the cache instead of the database: clients keep using the old version forever.
- Invalidating L2 Redis and skipping the L1 in-process cache: pods serve stale values from memory. See near-cache consistency.
TTL as backstop
TTL bounds worst-case staleness and memory. Add jitter so keys filled together do not expire together (a stampede amplifier). TTL alone is a freshness SLA, not linearizability. If the invalidation consumer is down, TTL is what eventually corrects the cache, so alert on consumer lag, not only on miss ratio. How to pick the number: TTL + jitter.
Event-driven invalidation
On commit, publish UserUpdated(id, version). Consumers DEL user:{id} and every derived key (by-email, lists, feeds). Sources: an application outbox, Debezium CDC, Kafka, Redis Streams. Design for:
- At-least-once delivery.
DELand "raise the floor" are idempotent, so retries are safe. - Ordering per entity key (partition by id), or carry the version so an old event cannot undo a newer one.
- Fan-out to every tier: L2 Redis and every pod's L1.
The dual-write bug (commit succeeds, publish fails) is why the outbox row is written in the same database transaction as the change. See transactional outbox.
Redis keyspace notifications tell you about Redis key events (expired, deleted). They do not tell you about database writes, so they are not a substitute for outbox or CDC.
Versioned keys
Two shapes. Versioned key names (user:42:v7) never mutate in place: the writer bumps the version in the database and readers fetch the new key, while old keys age out by TTL or eviction. That is a good fit for immutable snapshots, and readers still need the current version from somewhere (the database, or a small pointer key). That pointer has the same invalidation problem. Versioned payloads ({v: 7, data}) keep one key and use the version to reject older writes, which is what the code below does.
The stale resurrection race
Order on the write path: commit the database, invalidate the cache, then the next reader reloads.
Race: a reader misses before the write, reads the old row, stalls, and SETs the cache after your DEL. The cache now holds the old value until TTL. Checking "is the database version newer?" and then SETting is still a race (check-then-act). Working fixes:
- Version floor plus an atomic conditional fill. The invalidator records the highest invalidated version (
floor:user:42) and the fill is a Lua script that refuses a version below the floor. Give the floor key a TTL comfortably longer than the slowest read path. - Delayed double delete.
DELagain a few hundred milliseconds after commit. Cheap, and still probabilistic. - Short TTL as the backstop for whatever slips through.
In Redis Cluster both keys in one script must share a hash slot, so use a hash tag: {user:42} and {user:42}:floor.
Sequence
- 1
Reader pod → Redis
Step 1 GET user:42 - miss
- 2
Reader pod → Database
Step 2 SELECT returns v1
- 3
Writer and outbox consumer → Database
Step 3 UPDATE to v2 and outbox row in one transaction
- 4
Writer and outbox consumer → Redis
Step 4 SET floor to 2, then DEL user:42
- 5
Reader pod → Redis
Step 5 conditional fill with v1
- 6
Redis → Reader pod
Step 6 rejected because v1 is below floor 2
- 7
Reader pod → Redis
Step 7 next read misses, loads v2, fill accepted
- 8
Reader pod
Failure path - a plain SET in step 5 resurrects v1 until TTL expires
Lesson map
Cache Invalidation — TTL vs Event-Driven vs Versioned Keys
Invalidation is how you stop serving lies. TTL is the safety net, event-driven DEL tracks writes, and versions stop a slow reader from writing an old snapshot back after your DEL. Combine them and design for the invalidation path failing.
Architecture. Reader pod Ready. Writer and outbox consumer Ready. Redis Ready. Database Ready. Failure path - a plain SET in step 5 resurrects v1 until TTL expires Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB R["Reader pod Ready"] W["Writer and outbox consumer Ready"] C["Redis Ready"] D["Database Ready"] fail["Failure path - a plain SET in step 5 resurrects v1 until TTL expires Ready"] R -->|Step 1 GET user:42 - miss| C R -->|Step 2 SELECT returns v1| D W -->|Step 3 UPDATE to v2 and outbox row in one transaction| D W -->|Step 4 SET floor to 2, then DEL user:42| C R -->|Step 5 conditional fill with v1| C C -->|Step 6 rejected because v1 is below floor 2| R R -->|Step 7 next read misses, loads v2, fill accepted| C R -->|Failure path - a plain SET in step 5 resurrects v1 until TTL expires| fail
Sandbox: version-gated cache-aside (Python)
Runnable with python3, no dependencies. FILL_LUA is the Redis version of the conditional fill.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Jittered TTL plus event-driven invalidation (TypeScript)
Runnable with npx tsx, no dependencies.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Interview Q&A
Why is cache invalidation hard?
Answer
Dual-write gaps, multiple tiers, lost or reordered events, and slow readers that write old snapshots back. There is no single knob.
TTL or events?
Answer
Both. Events give freshness in seconds. TTL bounds the damage when an event is lost.
What does a versioned key buy you?
Answer
Immutable entries cannot be overwritten in place, so there is no update race on that key. You pay with a version lookup and garbage that ages out via TTL or eviction.
What is stale resurrection and how do you stop it?
Answer
A reader loads a pre-write snapshot and SETs it after your DEL. Stop it with a version floor and an atomic conditional fill. A delayed second DEL and a short TTL are backups.
What about secondary keys?
Answer
Invalidate every derived key (by-id, by-email, lists, feeds) or use a namespace epoch. Forgetting a list key is how feeds lie.
CDC or application events?
Answer
CDC catches every database write, including manual fixes. Application events carry intent and need discipline. The outbox pattern gives you the domain change and the event in one transaction.
Negative lookups?
Answer
Cache "not found" with a short TTL and invalidate it on create events. See negative caching.
What if the invalidation consumer is down?
Answer
TTL is the backstop. Alert on consumer lag. A silent lag is a growing stale window.
Is write-through a form of invalidation?
Answer
It SETs the new value instead of deleting, so concurrent writers still need version checks, and L1 caches still need a fan-out. See write-through vs write-behind.
Pitfalls
A commits v2 and invalidates. B missed at v1, then fills. Write the cache and floor contents after each step, with the conditional fill and without it.