gap·map

Backend caching

[ middle depth ]

Cache-aside, read-through, write-through, write-behind

Four patterns, distinguished by who talks to the database and when.

Cache-aside is the default. Your app checks the cache, and on a miss it reads the database, populates the cache with a TTL, and returns the value. Reads are lazy: a key only lands in the cache once someone asks for it.

read:  GET key -> hit? return : (read DB, SET key TTL, return)
write: write DB, then DELETE key

Read-through moves that miss logic into the cache layer, so the app only ever talks to the cache and the cache loads from the DB behind the scenes. Write-through writes to the cache and the DB together on every write, keeping them in step at the cost of a slower write. Write-behind (write-back) writes to the cache and flushes to the DB asynchronously: fastest writes, but a cache-node crash before the flush loses acknowledged data. Reach for write-behind only when you can tolerate that loss (metrics, counters), not for orders or payments.

How cache invalidation actually works

Three tools, roughly in order of how much you should trust them:

  • TTL: every key expires after N seconds. It bounds staleness even when everything else fails, so treat it as the backstop under every other strategy.
  • Explicit invalidation: on a write, remove the key so the next read reloads.
  • Versioned keys: bake a version into the key (user:1:v7). A write bumps the version, old entries are never read again and age out on their own.

On a write, delete the key. Do not overwrite it in place.

wrong "On a write I just SET the new value into the cache, so it stays warm." A slow reader that loaded the old row before your write can SET its stale copy after yours, and now the cache is wrong until the TTL fires. Deleting removes the racing target: the next reader misses and repopulates from the fresh row. This is the single most common cache bug, and interviewers ask about it directly.

What a cache stampede is

A popular key expires. Every concurrent request misses at the same instant and hits the database together, and the DB load spikes hard enough to cascade. It is worst for expensive-to-compute hot keys (a homepage feed, a leaderboard). The mental fix at this level: make sure one expiry doesn't turn into ten thousand identical DB queries. How you actually cap that (locking, request coalescing, early recompute) is in the senior notes.

[ senior depth ]

Stampede mitigations and their tradeoffs

Three ways to stop a hot key's expiry from stampeding the database.

Locking (mutex): on a miss, one request takes a per-key lock (SET lock:key val NX EX 5) and recomputes while the rest wait or serve the last stale value. Give the lock a TTL so a crashed holder doesn't deadlock every future reader. The cost is an extra write per miss and a decision about what the losers do.

Request coalescing (singleflight): collapse concurrent misses in one process into a single DB call that fans back out to all waiters. Cheap and lock-free, but it only dedupes within a node; ten nodes still send ten queries.

Probabilistic early recomputation (XFetch): each reader rolls a dice that gets likelier as expiry approaches, and the winner refreshes the value while it is still warm. No lock, no cliff.

recompute early when:  now - delta * beta * ln(random()) >= expiry
  delta = measured recompute cost, beta >= 1 tunes eagerness

Locking and coalescing act at miss time; XFetch and background refresh-ahead act before expiry so there is no miss to stampede at all.

Redis eviction policies

When maxmemory is hit, maxmemory-policy decides what happens. The default is noeviction: writes return errors rather than dropping data. To run Redis as a cache you pick an evicting policy:

  • allkeys-lru / allkeys-lfu / allkeys-random: evict from all keys.
  • volatile-lru / volatile-lfu / volatile-random / volatile-ttl: evict only keys that have a TTL set.

LRU drops least-recently-used, LFU least-frequently-used (better when a small hot set should survive a burst of one-off reads). Both are approximate: Redis samples a handful of keys (maxmemory-samples) instead of keeping a true global ordering, and LFU uses a decaying probabilistic counter.

wrong "I'll use volatile-lru so my cache evicts under pressure." If no key carries a TTL, volatile-* has nothing eligible and fails writes exactly like noeviction. Use allkeys-* for a pure cache; keep volatile-* for a mixed store where some keys are persistent and only the expiring ones may be dropped.

Cache and database consistency

Cache and DB are two systems with no shared transaction, so the dual-write problem is unavoidable: any strategy has a window where one store is briefly wrong, and your job is to choose which one and how long. Write-through does not fix this. It is still two writes to two systems, so a crash between them leaves them disagreeing.

Delete-on-write shrinks the cache-aside stale-set window but does not close it: a reader that already loaded the old row can repopulate right after your delete. When that matters, version the key so stale entries are unreachable, or do a delayed double-delete (delete now, delete again after a beat to catch the racing repopulate). Multi-layer setups add a per-node in-process cache in front of Redis, and per-node invalidation is the hard part: bust via pub/sub, or accept a short TTL of skew.

Interviewers grade whether you volunteer the race and the dual-write reality before being asked, name the specific fix for the specific window, and separate eviction policy from stampede as different problems with different tools.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on