Backend / Caching / 01_cache_consistency.md

Cache consistency

Updated 4 interview angles 10 min read source
On this page10
  1. The four patterns, and where each goes stale
  2. The race delete-on-write does not close
  3. The fixes, and what each costs
  4. What to cache and how to know it works
  5. Layers: in-process, Redis, CDN
  6. Deploys and the shape of cached data
  7. Where it breaks in production
  8. The trade not taken
  9. Related
  10. Interview angle

Cache consistency

Article.

How do you keep a cache consistent with the database when reads and writes race? “Delete the key when you write” is the usual answer, and it is not enough: a reader that missed a moment earlier can put the old value back after your delete, and it then stays until the TTL expires.

Senior answers name that interleaving, the fixes that actually close it (leases, versioned writes), what each costs, and the fact that the TTL is the only thing that bounds every remaining case.

This note is about correctness. Stampedes in depth are in Cache Stampede and Mitigation Patterns, Redis itself in What is Redis, HTTP and CDN caching in HTTP semantics and caching.

The four patterns, and where each goes stale

Pattern Write path Goes stale when
Cache-aside app writes DB, deletes key a racing reader refills old data
Read-through cache loads from DB on miss the same race, inside the cache layer
Write-through app writes DB and cache two writers land in opposite orders
Write-behind app writes cache; DB later the cache dies before flushing
  • Cache-aside (lazy loading) is the default: on a miss the application reads the database and fills the cache; on a write it updates the database and deletes the key. Deleting is better than setting the new value, because two writers setting values can finish in the opposite order from their database commits.
  • Write-through is often described as “never stale”. It is not: writer A commits 1, writer B commits 2, B’s cache SET lands, then A’s lands, and the cache holds 1 forever (or until TTL). And if the cache write fails after the database commit, the two disagree with no one noticing.
  • Write-behind acknowledges a write once it is in the cache and flushes to the database later. It makes the cache the system of record for those seconds; a lost cache node loses acknowledged writes. Reserve it for data you can afford to lose or rebuild (counters, view counts).

The race delete-on-write does not close

The interleaving, run with an in-memory cache standing in for Redis:

text
--- cache-aside, delete on write
reader  cache miss
reader  read DB: old@example.com
writer  UPDATE DB -> new@example.com
writer  DELETE cache key
reader  SET old@example.com
result  cache=old@example.com  db=new@example.com

The reader read the old row, was slow for 20 ms (a GC pause, a busy event loop, a slow network), and wrote the old value after the writer’s delete. Nothing will correct it until the TTL expires. The window is small, but at thousands of requests per second on a hot key it is hit daily.

The replica-lag variant

The same thing happens with no slow reader at all, if misses are served from a read replica:

  1. The writer updates the primary and deletes the key.
  2. A reader misses and reads from a replica that is 300 ms behind.
  3. It fills the cache with the pre-update row.

Under replication lag, “delete after write” deletes too early. Misses for recently written keys must read from the primary, or the invalidation must be delayed past the lag.

The fixes, and what each costs

Leases: reject a fill that has been overtaken

Facebook’s memcache (Scaling Memcache at Facebook, NSDI 2013) issues a lease token with every miss. The reader’s SET must present the token, and a delete in between voids it:

text
--- with leases
reader  cache miss
reader  read DB: old@example.com
writer  UPDATE DB -> new@example.com
writer  DELETE cache key
reader  SET old@example.com with lease 1: REJECTED
result  cache=None  db=new@example.com

The next reader misses again and loads the new row. The same mechanism rations misses: while a lease is outstanding, other readers wait briefly or get a stale value instead of all hitting the database, which also solves the stampede for that key. Redis has no built-in leases; a Lua script with a token in a side key gives the same effect.

Versioned writes: the cache keeps the newest

If rows carry a version (a version column incremented on every update, or an LSN), the cache can refuse to go backwards. One atomic script in Redis:

lua
-- KEYS[1]=key  ARGV[1]=version  ARGV[2]=payload  ARGV[3]=ttl seconds
local cur = tonumber(redis.call('HGET', KEYS[1], 'v') or '-1')
if tonumber(ARGV[1]) <= cur then
  return 0                                  -- older than what we hold
end
redis.call('HSET', KEYS[1], 'v', ARGV[1], 'd', ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1

The catch: on a write you must not simply delete the key, or a slow reader finds no version to compare with and writes its old one. The writer either sets the new value with its version, or leaves a short-lived tombstone holding the new version and no payload.

Delayed double delete

Delete on write, then delete again after a delay longer than replication lag plus a read (often 0.5-1 s, from a queue or a scheduled task). It narrows the window to readers slower than the delay; it does not close it, and it doubles invalidation traffic. Cheap and common, which is why interviewers ask what it still misses.

Invalidation from the database log (CDC)

Debezium (or a logical replication consumer) reads committed changes from the WAL and deletes the affected keys. Invalidation then happens only after commit, for every writer, including scripts and migrations that bypass the application. Costs: another pipeline to run, invalidation that lags by its delay, and per-key ordering only if events for one key stay on one partition.

It narrows the race (the delete arrives later) but a slow reader can still refill, so pair it with leases or versions where it matters. Ordering caveats are the same as for the outbox: The transactional outbox.

The TTL bounds everything

Every pattern above leaves some case open: a lost invalidation message, a deploy bug, a manual UPDATE in production. The TTL is the upper bound on how long any of them can serve wrong data, so choose it from the business question “how stale may this be?”, not from the hit rate. Add jitter so keys written together do not expire together (Cache Stampede and Mitigation Patterns).

What to cache and how to know it works

  • Cache what is read far more than written, and expensive to compute: a profile page, a permission set, a rendered product listing. Data that changes every request gains nothing.
  • Negative caching: cache “not found” too, with a short TTL (30-60 s), or a stream of requests for IDs that do not exist (bots, enumeration) goes straight to the database every time. A Bloom filter of existing IDs in front of the lookup rejects impossible keys without storing each miss.
  • Hit ratio decides the database’s load: origin reads = QPS x (1 − hit ratio). Dropping from 99% to 98% doubles database reads; 99% to 90% is 10x. Watch it per key prefix, not only globally.
  • Measure staleness, not just hits: sample a small fraction of cache hits, read the same key from the primary, and count mismatches. A stale-read rate of zero proves the invalidation path; a rate above zero after a deploy finds the bug before users do.

Layers: in-process, Redis, CDN

Layer Latency Coherence
In-process (cachetools.TTLCache) ~100 ns none: one copy per process
Redis client-side (RESP3) ~100 ns on hit server pushes invalidations
Redis ~0.2-1 ms one copy; invalidation is yours
CDN / HTTP cache at the edge TTL, purge API, stale-while-revalidate
  • In-process caches multiply: 50 pods x 4 workers is 200 independent copies, and a delete reaches none of them. Use them only for data that may be stale for the whole TTL (feature flags, config), with short TTLs.
  • Redis client-side caching is the coherent near-cache: with RESP3 the server tracks which keys a client has read and pushes an invalidation when they change. In redis-py 5.1+:
python
from redis import Redis
from redis.cache import CacheConfig

r = Redis(host="cache", protocol=3, cache_config=CacheConfig(max_size=10_000))
r.get("user:42")      # network
r.get("user:42")      # served locally until Redis pushes an invalidation

If the connection drops, invalidations sent meanwhile are lost, so the client must flush its local cache on reconnect; check that yours does.

  • CDN caching is for responses shared by many users; anything personalised needs Cache-Control: private, or one user’s page is served to another.

Deploys and the shape of cached data

During a rolling deploy, old and new code read each other’s cached values. A renamed field breaks deserialisation in one version or serves a half migrated object. Either keep cached payloads backward compatible (add fields, never rename), or version the key prefix (user:v2:42) so each version reads its own entries. A prefix bump is a cold cache for that data: warm the hot keys before shifting traffic, or the deploy is a stampede.

Where it breaks in production

  • “I updated it and users still see the old value for an hour”: the read/write race or a lagging replica refilled the key, and the TTL is an hour. Signal: the sampled stale-read rate.
  • A cache flush or failover takes the database down: the database was sized for a 1% miss rate. Signal: hit ratio and database CPU together; defences in Cache Stampede and Mitigation Patterns.
  • Evictions you did not expect: Redis’s default maxmemory-policy is noeviction (writes fail when full), while ElastiCache’s default parameter group uses volatile-lru, which never evicts keys without a TTL. Signals: evicted_keys, used_memory; see Persistence, eviction and memory.
  • One hot key saturates one shard: a celebrity profile or a global config key. Find it with redis-cli --hotkeys (needs an LFU eviction policy); fix it with an in-process layer for that key or replicas of it.
  • Data leaking between users: a shared CDN or application cache keyed without the user or tenant.

The trade not taken

No cache. Before adding one, check whether the read is slow because it lacks an index, could be a materialized view, or could go to a read replica. Each of those stays consistent (or consistently lagged) without an invalidation path to get wrong. A cache is a second copy of the data with its own failure modes; add it when the read rate, not a missing index, is the problem.

The cache as the source of truth (write-behind for everything) trades durability for write latency. It is right for counters and rate-limit windows, wrong for orders and balances.

Interview angle 4

  • “You update the row and delete the key, yet users see the old value for an hour. How?” - a reader missed, read the old row, and set it after your delete; or a miss read a lagging replica. The TTL is an hour, so it stays.
    • Follow-up: how do you close that gap? A lease from the cache on each miss, voided by the delete, so the late SET is rejected; or versioned writes where the cache refuses an older version.
    • Follow-up: and across two regions? Invalidations must reach every region’s cache, usually from the database’s change stream in each region; until they do, the TTL is the bound, so keep it short for data that matters.
  • “Why delete on write instead of updating the cache?” - two writers setting values can land in the opposite order from their commits; deleting makes the next reader load the committed value.
    • Follow-up: so write-through is worse? It has the same ordering race plus the failed-second-write case; it helps only when every write goes through one serialized path.
  • “The hit rate fell from 99% to 97%. What happens?” - database reads triple. If the database was sized for 1%, latency climbs, then timeouts.
    • Follow-up: what do you do first? Find which prefix lost hits (a deploy that changed keys, an eviction storm), shed or coalesce misses, and warm the cache before restoring traffic.
  • “In-process cache or Redis for a 50-pod service?” - in-process for data that may be stale for the whole TTL; Redis for anything that must be invalidated, or Redis client-side caching for both speed and coherence.
    • Follow-up: what breaks with client-side caching? Invalidations sent while the connection is down are lost, so the client must flush on reconnect.