Caching Systems (Part 2): Versioned Caches, Pub/Sub Invalidation, and Consistency
Part 1 explained caching layers and failure modes. This part is about keeping caches correct when data changes in a distributed system: versioned keys, event-driven invalidation, and the consistency trade-offs you actually live with.
1) Versioned caches: invalidate by changing the key
Instead of deleting keys, you bump a version and let old keys expire naturally.
Key pattern:
user:{id}:v{version}
How it works: - Store the current version in DB or a small metadata store. - Reads fetch the version, then read the versioned cache key. - Writes increment the version.
Pros - Invalidation is cheap and reliable (no delete storms). - Old keys can expire with TTL in the background. - Works across many caches and services.
Cons - Extra read (to get version) or version is cached too. - Requires a version store with strong write semantics.
Where it shines - User profiles, product pages, or any entity with frequent updates. - Multi-service systems where you cannot fan-out invalidations reliably.
2) Pub/Sub invalidation: push change events
In this model, writes publish an event. Consumers invalidate or update their caches.
Event payload (example):
{
"entity": "user",
"id": "123",
"action": "updated",
"ts": 1734790000
}
Typical flow 1) Write to DB 2) Publish event 3) Cache owners receive event 4) Invalidate or update cache
Why it fails if you do it naively - Events can be dropped, delayed, or reordered. - Consumers can be down when events publish.
How to make it reliable - Use an outbox pattern (store events in DB, publish asynchronously). - Make handlers idempotent. - Use a durable stream (Kafka/NATS) instead of volatile pubsub when correctness matters.
3) TTL is not enough; use soft TTL and stale-while-revalidate
TTL alone creates stampedes and synchronized expirations.
Better pattern - Soft TTL: consider cache stale before it truly expires. - Serve stale data briefly while one request refreshes in background. - Add jitter to TTL to avoid thundering herds.
Example strategy - Hard TTL: 10 minutes - Soft TTL: 8 minutes - If age > 8 minutes, refresh asynchronously
4) Stampede control: avoid N concurrent recomputations
When popular keys expire, you can melt the DB.
Mitigations - Singleflight (request coalescing) - Locking on recompute - Probabilistic early refresh - Pre-warming hot keys
5) Consistency is a choice, not a bug
There is no perfect cache consistency, only explicit trade-offs.
Common models - Eventual consistency: cheap, fast, some staleness - Bounded staleness: TTL-based, measurable drift - Read-your-writes: require versioning or session caches - Strong consistency: usually too expensive to maintain for caches
Rule of thumb - If a bug is catastrophic, skip caching for that path. - If staleness is acceptable, make it measurable and visible.
6) A practical decision matrix
Use this to pick a strategy per domain:
- User profile: versioned keys + TTL + background refresh
- Search results: TTL + soft TTL + async refresh
- Feature flags: pub/sub with durable stream + short TTL
- Financial balances: avoid cache or use read-your-writes session cache only
Final takeaway
Correctness in caches is not automatic. It requires: - deterministic invalidation (versioning), - durable change propagation (pub/sub + outbox), - and explicit consistency guarantees.
Part 3 shows how to build a cache-aside layer in Go with these patterns baked in.