Designing a distributed cache
A Redis-like cache at full depth: the single node, LRU and LFU eviction, expiry, hash slots and routing, replication and failover, cache-aside, stampedes and hot keys.
On this page
Every system in this series leans on a cache, and most of them lean on the same one: a few hundred gigabytes of memory, spread over a dozen machines, answering a million operations a second in under a millisecond. This article builds that machine. It starts inside a single node, because the memory layout and the eviction policy decide almost everything, and only then spreads it across a cluster, where the interesting problems are failover and the keys everybody wants at once.
The map
Read this first when short on time. Every branch is a section below.
What a cache must do, and at what size
The interface is four operations — GET, SET with an optional TTL, DELETE, and atomic increments — plus, in a Redis-shaped cache, a handful of data structures such as hashes, sorted sets and lists that let a client do server-side work instead of read-modify-write round trips.
What it must not do is promise durability. A cache is allowed to lose data: that single permission is what lets it keep everything in memory, replicate asynchronously and fail over in seconds. The corresponding obligation on every caller is that correctness must never depend on a hit, which is the rule that most cache incidents come down to.
Working set 500 GB hot data, average value ~2 KB -> ~250 million keys
Overhead ~90 B per key (object header, table entry, expiry)
250M x 90 B ~= 22 GB of pure overhead
Throughput 1,000,000 ops/s at peak, ~95% reads
Per node ~100k ops/s of simple commands per core-thread
-> ~10 primary shards, each ~50 GB, plus 10 replicas
Latency server-side p99 under 1 ms
in-datacenter round trip ~0.5 ms -> end to end ~1.5 ms
Miss cost a miss is a database read, 5-50 ms -> hit rate is the productTwo numbers shape everything that follows. Overhead per key is around 90 bytes before the data itself, so a cache of many tiny values is mostly bookkeeping — twenty-two gigabytes here just to remember that the keys exist. And per-node throughput is bounded by one thread's worth of command execution plus the network card, which is why the answer to more load is always more shards rather than a bigger machine.
flowchart TD A["application"] --> C["cache client, holds the slot map"] C --> S1["shard 1 primary"] C --> S2["shard 2 primary"] C --> S3["shard 3 primary"] S1 -.->|"async replication"| R1["shard 1 replica"] S2 -.-> R2["shard 2 replica"] S3 -.-> R3["shard 3 replica"] A -->|"on miss"| D[["database"]]
- Redis is the reference implementation of this design, and ElastiCache, MemoryDB and Momento are managed versions with different durability promises.
- Memcached is the older, narrower answer: strings only, multi-threaded, no replication, and still the right tool when that is all you need.
- Facebook ran one of the largest memcached deployments ever built and published the lease and invalidation mechanics that most later designs borrow.
Inside one node
A cache node is a hash table with an eviction policy and a network loop. The details of those three decide its memory efficiency and its tail latency.
Memory is the resource being managed, so a cache spends real effort on encodings: integers are stored inline rather than as strings, short strings are embedded in the object header instead of being a separate allocation, and small collections are held as flat arrays until they outgrow a threshold. The consequence for an operator is that resident memory is not the sum of the values — it is that plus per-key overhead plus allocator fragmentation, and the ratio between allocated and resident memory is a metric worth alerting on.
Threading is the design decision people find most surprising: the command loop is single-threaded. Every command runs to completion before the next begins, which makes every operation atomic for free, removes all locking from the data structures, and gives predictable latency. The cost is that one slow command blocks everything — a KEYS * over a million keys, or deleting a collection with a million elements, is a multi-second stall for every client. Modern versions push socket reads and writes onto helper threads, which raises throughput without reintroducing locks, and newer engines such as Dragonfly and KeyDB shard the keyspace across cores inside one process instead.
Any command whose cost is proportional to the size of the data is a latency incident waiting to happen on a single-threaded server. Delete large collections asynchronously, iterate with a cursor rather than listing the keyspace, and treat an O(n) command in production as a bug rather than a slow query.
Eviction: LRU, LFU and what actually gets implemented
When memory fills, something must go. The policy decides which, and the choice between the two main families matters more than any tuning within them.
LRU evicts the least recently used key. It is the intuitive answer, it adapts instantly when the working set changes, and it has one well-known failure: a single large scan — a nightly job touching every key once — evicts the entire useful working set in favour of data nobody will read again. LFU evicts the least frequently used key, which survives that scan because one touch does not outrank a key read a thousand times an hour. Its own failure is the mirror image: without decay, a key that was hot last month keeps outranking one that is hot today.
| Policy | Evicts | Good at | Bad at |
|---|---|---|---|
| LRU | Oldest access | Working sets that shift over time | Scans, which flush everything useful |
| LFU | Lowest access count | Stable, skewed read traffic, the common case | Newly hot keys, unless counters decay |
| Random | Any key | Costing nothing and being surprisingly acceptable | Being defensible when the hit rate matters |
| Shortest TTL | The key expiring soonest | Data that genuinely ages out | Keys without a TTL, which it cannot touch |
Neither policy is implemented literally. A true LRU needs a doubly linked list threaded through every object, updated on every read — that is two pointer writes per GET and sixteen bytes per key, and it turns a read-only workload into a write-heavy one. Real caches approximate: they store a coarse timestamp in the object header and, when they need to evict, sample a handful of random keys and drop the oldest of the sample. Sampling five keys gets close to true LRU; sampling ten is closer still, and the remaining error costs less than the bookkeeping would.
LFU is approximated harder, because a true counter would saturate and would never forget. The standard trick is a logarithmic counter in a single byte, incremented probabilistically so that the higher the count the less likely another increment is, paired with a decay that halves counters over time. Eight bits then represent a range from one access to millions, and yesterday's hot key fades on its own.
The last decision is the scope: evict from all keys, or only from keys that carry a TTL. Restricting eviction to keys with a TTL lets an application keep some data pinned — a configuration blob, a feature flag set — while the rest competes for space. The trap is that if the pinned set grows past the memory limit, writes start failing rather than evicting.
- Redis exposes exactly this matrix as
maxmemory-policy, with sampled LRU since 3.0 and logarithmic LFU with decay since 4.0. - Memcached runs a segmented LRU with hot, warm and cold queues, which resists scan pollution without needing frequency counters.
- CDN caches face the same choice at a different scale, and most settle on a frequency-aware policy for the same reason: their traffic is heavily skewed.
- Scan pollution
- A one-off pass over many keys evicting the useful working set, because every key it touches looks recently used.
- Sampled eviction
- Choosing a victim from a small random sample instead of maintaining a globally ordered structure. Nearly as good, and far cheaper.
- Counter decay
- Periodically reducing frequency counters so that historic popularity fades and newly hot keys can compete.
Expiry: two mechanisms, neither exact
A TTL says when a key stops being valid, not when its memory is released. Those are different events, and the gap between them is a common source of confusion.
Expiry works two ways at once. Lazily: when a key is read, its expiry is checked, and if it has passed the key is deleted and a miss returned. That is exact from the client's point of view and costs nothing, but a key nobody reads is never noticed. Actively: a background cycle samples a batch of keys that carry TTLs, deletes the expired ones, and — if more than a quarter of the sample had expired — immediately repeats, on the theory that there is probably a lot to clean up. The loop is deliberately bounded so it cannot starve command processing.
Three consequences follow. Memory is held for some time after logical expiry, so used_memory lags reality. A replica does not expire keys on its own — it waits for the primary's delete, so that primary and replica cannot disagree — which means a read from a replica can briefly return a key the primary has already dropped. And if a large number of keys are given the same TTL, they all expire in the same second, and every one of them misses at once.
Add jitter to every TTL — a random ten to twenty percent — so that keys written together do not expire together. It is one line of code and it removes an entire class of synchronised-miss incident.
Spreading across nodes
One node holds fifty gigabytes and serves a hundred thousand operations a second. The dataset needs ten of those, so something must decide which node owns a key, and that decision must survive nodes being added and removed.
Plain modulo hashing is disqualified immediately: hash(key) mod N remaps almost every key when N changes, so adding one node to a nine-node cluster invalidates the whole cache at once. Consistent hashing fixes that by placing nodes and keys on a ring so that adding a node moves only the keys between it and its predecessor.
Production caches usually go one step further and insert a fixed layer of slots between keys and nodes: hash the key into one of a fixed number of slots — 16,384 is the usual choice — and assign ranges of slots to nodes. The slot count never changes, so rebalancing is a matter of moving slots between nodes, which is explicit, resumable and easy to reason about.
Routing then has two shapes. A smart client caches the slot-to-node map and connects directly to the owner, so every operation is one round trip; when the map is stale the node replies with a redirect naming the correct owner, and the client refreshes. A proxy hides all of this behind a single endpoint, which simplifies clients in many languages at the cost of an extra network hop on every request — meaningful when the budget is one millisecond.
Sharding breaks one thing: multi-key operations. A transaction or a multi-get touching keys on different nodes has no atomic implementation, so clusters simply refuse them. The workaround is a hash tag: put a substring of the key in braces and hash only that, so user:{42}:profile and user:{42}:sessions land on the same node by construction. It works, and it reintroduces the possibility of skew, since anything co-located is also concentrated.
Replication, failover and the writes you lose
Each shard has a primary and one or more replicas. Replication is asynchronous: the primary acknowledges a write to the client and streams it to replicas afterwards. That is the right default for a cache — synchronous replication would put a network round trip inside every SET — and it has a consequence that must be stated plainly rather than discovered.
sequenceDiagram autonumber participant C as Client participant P as Primary participant R as Replica participant S as Cluster peers C->>P: SET session:9f2 P-->>C: OK P--xR: replication stream lost, primary dies S->>S: primaries agree the node is unreachable S->>R: promote, take over the slots R-->>C: redirect, the replica now owns the slot Note over C,R: session:9f2 was acknowledged and is gone
Failover is four steps: peers notice the primary is unreachable for longer than a timeout, a majority agrees rather than trusting one observer, a replica is promoted, and clients are redirected. The middle step is what prevents split brain: without a majority requirement, a primary that is merely partitioned would keep accepting writes while its replacement also accepted them, and the two histories could never be reconciled. A partitioned primary must also stop serving on its own, after a timeout, since it cannot tell the difference between being isolated and everyone else being dead.
The whole process takes seconds, most of it the detection timeout. For a cache that is fine: clients see errors, fall back to the database, and recover. It is also the reason replicas are not primarily a read-scaling device here — reading from a replica gives you stale data and saves nothing, since the primary was not the bottleneck.
- Redis Sentinel handles this for non-clustered deployments and Redis Cluster does it internally, both with a majority requirement and a configurable detection timeout.
- MemoryDB changes the trade by writing to a durable transaction log before acknowledging, which removes the lost-write window and adds latency to every write.
- Memcached has no replication at all: a lost node is a lost shard, and the design simply accepts the miss storm that follows.
Using it correctly: cache-aside and its rivals
Most cache bugs are not in the cache. They are in the pattern the application uses around it, and there are only a few patterns.
sequenceDiagram autonumber participant A as Application participant C as Cache participant D as Database A->>C: GET user:42 C-->>A: miss A->>D: SELECT the row D-->>A: row A->>C: SET user:42 with a jittered TTL Note over A,D: on update: write the database, then DELETE the key A->>D: UPDATE the row A->>C: DEL user:42
Cache-aside is the default: read from the cache, and on a miss read the database and fill the cache. On a write, update the database and then delete the key. Deleting rather than updating matters more than it looks. Two concurrent writers that both update the cache can leave it holding the older value forever, because the ordering of their cache writes need not match the ordering of their database writes; two writers that both delete leave the cache empty, and the next reader repopulates it correctly.
There is still a race: a reader that misses, fetches an old row, and is then paused long enough for a writer to update the database and delete the key, will fill the cache with the stale value afterwards. It is narrow, and three things bound it — a short TTL, a versioned key so a new write is a new key, or a lease in which the cache hands one client the right to fill a key and makes others wait.
| Pattern | Who talks to the database | Use when |
|---|---|---|
| Cache-aside | The application, on a miss | Almost always: explicit, simple, survives a cache outage naturally |
| Read-through | The cache, through a loader | The loading logic should be shared and uniform across callers |
| Write-through | The cache, synchronously on write | The cache must never be stale and the write latency can afford it |
| Write-behind | The cache, asynchronously after the write | Write volume must be absorbed — and losing recent writes is acceptable |
| Refresh-ahead | A background job before expiry | A small set of predictable, expensive keys such as a homepage |
Whatever the pattern, every key gets a TTL. Not because the data expires, but because a TTL is the bound on how wrong the cache can be when an invalidation is missed — and invalidations are missed, whenever a deployment has a bug or a message is dropped. A cache with no TTLs has no upper limit on staleness.
Stampedes, hot keys and penetration
Three failure modes share a shape: the cache stops absorbing load and passes it straight through to whatever is behind it.
Stampede. A popular key expires, and every request that arrives in the next few milliseconds misses and goes to the database for the same row. At ten thousand requests a second for one key, that is ten thousand identical queries. The fix is to let one request through and make the rest wait for its result, either with a short-lived lock in the cache or with in-process coalescing so each server issues at most one load per key.
flowchart TD
R["request misses the key"] --> L{Lock acquired?}
L -- yes --> F["load from the database"]
F --> W["write the key, release the lock"]
L -- no --> S["wait briefly, then read the key"]
S --> H{Present now?}
H -- yes --> D["serve it"]
H -- no --> O["serve stale or degrade"]
W --> D
A gentler variant avoids the expiry cliff entirely: store the logical expiry inside the value and have readers probabilistically refresh it shortly before that time, so the key is replaced while it is still valid and never has a moment of absence.
Hot key. One key — a celebrity profile, a viral post, a global configuration blob — receives more traffic than one node can serve, and sharding does not help because all its requests hash to the same slot. Three remedies, usually combined: replicate the key under several suffixed names and have clients pick one at random; put a small local cache in front of the remote one so that a second of staleness eliminates almost all remote reads; or, for genuinely global data, accept that it belongs in the application's own memory and not in a shared cache at all.
Penetration. Requests for keys that do not exist anywhere miss the cache by definition and reach the database every time — the cheapest denial of service there is. Cache the negative answer with a short TTL, and, when the keyspace is huge and the miss traffic malicious, put a Bloom filter in front so that a definitive "no such key" can be given without touching either store.
Persistence and the cold restart
A cache is allowed to lose data, but losing all of it at once is a different event: an empty cache sends one hundred percent of traffic to a database provisioned for two percent of it, and that is how a cache outage becomes a full outage.
Two mechanisms mitigate it. A snapshot writes the whole dataset to disk periodically, cheaply, by forking and letting the copy-on-write page table do the work; it restores fast and loses everything since the last snapshot. An append-only log records every write, is replayed on start, and is compacted in the background; it loses at most a second with a per-second flush, at the cost of writing every mutation twice.
Neither turns a cache into a database, and it is worth being explicit about why: both are asynchronous, so an acknowledged write can still be lost, and a cache's value is bounded by the store behind it anyway. What they genuinely buy is a warm restart.
Plan the cold-start path before you need it. Restore from a snapshot if there is one, otherwise warm the node from a peer or from a list of the hottest keys, and admit traffic gradually rather than putting an empty node straight into rotation. A cache that cannot be restarted safely is a cache that can never be upgraded.
What breaks first
- Memory. The first limit, and it arrives as a rising eviction rate and a falling hit rate rather than as an error. Watch the hit rate and the eviction counter together: evictions with a high hit rate are healthy, evictions with a falling hit rate mean the working set no longer fits.
- One node's CPU. With a single-threaded command loop, a shard saturates at its thread's throughput. The symptom is latency, not errors, and the fix is more shards — or fewer, larger operations through pipelining and multi-key commands.
- The network card. Easy to forget: a million operations a second at 2 KB per value is four gigabytes a second, which is real hardware. Large values hit this before they hit CPU.
- Failover time. The availability floor. Detection timeout plus election plus client redirect is seconds, during which that shard is down. Shortening the timeout trades false failovers against faster recovery.
At ten times the traffic, the shape is unchanged: more shards each holding a smaller share, client-side caching for the hottest keys, and batching to amortise the per-operation network cost. The one thing that does not scale by adding nodes is a single hot key, which is why it gets its own section above.
Recap
- A cache is defined by what it is allowed to lose. That permission buys memory residency, asynchronous replication and fast failover, and obliges every caller to be correct without a hit.
- Per-key overhead is around 90 bytes, so a cache of tiny values is mostly bookkeeping; per-node throughput is one thread's worth of commands, so growth means more shards, not bigger machines.
- The node is two hash tables — keys to objects, and keys to expiry times — plus an object header carrying the eviction metadata.
- A single-threaded command loop makes every operation atomic and latency predictable, and makes any O(n) command a stall for every client.
- LRU adapts to shifting working sets but is destroyed by a scan; LFU survives scans but needs decay so old heat fades. LFU is the better default for skewed read traffic.
- Neither policy is implemented literally: caches sample a few random keys and evict the worst of the sample, and store frequency in a logarithmic byte-sized counter.
- Expiry is lazy on read plus active sampling in the background, so memory is released after logical expiry and replicas wait for the primary's delete.
- Jitter every TTL, or keys written together expire together and miss together.
- Keys map to a fixed number of slots and slots map to nodes, so adding a node moves slots rather than remapping the keyspace. Multi-key operations need a hash tag to force co-location.
- Replication is asynchronous, so failover loses acknowledged writes; a majority must agree before promoting, or a partitioned primary produces split brain.
- Cache-aside is the default pattern, and updates delete the key rather than rewriting it, because deletes are idempotent and concurrent rewrites can invert.
- Every key gets a TTL — not because the data expires, but because it bounds how wrong the cache can be when an invalidation is lost.
- Stampedes are solved by coalescing or early recomputation, hot keys by replication and a local cache, penetration by negative caching and a Bloom filter.
- Snapshots and append-only logs exist for warm restarts, not durability. Plan the cold-start path or a restart becomes an outage.
- The bottleneck ladder is memory, then one node's CPU, then the network card, then failover time. A single hot key is the one problem more nodes do not fix.
Questions
Try answering each one out loud before opening it. Lead with the one-line answer, then the reasoning, then one detail from a real system.
What does a cache promise, and what does it deliberately not promise?
It promises fast access to a recent copy of data; it explicitly does not promise durability, and that permission is what allows in-memory storage, asynchronous replication and failover in seconds.
- The obligation it places on callers is that correctness must never depend on a hit.
- Losing the whole cache at once is a different event from losing a key, because the database behind it is not sized for full traffic.
- Systems that need durability with cache latency — such as MemoryDB — pay for it with a log write on every operation.
Most cache-related incidents trace back to code that treated a hit as guaranteed rather than to the cache failing.
Why is per-key overhead worth calculating before choosing a cache size?
Because around 90 bytes per key of object header, table entry and expiry record is charged before any value, so a cache of 250 million small keys spends roughly 22 GB on bookkeeping alone.
- Caching many tiny values is disproportionately expensive, which sometimes argues for packing them into one larger value.
- Resident memory is allocated memory plus fragmentation, so the ratio between the two needs monitoring.
- Compact encodings for small integers, short strings and small collections exist precisely to fight this.
It is the number that decides whether a workload needs eight nodes or eighteen, long before throughput does.
Why is a single-threaded command loop a reasonable design in 2026?
Because it makes every command atomic with no locking, gives predictable tail latency, and a single thread already handles on the order of a hundred thousand simple operations a second — past which the answer is another shard, which you need anyway for memory.
- Lock-free data structures and free atomicity are worth a great deal in a system whose commands are microseconds long.
- Socket reads and writes can be moved to helper threads without reintroducing locks on the data.
- The cost is that one O(n) command blocks every client, which makes large deletes and keyspace scans production hazards.
Newer engines such as Dragonfly shard the keyspace across cores inside one process to keep the property while using the whole machine.
LRU or LFU?
LFU for most caches, because real traffic is heavily skewed and LFU keeps the genuinely popular keys; LRU when the working set shifts constantly, since LFU needs decay to let newly hot keys displace historic ones.
- LRU's fatal case is a scan: one pass over every key evicts the entire useful working set.
- LFU's fatal case is no decay: last month's hot key outranks today's forever.
- Random eviction is cheaper than either and performs less badly than people expect.
Memcached's segmented LRU is a third answer that resists scan pollution without maintaining frequency counters at all.
Why do real caches only approximate LRU?
Because true LRU needs a doubly linked list threaded through every object and updated on every read, which costs about sixteen bytes per key and turns a read-only workload into a write-heavy one.
- The approximation stores a coarse timestamp in the object header and samples a handful of random keys at eviction time, evicting the oldest of the sample.
- Sampling five keys is close to true LRU and sampling ten is closer, with diminishing returns.
- LFU is approximated even harder, with a logarithmic byte-sized counter and periodic halving.
The general lesson is that eviction quality is worth less than the memory and CPU an exact implementation would consume.
How does expiry actually work, and why does memory not drop at the TTL?
Keys expire lazily when read and actively by a background sampler, so a key nobody reads keeps its memory until the sampler happens to pick it — logical expiry and memory release are separate events.
- The active cycle samples a batch of keys with TTLs and repeats immediately if a large fraction had expired, but stays bounded so it cannot starve commands.
- Replicas do not expire keys independently: they wait for the primary's delete, so a replica read can briefly return an already-expired key.
- Reported memory therefore lags reality, which matters when sizing from metrics.
The same pattern appears in managed stores, where a documented TTL can take many hours to reclaim space.
Why add jitter to TTLs?
Because keys written at the same moment with the same TTL expire at the same moment, so every one of them misses simultaneously and the database takes the whole burst.
- A random ten to twenty percent variation spreads the expiries across a window.
- The same reasoning applies to retry backoff, where jitter prevents synchronised retry waves.
- It matters most after a bulk load, which naturally writes millions of keys within seconds.
It is a one-line change that removes an entire class of periodic latency spike.
Why hash keys into a fixed number of slots rather than directly onto nodes?
Because the slot count never changes, so rebalancing means moving slot ranges between nodes — an explicit, resumable operation — while the key-to-slot function stays constant forever.
- Plain modulo hashing remaps nearly every key when the node count changes, invalidating the whole cache.
- Consistent hashing solves that too, but slots make ownership easier to inspect, migrate and reason about.
- Sixteen thousand-odd slots is fine granularity for any realistic cluster size.
The client caches the slot map and refreshes it when a node replies with a redirect, so routing costs one round trip in the normal case.
Why can a clustered cache not run a transaction across two arbitrary keys?
Because the keys may live on different nodes and there is no atomic cross-node operation, so the cluster refuses rather than pretending; the workaround is a hash tag that forces related keys into the same slot.
- Putting a substring in braces makes only that part determine the slot, so related keys co-locate by construction.
- Co-location reintroduces skew, since everything grouped together is also concentrated on one node.
- A proxy can hide routing but cannot make a cross-node multi-key operation atomic.
This is the same constraint any sharded store has, stated honestly at the API rather than hidden behind a distributed transaction.
What happens to a write that was acknowledged just before a failover?
It is lost, because replication is asynchronous: the primary acknowledged the client before the replica had the data, and the promoted replica never saw it.
- This is the correct trade for a cache, since synchronous replication would put a round trip inside every write.
- A majority of peers must agree before promoting, or a merely partitioned primary keeps accepting writes and produces split brain.
- The old primary must also stop serving on its own after a timeout, since it cannot distinguish isolation from everyone else being dead.
Any system that cannot tolerate this needs a durable write path, which is a different product with a different latency profile.
On a write, why delete the cached key rather than update it?
Because two concurrent writers can apply their cache updates in a different order from their database updates, leaving the cache holding the older value indefinitely; two deletes just leave it empty, and the next reader repopulates correctly.
- Deletion is idempotent, so a retried or duplicated invalidation is harmless.
- It also avoids caching values that nobody subsequently reads.
- The remaining race is a reader that fetched an old row and fills the cache after the delete, which a short TTL, a versioned key or a lease bounds.
Facebook's memcache leases exist precisely to close that last race at very large scale.
Should every key have a TTL even when the data never becomes invalid?
Yes, because the TTL is not about the data ageing — it is the upper bound on how wrong the cache can be when an invalidation is lost, and invalidations are lost during deployments, network failures and bugs.
- A cache with no TTLs has no limit on staleness and no self-healing path.
- The TTL also caps the blast radius of a bad value written by a broken code path.
- It costs a slightly lower hit rate, which is almost always the right price.
Pinning a small set of keys without TTLs is reasonable, provided the pinned set can never grow past the memory limit.
A popular key expires and ten thousand requests a second miss it. What do you do?
Coalesce: let one request load from the database while the others wait for its result, using a short-lived lock in the cache or in-process deduplication per server.
- Without it, one expiry becomes ten thousand identical database queries in a second.
- A better variant avoids the cliff entirely by storing the logical expiry inside the value and probabilistically refreshing shortly before it, so the key is never absent.
- The waiting path needs a fallback, because the one loader can always fail.
Combine it with TTL jitter so that many keys do not reach this moment simultaneously.
One key gets more traffic than a single node can serve. How do you fix it?
Sharding cannot help, because every request for that key hashes to the same slot, so you either replicate the key under several suffixed names and have clients pick one at random, or put a small in-process cache in front of the remote one.
- A one-second local cache removes almost all remote reads for a key that is being read constantly.
- Replicated copies need the same invalidation applied to every copy, which is the cost of the approach.
- Genuinely global data — a feature flag set, a configuration blob — belongs in application memory rather than in a shared cache.
This is the one scaling problem in the article that adding nodes does not solve, which is why it is worth naming explicitly.
What is cache penetration and how is it stopped?
Requests for keys that exist nowhere, which miss by definition and reach the database every time; the defence is caching the negative answer briefly, and a Bloom filter in front when the miss traffic is adversarial.
- A negative entry needs a short TTL, since the key may legitimately be created shortly after.
- A Bloom filter can say "definitely absent" with a small memory footprint and no false negatives.
- Miss rate is a better throttling signal than request rate, since a scanner's miss rate approaches 100 percent.
It is the cheapest denial of service available against any read-through cache, which is why it is worth designing for rather than discovering.
Does a cache need persistence?
Not for durability — it exists to make restarts warm, because an empty cache sends one hundred percent of traffic to a database provisioned for a small fraction of it.
- A snapshot forks and writes the dataset periodically, restores quickly, and loses everything since the last one.
- An append-only log replays every write and loses at most a second, at the cost of writing each mutation twice.
- Both are asynchronous, so neither makes an acknowledged write safe.
The operational rule that matters more than either is to admit traffic gradually to a cold node rather than putting it straight into rotation.