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.

← Back to Log