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.

Prev