Hotshard

Design a Distributed Cache

A service needs a value, and it has the key for it. The value lives in a database, and reading it from the database is too slow and too expensive to do on every request when the same handful of values are asked for over and over. So put the values in memory, close to the service, and answer most reads from there. Build the system that keeps those values in RAM across many machines, answers reads in about a millisecond, keeps the database from being hammered, and keeps the cached values close enough to the truth. This is a distributed cache: an opaque, in-memory, possibly-stale accelerator sitting in front of a system of record.

Predict ~1.5 h · Read ~50 min

The problem i

A distributed cache looks like a storage problem and turns out to be a traffic problem. The instinct is to worry about how you store all those key-value pairs quickly and how you spread them across machines, because that is what a cache is. Hold that instinct, because it is pointing at the easy half. Everything hard about this system turns on two facts, and neither one is about how you store a value.

Here is the first hard fact. Storing key-value pairs fast is not the hard part. A cache is, at its core, a hash map in memory: hand it a key, it hands back a value, in about a millisecond. That is a named, solved primitive. You can put it on many machines and split the keys across them, and that too is a known move. None of it is where the design gets hard. The database behind the cache is where the durability and the schema live; the cache is deliberately dumb, holding opaque values it can lose on a restart without anyone's data being lost. If storing pairs fast were the whole problem, this would not be a system to design.

Here is the second hard fact, and it is where the wall is. Real read traffic is wildly uneven. Out of millions of keys, a tiny few are asked for a hugely disproportionate number of times — a celebrity's profile, a viral post's counter, the front-page item everyone is loading at once. This unevenness has a name and a shape: it is Zipfian, a few keys drawing most of the requests. And here is the trap. The natural way to spread a cache across machines is to hash each key to a shard, so key by key the load spreads out. But a single key hashes to exactly one shard. It stays there. So when one key is hot enough to overwhelm a single machine, adding more machines does not help that key at all — every request for it still lands on the same one shard, and that shard's own ceiling is the wall. The hard problem is not spreading a million keys across a hundred machines. It is that the one hottest key cannot be spread by that mechanism at all.

Those two facts flip the problem. The scary-sounding part, storing and sharding the data, is the routine part. The boring-sounding part, one popular key, is the wall — and it is a wall that adding hardware in the obvious way runs straight into. Getting the common case fast is the easy half. Surviving the one key that a thousand times more people want than any other is the whole game.

There is a third strand, quieter but real, and it is about staying correct. The cache holds copies of values that live in the database, and the database changes. A copy that has drifted from the truth is a stale read, and left alone it drifts forever. So something has to notice a write and drop the stale copies. And there is a failure that hides inside the happy path: when a popular key's cached copy expires, every request that wanted it misses at the same instant, and instead of one refill from the database you get a thousand of them at once — a herd that can knock the database over precisely when the cache was supposed to protect it. Keeping copies fresh, and surviving the herd, is the third thing the design has to answer.

One idea frames why all of this is worth the trouble. A cache in front of a database is not a speed toy; it is a capacity multiplier. When the cache answers the overwhelming majority of reads from memory, only a small fraction of them reaches the database, so the database tier can be a fraction of the size the raw read rate would otherwise demand. That offload is why the whole design exists, and it is why every mechanism later in this article — keeping copies fresh, surviving the herd, replicating the hot key — is worth its complexity: each one exists to protect that hit ratio, because anything that drops it re-exposes the database to the full read rate.

Two timings are worth pinning now, because decisions later turn on them. A read served from the cache's memory is about one millisecond. Reading the same value from the database, even off an index, is about five milliseconds, and a durable write to the database is about ten. The gap between a millisecond from RAM and a database round-trip is the gap the whole design is built to keep the reader on the right side of.

Functional requirements what it must do

  • Answer a read for a key from memory, fast, most of the time.
  • Keep the database from being hammered by reads.
  • Survive one wildly popular key.
  • Keep cached values close enough to the truth.
  • Do not let a popular key's expiry become a database stampede.

Non-functional requirements what it must be

  • Serve reads in about a millisecond at the ninety-ninth percentile, at fleet scale.
  • Sustain the fleet read rate with no single machine as the ceiling for the common case.
  • Stay available and fast under a node loss, without a database spike.
  • Accept staleness as a tunable dial, not a bug.

Out of scope left out on purpose

  • How the cache stores and persists data on disk
  • The database's own consistency protocol
  • The database's storage engine
  • Deciding a counter's correctness under concurrent writers

How the design works

The finished design, explained one piece at a time: first the contract the system serves, then the smallest version that works, then the version that survives scale — walked use case by use case, one deep dive at a time, with the finished diagram at the end.

The API

The whole product is these endpoints. Every box in the design below exists to serve one of them.

READ keyRead a value by key, answered with the value or a miss. This is the operation that happens millions of times a second and that the entire read-side design is built to make cheap and local.
contract ▾
# The dominant operation — a lookup by key, answered from memory or a miss.
 GET key
 value            # a hit, ~1 ms from RAM
 (miss)           # fall through to the database, then fill the cache
WRITE keyWrite a value for a key, used to fill the cache after a miss and, rarely, to update a value directly. Writes are a small fraction of the traffic; the read-to-write imbalance is the whole reason a cache pays off.
contract ▾
# A write goes to the database first — it is the source of truth.
 SET key value    # fill after a miss, or a direct update

DELETE keyDrop a value by key, the operation invalidation uses to remove a copy that has gone stale. A caller rarely issues it by hand; the system issues it when the database changes.
contract ▾
# Rarely issued by hand — the system drops a stale copy when the database changes.
 DELETE key       # remove the stale copy so the next read refills from the database

part 1 The single-server version

Start with the smallest thing that works: one cache node in front of one database, and a service that knows to check the cache first.

Start with the vocabulary, because these few words carry the whole problem. A service — call it the App server — wants a value, and it has the key for it. It first asks the cache. If the cache has the value, that is a hit, and the value comes back from memory in about a millisecond. If the cache does not have it, that is a miss: the app server reads the value from the database, hands it back to whoever asked, and also writes it into the cache so the next request for that key is a hit. That pattern — check the cache, and on a miss read the database and fill the cache — is called cache-aside, because the cache sits beside the read path rather than in the middle of it. The set of keys actually being asked for at any moment is the working set. Those words — a key, a value, a hit, a miss, cache-aside, the working set — carry everything that follows.

The day-one design is direct. There are three boxes. The App server runs the cache-aside logic. A single Cache node holds key-value pairs in memory. And an Origin DB is the durable system of record — the place the values actually live and the only place that can never be wrong. On a read the app server checks the Cache node; on a hit it is done in a millisecond; on a miss it reads the Origin DB, returns the value, and writes it back into the Cache node. On a write, the app server writes the Origin DB, because the database is the source of truth. For one app server and a modest read rate, this is genuinely fine, and it already earns its keep in the one way that matters most.

That way is the offload, and it is worth doing the arithmetic out loud because it is the number that justifies every later piece of this design. Suppose the app server is taking a million reads a second, and suppose the cache answers ninety-nine of every hundred of them from memory — a high hit ratio. Then only about one in a hundred reads misses and falls through to the database, which is about ten thousand reads a second reaching the Origin DB instead of a million. The database only ever has to be built for that ten thousand. Without the cache it would have to survive the full million. That is a database tier roughly a hundred times smaller for the exact same read traffic. The cache is not making individual reads a little faster as a nicety; it is a capacity multiplier that lets a small database serve a huge read load. Hold onto that hundredfold, because every hard decision later in this article is in service of protecting it — the instant the hit ratio slips, that multiplier swings back the other way and the database feels it.

App servercache-asideCache node· Redisone node · in-memoryOrigin DB · MySQLsource of truthdurable
The whole system on day one.
Following a request
A read for a key
  1. the app server checks the cache for the key — on a hit the value comes back from memory in about a millisecond~1 ms
  2. on a miss the app server reads the value from the database~5 ms
  3. and writes it back into the cache, so the next read for that key is a hit~1 ms
A write for a key
  1. the app server writes the database, because the database is the source of truth~10 ms

One hop dominates every path: the client's request crossing the public internet to reach the datacenter. That hop is tens of milliseconds — call it about 30 — and it is set by physical distance, not by anything we build. Everything after it happens inside a single datacenter, where the network between two machines is well under a millisecond — so those numbers are the actual work, not the wait: a durable write to disk about 10 ms, an indexed read about 5 ms. A hop between two datacenters would cost 10–100× the in-datacenter number, which is why the whole path stays in one region.

This one-node design does not survive being pointed at real scale, and it fails for a plain, physical reason that has nothing to do with the hot key yet. One cache node runs out of two things at the same time. It runs out of memory: the working set — all the keys actually being asked for — grows past what one machine's RAM can hold, and once the cache cannot hold the working set the hit ratio falls and the database feels the full read rate. And it runs out of throughput: a single Redis node tops out at about 180k operations a second when clients are not batching their commands, because Redis runs commands on a single thread and cannot simply use more cores to go faster. A million reads a second is far past what one node can answer even if every read were a hit. So the working set no longer fits, and the read rate no longer fits, on any one machine. That is what forces the first real decision, and it is where the scaled design begins.

part 2 The version that survives scale

This section walks the design by use case, in the order you actually have to decide things. First, spread the cache across many nodes so the working set and the ordinary read rate both fit — and pick the sharding scheme that does not melt down every time a node joins or leaves. Then face the wall the brief warned about: one key that no amount of sharding can spread, and the two-part fix it forces. Then keep the whole thing correct — drop stale copies when the database changes, decide what to evict when memory fills, and survive the herd when a hot key expires. And finally, watch a real company run straight into the hot-key wall in production, and see that the fix was exactly the one this design arrived at. The finished diagram waits at the end.

Two ways through this section

The six decisions below are the heart of this design, and there are two ways to take them. In the design room you make each one yourself and the board reacts to your pick. The write-ups after this point cover the same decisions in full — read them instead, or afterwards as reference.

use case 1 The working set outgrows one node

A single cache node has hit two ceilings at once — its memory and its throughput. There is only one way past that: spread the data across many nodes instead of one. The interesting part is the mapping itself — how each key gets assigned to a node, so that adding or losing a machine does not scramble the whole cache.

deep dive 1 The working set outgrows one node
The question

One cache node has run out of room and speed at the same time. The working set no longer fits in one machine's memory, and the read rate is past one machine's single-threaded ceiling. Both problems have the same shape: too much for one box. So the answer has the same shape too — use more boxes. The only real question is how you decide which key lives on which box, and that decision is less obvious than it looks. The keys have to split across many cache nodes, the app server has to know which node holds a key without asking every node, and the fleet is not fixed — nodes get added as load grows and removed when they fail.

Follow the obvious answer first, because it is what most people reach for: hash the key and take the result modulo the number of nodes. With ten nodes, key hashes land in buckets zero through nine, and each bucket is a node. It is simple, it is direct, and the app server computes the node itself with no lookup. The attraction is real. But watch what happens when the fleet changes. Add one node, going from ten to eleven, and now every key is hashed modulo eleven instead of ten — which means almost every key maps to a different node than before. The cache does not gently rebalance; it effectively empties, because nearly every key is now being looked up on a node that does not have it. Every one of those lookups is a miss, and every miss falls through to the database at the same time. Adding a single node to relieve load triggers a fleet-wide cache-miss storm that slams the database — the exact thing the cache exists to prevent, caused by the routine act of growing the fleet. The same happens, unavoidably, every time a node fails and drops out.

So the requirement sharpens: adding or removing a node must move only a small fraction of the keys, not almost all of them. That is exactly what a consistent-hash ring gives you, and it is why the ring wins over mod-N. Mod-N was simpler, but its fleet-change behavior was a self-inflicted stampede; the ring pays a little complexity to make membership changes cheap, which is the property that matters at fleet scale.

A consistent-hash ring with virtual nodes — keys and nodes on one circle, a membership change moves only one arc. Map both keys and nodes onto the same fixed circle of hash values. A key belongs to the first node found walking clockwise from the key's position on the circle. When a node is added, it drops onto one arc of the circle and takes over only the keys in that arc — every other key stays exactly where it was. When a node leaves, only its arc's keys move, to the next node clockwise. A membership change moves a small fraction of the keys instead of nearly all of them. To keep each node's share even, each physical node is placed at many points around the circle rather than one — virtual nodes — so load and the cost of a membership change spread smoothly across the whole fleet instead of dumping onto one unlucky neighbor. This is the sharding scheme Amazon's Dynamo uses and the norm across distributed key-value systems.

Now size the fleet, and this is where the both-sizings habit earns its keep, because a cache has to be sized two different ways that give two different answers, and you take the larger. The throughput sizing is the read rate divided by a node's ops ceiling — a million reads a second over about a hundred and eighty thousand a node is about six nodes just to serve the rate. The capacity sizing is the working set held in memory, and the working set is not just its raw bytes: take a hundred million items averaging a kilobyte each, about a hundred gigabytes raw, replicate it three ways so a single node loss does not cold-cache a whole slice, and add the memory allocator's ten-to-twenty-percent rounding overhead, for about three hundred and forty-five gigabytes of actual fleet RAM — roughly three nodes on a hundred-and-twenty-eight-gigabyte machine. So throughput wants about six nodes and capacity wants about three, and the honest answer is the larger of the two. A fleet that can hold the working set but cannot answer the read rate is just as broken as one that can answer the rate but cannot hold the set.

Under the hood — how it works

Two facts shape the per-node ceiling, and they are why the throughput sizing is a range rather than a constant. First, Redis executes commands on a single thread, so a node's throughput does not climb by adding cores — it climbs by clients batching commands together, which takes that roughly hundred-and-eighty-thousand-a-second figure up past one and a half million when sixteen commands are sent at once. Memcached makes the opposite choice — it is multi-threaded, with worker threads defaulting to the core count — which is why its per-node ceiling scales with cores instead of with batching. Neither is simply better; they put the throughput ceiling in different places. Second, connections are not free: an instance juggling thirty thousand connections sustains only about half the throughput of the same instance serving a hundred, so a fleet in front of a large app tier has to pool connections or pay for it in ops a second.

Its throughput sizing is the read rate divided by the per-node ops ceiling — about six nodes for a million reads a second unpipelined, fewer if clients pipeline. Its capacity sizing is the working set times the replication factor times the slab overhead, divided by per-node RAM — about three nodes for the worked example. Take the larger. Its failure story is the reason the replication was there: when a node dies, the keys it held are simply lost, because a cache is not the source of truth — those keys go cold and are refilled from the database on the next read. Two things keep that from becoming a database spike: the keys were replicated across three nodes so another node already has a copy for most of them, and a small standby pool absorbs the retries for the rest. The one failure the ring cannot fix by itself is the one the next use case is entirely about: a single key so hot that the one shard holding it is overwhelmed no matter how many nodes the ring has.

⚡ From production

The consistent-hash ring with virtual nodes and three-way replication is not a bespoke design here — it is the model Amazon published for Dynamo, which assigns each physical node to multiple random points on the ring so a join or leave redistributes load across many peers, and replicates each key to N=3 unique nodes clockwise from its position. And the per-node ceiling is Redis's own benchmark documentation: about a hundred and eighty thousand operations a second unpipelined, rising past one and a half million with sixteen-command pipelining, and an instance at thirty thousand connections running at about half the throughput of one at a hundred.

DeCandia et al. — Dynamo: Amazon's Highly Available Key-Value Store (SOSP 2007)
App servercache-asideCache ring· Redisconsistent hash · virtual nodes · N=3Origin DB · MySQLsource of truthdurable
The board after this deep dive — “A read for a key” traced, hop by hop. The numbered steps below walk the same path.
A read for a key
  1. the app server hashes the key to its node on the ring and checks there — on a hit the value comes back from memory in about a millisecond~1 ms
  2. on a ring miss the app server reads the value from the database~5 ms
  3. and fills the ring, so the next read for that key is a hit~1 ms
use case 2 One hot key pins one shard, and more shards don't help

Ordinary traffic is handled — the ring carries it beautifully. Then a single key catches fire, the way one does when something goes viral, and the ring turns from the fix into the bottleneck: consistent hashing nails that key to exactly one node, and buying more nodes changes nothing. This is the wall the brief flagged, and beating it is what the whole board is built around.

deep dive 2 One hot key pins one shard, and more shards don't help
The question

The ring spread the keys across the fleet, and for the millions of ordinary keys it works exactly as promised. Then one key stops being ordinary. A celebrity posts, a post goes viral, one product lands on the front page, and suddenly a single key is being asked for far more than any other — a thousand times an average key, say. That key hashes to exactly one shard on the ring, and it stays there. So every request for it lands on that one shard, and that shard's single-node ceiling is the ceiling for that one key. The fleet has sixty nodes, but fifty-nine of them are doing nothing for this key.

The instinct — and it is a strong, natural instinct, the one nearly every engineer reaches for — is to add more shards. The cache is overwhelmed, so make the fleet bigger. But walk it through and it does nothing, because the problem is not fleet-wide capacity; it is one key's placement. That hot key hashes to one shard regardless of how many shards exist. Double the fleet from sixty nodes to a hundred and twenty, and the hot key is still on exactly one of them, still capped at that one node's ceiling, while the hundred and nineteen others carry none of its traffic. Adding shards spreads different keys onto different machines; it cannot spread one key, because one key is one hash is one shard.

And the arithmetic is counterintuitive. The unevenness is Zipfian, and Zipf has a hard ceiling on what sharding can buy. With a realistic popularity skew, the most sharding can ever multiply your total throughput is roughly ten times a single shard's ceiling, and that is with an infinite number of shards — not a hundred times, not a thousand. Past a point every new shard is carrying cold keys nobody wants while the hot key stays pinned. And the practical onset is earlier than intuition says: model a key drawing about a one-percent traffic share, and a naive one-percent-divided-by-shard-count estimate predicts you would be fine out to about a hundred shards, but the requests do not spread evenly — that one percent concentrates on the single shard holding the key — so errors actually start appearing around fifty shards, roughly half the naive prediction. The myth is that a hot key is a capacity problem you solve by adding capacity. The truth is that a hot key is a placement problem, and adding capacity leaves the placement untouched.

Replicate the hot shard, and promote the hottest keys into an app-local near cache checked before the ring. The fix cannot be more of the same shard. It has to be a different path for the hot key, and it comes in two parts. The first is hot-shard replication: instead of the hot key living on one shard, replicate that shard across several nodes, so reads for the hot key fan out across the copies instead of all hitting one. This is Facebook's default topology in effect — every frontend cluster gets its own full copy of the cache, so the hottest items are implicitly replicated across clusters, with a deliberate opt-out reserved for large, cold items where replicating would waste memory. The second part goes further and is the more powerful move: a near cache. Detect that a key is hot, and promote it into a small cache that lives inside the app server itself, checked before the ring is ever touched. Now the hottest key is answered in-process, on every app server, so the vast bulk of its traffic never reaches the ring at all — it is served from local memory on the very machine that wanted it.

The one key that could overwhelm any shard is exactly the key that is cheapest to keep a copy of everywhere, because it is one key and it is small. The near cache turns the worst key on the ring into the easiest key to serve. And the near cache is not free or automatic-forever: the system samples request traffic to detect which keys have crossed a hotness threshold, and only those cross into the near cache — it is deliberately tiny, a few megabytes per app server, holding just the top of the distribution. Because a promoted value lives in many app servers, it is kept fresh with a short time-to-live and treated as best-effort; the ring and the database remain authoritative. The detection has to keep up as the distribution shifts — yesterday's hot key cools and today's spikes — which is exactly the mechanism the Twitter incident later shows getting subtly, and expensively, wrong.

Under the hood — how it works

Promotion is not free and not automatic-forever. The system samples request traffic to detect which keys have crossed a hotness threshold, and only those cross into the near cache — the near cache is deliberately tiny, holding just the top of the distribution, a few megabytes per app server, not the working set. Because the near cache lives in each app server and there are many app servers, a value promoted there exists in many places, so it is kept fresh with a short time-to-live and treated as best-effort: the ring and the database remain authoritative, and the near cache is a front-line absorber for the hottest handful of keys, not a second source of truth.

The near cache's throughput is enormous and cheap because it is in-process — it serves the hottest keys at memory speed with no network hop — and its whole job is to lift the top-of-Zipf traffic off the ring. Its capacity is tiny by design: only the promoted hot keys, a few megabytes per app server, never the working set. Its failure story is soft: it is app-local and best-effort, so when an app server restarts its near cache is cold and reads simply fall through to the ring, which is correct and only slightly slower for a moment; staleness is bounded by the short TTL, and nothing durable lives there. The hot shard's replication adds copies of one shard's keyspace, so its capacity cost is small while its throughput for the hot key multiplies across the copies; if one replica dies, the others still serve the key, and the ring's normal three-way replication already covered the ordinary keys on that shard.

⚡ From production

Both halves of this fix are shipped, not theoretical. The implicit replication of hot items across every frontend cluster — with a deliberate opt-out for large, cold items — is Facebook's real default topology, the production form of hot-shard replication. And promotion of a detected hot key out of the sharded tier into a local cache is the mitigation Twitter landed on after its own hot-key outage, pulling detected hot keys out of the shared sharded tier and serving them from a local near cache.

Dan Luu — A Decade of Major Cache Incidents at Twitter
App servercache-asideCache ring· Redisconsistent hash · virtual nodes · N=3hot-shard replicationOrigin DB · MySQLsource of truthdurableNear cacheapp-local · hottest keys · short TTL
The board after this deep dive — “A read for a key” traced, hop by hop. The numbered steps below walk the same path.
A read for a key
  1. the app server checks its app-local near cache first — a promoted hot key is answered here, in-process, and never reaches the ring~1 ms
  2. on a near-cache miss the app server hashes the key to its shard on the ring and reads there~1 ms
  3. on a ring miss the app server reads the value from the database~5 ms
  4. and fills the ring, so the next read for that key is a hit~1 ms
use case 3 Keep the cache correct, and survive the herd

The read path is built: the near cache absorbs the hottest keys, the ring serves the rest, the database sits behind, protected. But three problems the happy path has been quietly glossing now have to be faced, because each one is a way the cache stops being trustworthy or stops protecting the database. The database changes and the cache holds stale copies. Memory fills and something has to be thrown out. And a popular key expires and a herd of misses hits the database at once. All three are about keeping the hit ratio — and the offload it buys — from quietly eroding.

deep dive 3 Keep the cache correct: the database changed, and the cache is now wrong
The question

The cache holds a copy of a value. The database is the source of truth, and it just changed — a user updated their profile, a count ticked up. The copy in the cache is now stale, and cache-aside on its own has no idea. Left alone, that stale copy is served to every reader until something removes it. And this has to work at fleet scale: a single logical write can touch a cached copy in many clusters, and the write path cannot slow to a crawl to keep them all in lockstep.

The simplest answer is a time-to-live: stamp every cached value with an expiry, and let it fall out on its own after, say, a few minutes. It is trivial to implement and it bounds the staleness — nothing is ever more than the TTL out of date. But a TTL is a blunt instrument. Set it short and you throw away good cached values constantly, dropping the hit ratio and re-exposing the database; set it long and readers see stale data for the whole window. A TTL alone means choosing between a poor hit ratio and visible staleness, with no way to react to an actual change.

The next answer is write-through: on every database write, synchronously update or delete the cached copy in the same breath. Now the cache is never stale — the write path keeps it in lockstep. The cost is that every write now pays a cache round-trip, to every place that copy might live, and at fleet scale a single logical write can touch a cache copy in many clusters. The write path, which was simple, becomes a fan-out to the whole cache tier, and it blocks on it. For a read-heavy system, paying that on the write path on every write is a lot of coupling to buy freshness — which is why the move that scales is to invalidate off the write path, driven by the database's own commit log, with a short TTL underneath as the backstop.

Invalidate on commit — tail the database commit log and broadcast batched deletes, with a short TTL as the backstop. Drive the invalidation off the write path, from the database itself. Put a component next to the database that watches its commit log — the ordered stream of changes the database already writes for its own replication, called the binlog in MySQL — and, whenever a committed change touches a key, that component broadcasts a delete for that key to every cache cluster. Facebook's version is a daemon that tails each MySQL commit log, extracts the keys touched by committed deletes, batches them, and broadcasts the batch to every frontend cluster. The freshness is event-driven — a change in the database causes a delete in the cache — but it happens asynchronously, at commit granularity, off the write path, so the write stays fast and the invalidation fan-out is batched rather than paid per-write-per-cluster.

The write path just writes the database; the database's own change stream drives the cache back toward truth a moment later. Underneath it, keep a modest time-to-live as a backstop, so anything the invalidation stream misses still ages out on its own. That leaves a bounded moment of staleness between the commit and the broadcast — which is exactly the staleness a cache is designed to tolerate. A blunt TTL alone forced a bad trade between hit ratio and staleness; a synchronous write-through bought freshness by coupling every write to the whole cache tier. Invalidate-on-commit gets event-driven freshness while keeping writes fast.

Under the hood — how it works

The Invalidator is sized by the database's commit and delete rate, not the read rate, and it batches deletes before broadcasting so the invalidation fan-out is not paid per-write-per-cluster. It holds almost nothing durably — only in-flight batches of keys to delete — and if it lags or dies, cached entries simply go stale until the TTL backstop catches them, and it resumes from its last-processed position in the commit log because the log itself is the durable checkpoint. It is a write-path component with a real data path — tail the commit log, extract touched keys, batch, broadcast — which is why it earns its own box rather than a badge.

The freshness this buys is eventual, not instantaneous, and that is the deliberate trade. Between the moment the database commits and the moment the batched delete lands in a cluster, a reader can still get the stale value — a window of milliseconds to seconds, bounded and small, and exactly the staleness a cache is designed to tolerate. The TTL backstop underneath is the safety net for anything the stream drops: a commit-log gap, a cluster that missed a broadcast. Freshness is driven by the database's own change stream, and bounded from below by the TTL.

⚡ From production

Invalidate-on-commit is Facebook's `mcsqueal`: a daemon that tails each MySQL commit log, extracts the keys touched by committed deletes, batches them, and broadcasts the batch to every frontend cluster, so a change in the database drops the stale copies without the write path fanning out to the whole cache tier. Every frontend cluster gets its own full copy of the cache by default, with regional pools the deliberate opt-out for large, rarely-accessed items.

Nishtala et al. — Scaling Memcache at Facebook (NSDI 2013)
App servercache-asideCache ring· Redisconsistent hash · virtual nodes · N=3hot-shard replicationOrigin DB · MySQLsource of truthdurableNear cacheapp-local · hottest keys · short TTLInvalidator ·binlog tailertails commit log · batched deletes
The board after this deep dive.
deep dive 4 Keep the cache correct: memory is full, so something has to go
The question

The cache's memory is finite, and the working set plus everything recently touched will eventually overflow it. When a new value needs room and there is none, some existing value has to be evicted. Which one? Get it wrong and you evict a value that is about to be asked for again, turning a would-be hit into a miss and a database read. And the access pattern here is not uniform — it is the same Zipfian skew that produced the hot key, a few keys steadily hot and a long tail of cold ones.

The default reflex is least-recently-used: evict whichever value has gone longest without being touched. It is the standard, it is cheap, and most of the time it is right. But under heavy skew it has a specific failure. A key that is steadily hot — asked for constantly but not in the single most recent instant — can get evicted during a burst of one-off accesses to other keys, simply because it was not the most recent touch, even though it is one of the most frequently wanted values in the whole cache. Evicting a steady-state hot key is exactly the wrong move, because it will be asked for again immediately and now it is a miss.

The alternative is least-frequently-used: evict whichever value is asked for least often, tracking a frequency count per key rather than just a last-touch time. Under Zipf, LFU protects the steadily-hot keys that LRU would drop, because it is measuring the thing that actually predicts a future hit. The historical objection was cost — an exact frequency count per key is expensive — and Redis answers it with an approximated, decaying counter, so LFU is cheap and cold-but-once-popular keys still age out. Neither is universally right, but under this problem's skew LFU is the better default.

Least-frequently-used, with an approximated decaying counter. Evict by least-frequently-used: throw away whichever value is asked for least often, tracking a frequency count per key rather than just a last-touch time. Under Zipf, this protects the steadily-hot keys that recency-based eviction would drop, because it is measuring the thing that actually predicts a future hit — how often a key is wanted, not how recently. The catch used to be cost: an exact frequency count per key is expensive. Redis solves that with an approximated counter — a probabilistic counter that also decays over time, so a once-popular key that has gone cold does not stay artificially protected forever.

Neither policy is universally right, and that is worth saying plainly. Least-recently-used is the better default when recency really does predict the next access; least-frequently-used wins under skew where a small set of keys stays hot regardless of recency. It is a read-pattern decision, and Redis ships both precisely because there is no single right answer. And the physical memory layout is worth naming because it fed the capacity sizing two use cases ago: memory is carved into fixed-size slab classes stepping up by about a factor of one and a quarter across roughly forty classes, and each value rounds up into a class, which is where the ten-to-twenty-percent overhead in the capacity math came from.

Under the hood — how it works

Eviction is O(1) per access with the approximated counters, so it never gates throughput; its capacity effect is the slab overhead already folded into the ring's sizing. The frequency counter Redis uses is a probabilistic counter that decays over time, so it costs a few bits per key rather than a full integer count, and a key that was popular last week but is cold now does not stay artificially protected — its counter decays until it becomes evictable. That decay is what keeps LFU from being fooled by history.

The physical memory layout is the other half of the story, and it is the same one that fed the capacity sizing: memory is carved into fixed-size slab classes stepping up by about a factor of one and a quarter across roughly forty classes, and each value rounds up into the smallest class that fits, which costs about ten to twenty percent over the raw item size. Eviction is cheap enough that it never gates throughput, and a cache that is evicting too aggressively is telling you something useful — that the working set is undersized for the fleet's RAM, which feeds straight back into the capacity sizing.

⚡ From production

Redis offers `allkeys-lru`, `allkeys-lfu`, and `volatile-ttl` among its eviction policies, and its LFU is approximated with a low-overhead probabilistic counter that decays over time — the mechanism that makes frequency-based eviction cheap enough to run per access. It ships both LRU and LFU precisely because there is no single right answer: the choice is the workload's access pattern.

Redis — Key eviction (LRU / LFU policies)
deep dive 5 Keep the cache correct: a hot key expires, and a thousand callers miss at once
The question

A popular key's cached copy expires, or is invalidated. In the very next instant, every one of the many callers who wanted that key misses at the same time. With plain cache-aside, each of those callers independently does the miss thing — reads the database and refills the cache. So instead of one database read to refill the key, the database gets a thousand simultaneous reads for the same value, a thundering herd, precisely on the popular keys and precisely when the cache was supposed to be shielding the database.

The mechanism is a lease. When a caller misses a key, the cache hands exactly one of them a short-lived token that says you go get it from the database, everyone else waits. Facebook's cache issues a sixty-four-bit lease to the first misser and will not issue another for that same key for ten seconds, so the later missers wait briefly and retry rather than all stampeding the database; when the first caller refills the value, the waiters get it from the cache. N concurrent misses on one key become one database read plus a batch of short waits.

There is a complementary approach worth knowing, XFetch, which attacks the same herd from the other side: instead of everyone missing at expiry, each caller independently and probabilistically decides to refresh the value a little before it expires, with the probability rising as expiry approaches, so the recomputes spread out over time and the value rarely expires with a crowd waiting on it. Leases coordinate the herd at the moment of the miss; XFetch avoids the synchronized miss in the first place. Either way, the popular key's refill stops being a database flood — and plain cache-aside, which lets every concurrent miss hit the database, is the thing both of them beat.

A lease — one misser refills, the rest wait, so a hot key's expiry costs one database read. Hand out a lease. When a caller misses a key, the cache hands exactly one of them a short-lived token — a lease — that says you go get it from the database, everyone else waits. Facebook's cache issues a sixty-four-bit lease to the first misser and will not issue another for that same key for ten seconds, so the later missers are told to wait briefly and retry rather than all stampeding the database; when the first caller refills the value, the waiters get it from the cache. N concurrent misses on one key become one database read plus a batch of short waits. And because a hot key expiring is exactly the moment the offload is most at risk, taming the herd is directly protecting the number the whole design is built around.

One more failure closes this use case, because it is the failure that most directly threatens the database. When a cache node dies, every request that would have hit it now misses. With nothing in between, all those misses fall through to the database at once — a node loss becoming a database load spike, the same shape as the herd. The fix is a small standby pool: dedicate roughly one percent of the fleet as spare cache servers, and when a client gets no answer from its assigned node it retries against the standby pool instead of the database. Facebook calls this the Gutter pool. It is not a full replica and it does not need to be; it just has to absorb the retries from a failed node long enough that the loss costs some cache warmth rather than a flood of database reads. It is a mechanism, not a box on the board — a small reserved slice of the same fleet.

Under the hood — how it works

Stampede control costs almost nothing — a lease is a per-key token, not a store — and its worst case is bounded: if the lease holder crashes before refilling, the lease expires after its window and the next misser takes it, so the herd is delayed briefly, never permanently stalled. A lease is granted to exactly one misser per key and withheld from the rest for a short window, so the database sees one refill read for a hot key's expiry instead of one per waiting caller, and the waiters get the value from the cache the moment the holder fills it.

The standby pool closes the node-loss failure. It costs about one percent of the fleet's servers and turns a node loss from a database spike into a brief dip in cache warmth: when a client gets no answer from its assigned node, it retries against the standby pool rather than falling through to the database. It is not a full replica — it just absorbs a failed node's retries long enough that the loss costs warmth rather than a flood of database reads. Together with the ring's three-way replication, it is why losing a cache node does not become a database load spike.

⚡ From production

Facebook's cache issues a sixty-four-bit lease token to whichever client first misses a key and does not re-grant it for that key for ten seconds, so later missers wait rather than all querying the database. And the node-loss cushion is Facebook's Gutter pool — roughly one percent of a cluster's servers held as a standby a client retries against on a server failure, instead of hitting the database.

Nishtala et al. — Scaling Memcache at Facebook (NSDI 2013)
App servercache-asideCache ring· Redisconsistent hash · virtual nodes · N=3hot-shard replicationevict: LFUlease + coalesceOrigin DB · MySQLsource of truthdurableNear cacheapp-local · hottest keys · short TTLInvalidator ·binlog tailertails commit log · batched deletes
The board after this deep dive — “A write for a key” traced, hop by hop. The numbered steps below walk the same path.
A write for a key
  1. the app server writes the database, because the database is the source of truth~10 ms
  2. separately, the invalidator tails the database's commit log and extracts the keys touched by committed changes~1 ms
  3. and broadcasts a batch of deletes to every cache cluster, so the stale copies are dropped a moment later without the write path paying for it~1 ms
use case 4 When a hot key took Twitter down

On the happy path, the design is done. This last use case opens with it already failing — and the failure is no side-show. It is the very wall this board spent every use case preparing for, and it happened for real, knocking a giant platform offline at full production scale. The fix they reached for turns out to be the one argued here all along.

deep dive 6 When a hot key took Twitter down
The question

Step back and look at what is built. The near cache absorbs the hottest keys, the ring serves the working set, hot shards are replicated, the invalidator keeps copies fresh, LFU protects the steadily-hot keys, leases tame the herd, and a standby pool cushions a node loss. It is complete for the happy path. Now the premise arrives already broken, the way a real incident does — and the twist is that this is not an off-to-the-side failure that happens to touch the cache. This is the wall this whole board was built around, hitting a real company in production. In April 2018, one of the largest social platforms in the world had a partial outage lasting about an hour, severe enough to be called a SEV-0. It was not a flood of new users and not a database failure. It was a hot key in the user-data cache.

The instinct, in the room and in the moment, is to read this as a capacity shortfall and reach for the reflex from the signature dive — add more shards, spread the load. And it is exactly the wrong move, for exactly the reason the signature dive established. More shards do not un-concentrate a hot key; the key still hashes to its shard, and the concentration the bad hash created does not dissolve because the fleet got bigger. The problem was never aggregate capacity. It was that specific hot keys were pinned to specific overwhelmed shards, and the detection that was supposed to catch them was sampling too coarsely to react.

The deeper lesson is that hot-key promotion is only as good as the hot-key detection feeding it, and detection is a moving target. The set of hot keys shifts constantly as things go viral and cool off, and the sampling that finds them has to stay fine enough to catch a newly-hot key before it saturates its shard — a threshold that was safe for small values became unsafe when the values grew. The mechanism that promotes a hot key to the near cache is straightforward; keeping the detection accurate as the workload changes underneath it is the ongoing engineering, and it is precisely where this incident went wrong.

Promote the hot key to a near cache — the same fix the signature dive arrived at. Two things went wrong together. First, the system that detected hot keys sampled only about one percent of events to decide what was hot — a rate that had been fine when cache values were small, but was now too coarse to catch the hot keys quickly once the values had grown larger, so hot keys were being missed or caught late. Second, the hash function in use, an FNV1-32, happened to be poorly suited to the actual key distribution, and it concentrated a set of high-variance keys onto a small number of shards. Those few shards took a wildly disproportionate share of the traffic, and their network links — one-gigabit NICs — saturated. The cache tier buckled not because it lacked capacity in aggregate, but because the traffic had piled onto a handful of shards that could not physically move the bytes.

The mitigation that actually resolved the incident was hot-key promotion: detect the hot key, pull it out of the shared sharded tier, and serve it from a local near cache instead, alongside tuning the detection thresholds, migrating to a better hash function, and upgrading the saturated NICs. The near cache is the same box this design added in the signature dive, for the same reason: a hot key served from every app server's local memory never touches the overwhelmed shard at all. The reframe to carry out is the one the whole board is built on. A hot key is not a bigger version of the scaling problem you solve with more machines. It is a placement problem, and the answer is a different code path — promote it, replicate its shard, keep it out of the one place it would otherwise pile up. Twitter learned it the expensive way; the design learns it as the signature.

Under the hood — how it works

The incident is Twitter's, reconstructed from their own postmortem: a partial SEV-0 outage of about an hour, traced to a hot-key detection system that sampled only about one percent of events — too coarse once cache values had grown large — combined with an FNV1-32 hash that concentrated high-variance keys onto a few shards, saturating those shards' one-gigabit NIC links. The fix that held was tuned hot-key detection plus promotion of detected hot keys to a local cache, alongside a migrated hash function and upgraded NICs. The bad hash and the small NICs were real contributors, but the load-bearing fix was removing the hot key from the shared shard.

The reframe carries past this board. A hot key is a placement problem, not a capacity one, so the answer is a different code path — promote it to a near cache, replicate its shard — rather than a bigger fleet that leaves the placement untouched. And promotion is only as strong as the detection feeding it: the hot set shifts as things go viral, so the sampling that finds a newly-hot key has to stay fine enough to catch it before its shard saturates. That detection accuracy, not the promotion mechanism, is the fragile part.

⚡ From production

Twitter's April 2018 SEV-0 traced to a hot-key detection system sampling only about one percent of events — too coarse once cache values grew — combined with an FNV1-32 hash that concentrated high-variance keys onto a few shards, saturating their one-gigabit NIC links. The fix was tuned hot-key detection plus promotion of detected hot keys to a local cache, alongside a migrated hash function and upgraded NICs.

Dan Luu — A Decade of Major Cache Incidents at Twitter
App servercache-asideCache ring· Redisconsistent hash · virtual nodes · N=3hot-shard replicationevict: LFUlease + coalesceOrigin DB · MySQLsource of truthdurableNear cacheapp-local · hottest keys · short TTLInvalidator ·binlog tailertails commit log · batched deletes
The board after this deep dive — “A read for a key” traced, hop by hop. The numbered steps below walk the same path.
A read for a key
  1. the app server checks its app-local near cache first — a promoted hot key is answered here, in-process, and never reaches the ring~1 ms
  2. on a near-cache miss the app server hashes the key to its shard on the ring and reads there~1 ms
  3. on a ring miss the app server reads the value from the database~5 ms
  4. and fills the ring, so the next read for that key is a hit~1 ms
Where this design sits on consistency (and CAP)

This design makes opposite durability choices for its two kinds of state, on purpose, because they are worth completely different things. The cache read path — the near cache, the cache ring, and the values a reader pulls — chooses availability and throughput over strong consistency. The near cache and the ring serve the copy they hold, close to the reader; a value that has drifted a little from the database is tolerable, because invalidation is event-driven but asynchronous and a TTL bounds the drift; and a miss simply falls through to the database and refills. None of this coordinates to be instantaneously correct, because a cache is a possibly-stale accelerator, not a system of record.

The Origin DB makes the other choice. It is the durable source of truth — the one place a value can never be wrong and the place every cached copy is derived from — so a lost write there is a real, unrecoverable harm, not one the next read repairs. So the Origin DB is the durability-leaning, replicated, backed-up dataset, and it is the one place the system's data is safe. That is exactly why the cache can afford to be cheap and rebuildable: a cache node that dies loses only warmth, because its keys refill from the database on the next read. Durability is bought where it matters and declined where it does not.

There is a name for the underlying choice. When a network fault splits a system in two — that split is the P, for partition — each operation has exactly two honest options: answer anyway from what this half knows and risk being wrong, which is the A, for availability, or refuse until the halves agree, which is the C, for consistency. A partition is not something you choose; it is weather. The useful way to apply the CAP theorem is per operation, by asking what a wrong answer would cost. Here a slightly stale cached value or a re-requested miss costs almost nothing while a lost durable write costs everything, so the design takes availability and throughput for the cache read path and pays for durability only on the Origin DB. Twitter's hot-key outage is the same lesson in the field: serve the hottest key from a near cache close to the reader, and keep the durable store off the hot path.

CAP theorem — the partition trade-off, per operation
The finished board

That is the whole machine, a read path and a write path meeting at the durable database, and every box on it was placed by one of the deep dives above. On the read path an app server first checks its app-local near cache for the hottest keys; on a miss it hashes the key to a shard on the cache ring and reads there; on a ring miss it reads the origin database, returns the value, and fills the ring so the next read hits. On the write path the app server writes the database, and the invalidator tails the database's commit log and broadcasts batched deletes to the ring, so a change in the database drops the stale copies a moment later without the write path paying for it. The near cache absorbs the one hottest key that no shard count could spread; the ring holds the working set and serves the ordinary read rate; hot shards are replicated so no single shard's ceiling is the wall; the invalidator keeps copies close to the truth; LFU eviction protects the steadily-hot keys; leases tame the herd on an expiry; a small standby pool cushions a node loss. If you carry away one sentence, make it the one the whole design turned on: an even-looking scaling mechanism can hide a pathological case that more hardware cannot fix — so ask what happens to the single hottest unit of work before you design.

App servercache-asideCache ring· Redisconsistent hash · virtual nodes · N=3hot-shard replicationevict: LFUlease + coalesceOrigin DB · MySQLsource of truthdurableNear cacheapp-local · hottest keys · short TTLInvalidator ·binlog tailertails commit log · batched deletes
The finished design — every box placed by one of the deep dives above.
What's on the diagram
App server
App server. The service that issues reads and runs cache-aside, and from the hot-key dive the host of the app-local near cache. On a read it checks the near cache first, then hashes a key to its shard on the ring, then falls through to the database on a ring miss, filling the ring on the way back; on a write it writes the database. It is the hub of the read path — it addresses the near cache, the ring, and the database each in turn, rather than chaining them.
Cache ring · Redis
Cache ring · Redis. The sharded cache fleet — the keyspace spread across many nodes by consistent hashing with virtual nodes, three-way replicated, with hot shards replicated further so no one shard's ceiling is the wall. It runs LFU eviction under memory pressure and lease-based stampede control on an expiry. It is sized by the larger of throughput (the read rate divided by a node's ops ceiling) and capacity (the working set times replication times slab overhead, divided by per-node RAM); a node loss loses its slice, cushioned by replication and a small standby pool.
Origin DB · MySQL
Origin DB · MySQL. The durable system of record the cache accelerates — the one place a value can never be wrong and the place every cached copy is derived from. At a high hit ratio it sees only about one percent of the read rate, the roughly hundredfold offload the whole design buys; durability and the schema live here, never in the cache. A cache miss or a cold cache falls through to it, correct and slower, and it is replicated and backed up as its own concern.
Near cache
Near cache. The signature: a small app-local cache that lives inside each app server and holds only the promoted hottest keys, checked before the ring so the top-of-Zipf traffic never reaches a shard. It is a few megabytes per app server, best-effort, kept fresh with a short TTL, cold on restart and correct to fall through to the ring — nothing durable lives here. It is what turns the one key that could overwhelm any shard into the cheapest key to serve, answered in-process on the very machine that wanted it.
Invalidator · binlog tailer
Invalidator · binlog tailer. The write-path daemon that tails the Origin DB's commit log, extracts the keys touched by committed changes, batches them, and broadcasts the batch of deletes to every cache cluster — event-driven freshness, asynchronous, off the write path. It is sized by the database's commit and delete rate, not the read rate, holds only in-flight batches, and resumes from its last-processed commit-log position on restart, because the log itself is the durable checkpoint.
What this design never covered — raise it yourself
How the cache persists to disk

A strong follow-up is what happens to the cache's data on a restart — snapshots, append-only logs. The honest answer names it as an operational tradeoff, not a design decision this board defends: a cache is not the source of truth, so it can come up empty and refill from the database. The durability lives in the database.

The database's consistency protocol

A sharp interviewer may push on how the durable store agrees on a value across its own replicas — quorums, version vectors. Name it as the database's concern, cited here only for the sharding and replication norms this design borrows, not re-derived.

A counter's correctness under concurrent writers

A follow-up on two servers racing to update the same shared value points at a write-correctness problem. Name it as a different center of gravity — this board's forcing number is read concentration, and the cache never arbitrates a counter's truth.

Memcached versus Redis

A natural which would you use invites the architecture contrast — Redis single-threaded, Memcached multi-threaded — which puts the per-node throughput ceiling in different places. Name it as a per-node-ceiling tradeoff (batching versus cores), not a one-is-better answer.

A bespoke hot-key detector

The deepest how does it actually work is how the system decides a key is hot in the first place, given that the hot set shifts constantly and the sampling that finds it is exactly what failed Twitter. Name it as the ongoing engineering the promotion mechanism depends on — detection accuracy as the workload changes underneath it.

Go deeper — the primitives this design leaned on
  • The consistent-hash ringkeys and nodes mapped onto one circle so a membership change moves only a small fraction of keys; the sharding scheme the working-set dive commits to without re-deriving
  • Cache strategy and stampedecache-aside, the write strategies, TTL, and the thundering-herd control the correctness dive argues — the operable model behind invalidate-on-commit and the lease
  • Cache eviction under skewLRU versus LFU evicting a Zipfian access pattern, the eviction decision the correctness dive makes

Feedback on this problem →