Module P-12·38 min read

Cache-aside vs write-through vs write-behind, and the two failure modes that take the database down anyway: the stampede and the thundering herd.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-12 — Distributed Caching with Redis

What this module covers: Module F-12 established what a cache is and how to size one. This module is about running a shared, networked cache and surviving it. The four read/write strategies and the specific data loss or latency each one buys, the Redis behaviours that page you — single-threaded command execution, maxmemory-policy, RDB versus AOF — cache stampede as the failure mode that turns a cache into an origin-killer, the hot key that sharding cannot fix, cluster mode's hash slots and multi-key restrictions, and the rule that a cache outage must degrade rather than fail.


Moving the Cache Off the Box Changes What Can Go Wrong

An in-process cache is a hash map. It cannot be unreachable, it has no serialisation cost, and its consistency question is trivial because there is one copy on one heap. It also cannot be shared, dies on deploy, and multiplies memory by your instance count.

A shared Redis instance fixes all three of those and introduces an entirely new set of problems, all of which follow from one fact: the cache is now a network service with its own capacity, its own availability, and its own failure modes. A cache lookup is no longer ~100 ns of DRAM (Module F-2) — it's a round trip, so roughly 0.5 ms in-datacentre plus serialisation, which is still 10–100× faster than the query it replaces but is no longer free. More importantly, it can now time out, refuse writes, evict your working set, stall on someone else's command, and go down entirely while your database is sized on the assumption that it never would.

That last point is the whole reason this module exists. Module F-12's closing observation — that caching a slow query converts a performance problem into an availability problem — is a statement about distributed caches specifically. A local cache cannot take your service down. A shared one can.


Four Strategies, Four Different Things You Agree to Lose

The strategy names get used loosely, so pin them to who does the work and when.

Cache-aside (lazy loading). The application owns the cache. On read: check cache, miss, query the origin, write the result back, return it. On write: write the origin, then delete (or update) the key.

ts

Cache-aside is the default and should be, because it is explicit, it tolerates cache unavailability trivially (a failed get is just a miss), and it caches only what is actually asked for. Its costs: every miss path is written by hand in every call site until you abstract it, the first request after any eviction pays full origin latency, and there is a window between the origin write and the cache delete where readers see stale data (Module P-13 is entirely about that window).

Read-through. Same read shape, but the cache library or a sidecar owns the fill. The application calls cache.get(key, loaderFn) and never sees the miss. Cleaner call sites and a natural place to hang single-flight logic. The cost is that the fill path is now inside a layer you did not write, so its timeout, retry, and error semantics are its opinions rather than yours — and a loader that throws inside a shared cache library often turns into a cached error or an unbounded retry.

Write-through. Every write goes to the cache and the origin synchronously, in the same request, before acknowledging. The cache is never stale for data written through it.

The cost is stated plainly: write-through doubles your write latency and doubles the number of things that must be up for a write to succeed. You have added the cache to the critical write path, so a Redis failover of two seconds is two seconds of failed writes — on a system where the cache was supposedly optional. It also caches data nobody has read yet, which for a write-heavy, read-sparse table means you are paying memory for keys that will expire unused.

Write-behind (write-back). Write to the cache, acknowledge the client, flush to the origin asynchronously in batches.

This is the fastest possible write and the one with a genuinely dangerous failure mode: write-behind loses acknowledged data when the cache process dies. You told the client the write succeeded; the only copy was in a memory buffer; the process was OOM-killed or failed over and the buffer went with it. Batching also means the origin is behind by up to a flush interval, so anything reading the origin directly — a report, a CDC pipeline (Module P-23), a second service — sees data that does not match what the API just confirmed. Write-behind is correct for high-volume, individually-worthless writes where you have decided out loud that losing the last few seconds is acceptable: view counters, last-seen timestamps, rate limit buckets (Module P-18). It is never correct for a payment (Module P-24).

StrategyRead latency on missWrite latencyStalenessFailure mode you are buying
Cache-asideFull origin costOrigin onlyWindow between origin write and invalidationStampede on a hot miss; every miss path hand-written
Read-throughFull origin costOrigin onlySame as cache-asideFill semantics owned by a library, not by you
Write-throughFull origin costOrigin + cacheNone for written keysCache is now on the write path; its downtime is your downtime
Write-behindFull origin costCache only (fastest)Origin lags the cacheAcknowledged writes lost on crash; origin readers disagree with the API

The honest default for almost every system: cache-aside for reads, synchronous invalidation on write, and write-behind reserved for named counters where the loss is priced in.


Redis Executes One Command at a Time, So One Bad Command Stalls Everyone

Redis processes commands from a single thread. Networking is threaded in modern versions, but command execution is serialised, and that single design choice explains most Redis incidents.

The consequence: there is no such thing as a slow command that only affects the caller. Every command holds the execution loop for its whole duration, and everyone else waits. Redis does 100K+ simple operations per second precisely because each one is O(1) and takes microseconds. Introduce one command that takes 200 ms and you have just added up to 200 ms to the p99 of every other client — including the health check, including the session lookup on the login path, including services that have nothing to do with the offending key.

The commands that do this:

  • KEYS pattern — scans the entire keyspace. On a few million keys this is tens to hundreds of milliseconds of total blockage. Use SCAN, which returns a cursor and lets other commands interleave. KEYS in a running application is close to a guaranteed incident; it is usually introduced by an admin script or a "clear the cache for this tenant" feature.
  • SMEMBERS / HGETALL / LRANGE key 0 -1 on a collection that grew. These are fine at 100 elements and catastrophic at 2 million: O(n) to gather, plus a multi-megabyte reply to serialise and push through the socket. The bug is almost never the original code — it is that the set had no bound and quietly grew for eight months.
  • DEL on a huge collection. Freeing a million-element structure is O(n) work in the same thread. UNLINK hands the reclamation to a background thread; prefer it for anything large.
  • Lua scripts and FUNCTION calls, which are atomic because nothing else runs while they execute. That atomicity is genuinely useful (it is how you build a correct rate limiter or a compare-and-delete), and the cost is that a loop inside a script is a stop-the-world pause.
  • FLUSHALL synchronously, and anything that touches every key.

Analogy: the hot cache key is the printed timetable taped to the station wall. Two hundred commuters a minute read it and never trouble the station master. The moment a cleaner peels it off, those same two hundred people queue at his one window in thirty seconds asking the identical question, and he can answer three. The paper was never the load-bearing part — his throughput was, and the paper was hiding that from everyone, including him.

Why this matters in production: a Redis latency spike presents as a service-wide p99 spike with no corresponding database or CPU signal, which sends teams hunting in the wrong place for an hour. The diagnostic is SLOWLOG GET, plus INFO commandstats for the aggregate time per command type, plus the latencystats percentiles. Set slowlog-log-slower-than low enough to actually catch things (the default threshold hides the 5–10 ms commands that matter when your budget is sub-millisecond). And remember Module F-4: this is fan-out tail amplification with extra steps, because a request that makes eight Redis calls has eight chances to land behind someone else's HGETALL.

The design rules that follow: bound every collection, never write an unbounded pattern scan, keep values small enough that serialisation is not the cost (a 5 MB JSON blob is a bad cache value regardless of how fast the lookup was), and pipeline or MGET batches of small keys rather than issuing them serially — not for Redis's sake but for the round trips (Module F-5).


maxmemory-policy Decides Whether a Full Cache Evicts or Starts Refusing Writes

This is the single most common way a Redis instance behaves in a way nobody expected, because the default is not what a cache wants.

Redis needs two settings to behave like a cache: maxmemory (a byte limit) and maxmemory-policy (what to do at the limit). If maxmemory is unset, Redis grows until the kernel OOM-kills it. If maxmemory-policy is noeviction — the default — Redis at the limit does not evict anything. It starts returning errors on every write command while continuing to serve reads perfectly.

That is a uniquely nasty symptom set: reads fine, hit ratio looks normal, and every cache fill fails silently in a catch block somewhere. The cache stops accepting new entries, so the miss rate climbs as TTLs expire and nothing replaces them, and the origin load rises over minutes while the cache dashboard looks healthy.

PolicyEvictsUse when
noevictionNothing; writes error at the limitRedis is a datastore, not a cache, and losing a key is a correctness bug
allkeys-lruApproximated LRU across all keysGeneral-purpose cache — the usual right answer
allkeys-lfuApproximated LFU across all keysSkewed access with a stable hot set; resists the sequential-flood problem from Module F-12
volatile-lru / volatile-lfu / volatile-ttlOnly keys that have a TTL setMixed instance: some keys are cache, some must persist
allkeys-random / volatile-randomRandom victimsRarely; cheaper than LRU sampling, much worse hit ratio

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.