Design a Distributed Cache
Shard keys over many memory nodes, evict and expire sensibly, and survive the three classic failures: stampedes, hot keys and stale fills.
- 98%required hit ratio: 1M reads, 20K database
- 12 nodesthroughput-bound at about 83K ops/s each
- ~8%of keys miss when one node dies
Show what to say
“Traffic is 1M reads a second against a database that does 20K, so the cache must hit 98%. I'd shard about 12 in-memory nodes with rendezvous hashing in the client library, so a lost node moves only its 8% of keys. Each node is an LRU with jittered TTLs. I'd guard the database with single flight, serve hot keys from a local copy, and delete on write.”
Trade-offs
- Client-side vs proxy routing: one hop and nothing central, but every client needs the node list; a proxy keeps clients thin but adds a hop.
- Cache-aside vs write-through: aside is simple and losing the cache is harmless; write-through keeps data fresh but slows writes.
- Replicating cache nodes vs not: replicas survive a loss, but double memory; usually protect the database instead.
Bring it up before they ask
A node loss sends about 80K extra reads a second to a database that handles 20K, so protect the load path with single flight and a rate limit.
over many in-memory nodes with rendezvous or , with and expire with a jittered . The design is won or lost on three failure cases: a on one expired key, a on one node, and a stale fill after a write.
Functional
- get, set and delete by key, with an optional TTL
- Cache-aside use from application code
- Spread data over many nodes and add nodes without downtime
- Evict old data when memory is full
- Stats: hit ratio, evictions, memory
Non-functional
- 1M reads a second at peak, under 1 ms per read
- Hit ratio of at least 98% to protect the database
- Losing a node must not take the database down
- Values are small (about 1 KB)
- Eventually consistent with the database
Out of scope
- Being a durable database
- Transactions and secondary indexes
- Cross-region replication
Clarify and estimate
Ask first:
Read more · 210 words
- Why ? Which store is limited, and what can it take (20K reads a second here)?
- Staleness? Can a value be a few seconds old?
- or big values? Both change the design.
Capacity estimate
| Quantity | Calculation | Result |
|---|---|---|
| Required hit ratio | 1 − 20K database reads ÷ 1M reads | 98% |
| Working set | 100M objects × 1 KB × 1.3 overhead | 130 GB |
| Nodes by memory | 130 GB ÷ 24 GB usable (75% of 32 GB) | 6 |
| Nodes by throughput | 1M ops/s ÷ 100K per node | 10, so use 12 |
| Load per node | 1M ÷ 12 | ≈ 83K ops/s |
| Bandwidth | 1M × 1 KB = 1 GB/s ÷ 12 | ≈ 0.7 Gbps per node |
| One node lost | 1 ÷ 12 | 8.3% of keys miss |
, not memory, sets the node count. The scary number is the last one: a dead node sends about 80K extra reads a second to a database that handles 20K.
API and data model
API. A client library over a plain wire protocol (Memcached or style).
Read more · 141 words
| Call | Behaviour |
|---|---|
get(key) | Value or a miss |
set(key, value, ttl) | Stores with expiry; evicts if full |
delete(key) | Removes; used after writes |
getOrLoad(key, loader) | Read-through: cache, then one guarded load, then fill |
mget(keys) | One round trip per node for a batch |
Data model. A entry is a key, a value, an expiry and a recency stamp.
| Field | Notes |
|---|---|
| Key | type:id, such as user:42; add a version to the name when the format changes |
| Value | Serialized bytes, about 1 KB; refuse values over 1 MB |
| Expiry | TTL with up to 10% jitter |
| Recency | Position in the LRU list, updated on every hit |
Nothing is persisted. A restarted node starts empty and refills.
Architecture
The client does the routing: no proxy hop, so a hit is one network round trip.
Read more · 82 words
Play , Miss and fill, and A node dies.
Tap any box to see what it is and what it talks to.
- Service
- Cache
- Database
Text description
- A request for user:42. The client first checks its local cache: not a hot key, nothing there.
- Rendezvous hashing scores each node for 'user:42'. Node B scores highest, so the client asks B directly. There is no proxy hop.
- Hit. The value comes back in under a millisecond; the database never hears about it.
Sharding, eviction and expiry
. The client hashes the key to pick a node.
Read more · 245 words
Hash mod N moves almost every key when N changes, emptying the . Use (or a ring): score each node for the key and take the best.
Lose a node and only its 1/12 of keys move, spread over all survivors. A node-list service marks dead nodes so clients skip them within seconds.
and expiry. evicts the entry unused for longest and suits most workloads. A bounds staleness. Add up to 10% so keys written together do not expire together. Count memory in bytes, not entries: one big value can push out many small ones.
Stampedes, hot keys and stale data
A popular key expires and 1,000 requests miss at once.
Read more · 143 words
lets the first load and makes the rest wait for it: one database read, not 1,000. "not found" briefly too.
A lives on one node, so more nodes cannot help. Detect it and keep a copy in each app server for about a second.
A write can race a slow reader and leave a stale fill. Pick a branch to see the race and two fixes.
- Service
- Cache
- Database
Text description
- Reader → Cache: get price:7
- Cache replies to Reader: miss
- Reader → Database: SELECT price
- Database replies to Reader: price = 10 (old)
- Writer → Database: UPDATE price = 12
- Writer → Cache: DELETE price:7
- Reader → Cache: SET price:7 = 10 (old)
Step-by-step answer
Order: clarify, estimate, , and , then the failure modes (, , stale fill, node loss).
Read more · 500 words
Show why a cache is needed
"The database does 20K reads a second; traffic is 1M. The must absorb 98%." The hit-ratio target frames everything else.
Choose the pattern
by default: the app reads the cache, loads on a miss, and fills. Losing the cache only costs . See caching techniques for and .
Route in the client
Client library with and a refreshed node list. No proxy hop, no central router to scale.
Size the fleet
Nodes by and by memory, take the larger, add headroom. Say what one node's loss does to the database.
Evict and expire
by bytes, TTL with . Negative caching for missing keys.
Protect the database
on misses, a or on the load path, and gradual warm-up after a restart.
Handle hot keys
Detect by counting per key in the client. Serve hot keys from a one second local cache or replicate them across nodes.
Keep it correct enough
Delete on write, short TTL as a backstop. For strictness, delay-delete or version the values.
Implementation
Tabs: a byte-limited with , with and negative , , and a hot-key guard.
Read more · 48 words
- The single-flight test proves 1,000 simultaneous misses make exactly one database read.
- The rendezvous test shows a lost node moves about 8% of keys onto all 11 survivors, and hash mod N moves over 85%.
- The hot-key test cuts 5,000 reads to under 150 node reads.
// One cache node's memory: a byte-limited LRU with per-key expiry.
// - A Map remembers insertion order, so "least recently used" is its first key, and "touch" is delete + set.
// - Entries expire lazily (checked on read) and the oldest are evicted when the memory budget is exceeded.
// The clock is injected so tests need no sleeping.
export class LruCache {
#map = new Map(); // key -> { value, size, expiresAt }
#bytes = 0;
constructor(maxBytes, { now = () => Date.now() } = {}) {
this.maxBytes = maxBytes;
this.now = now;
this.stats = { hits: 0, misses: 0, evictions: 0, expired: 0 };
}
get bytes() { return this.#bytes; }
get size() { return this.#map.size; }
get(key) {
const e = this.#map.get(key);
if (!e) { this.stats.misses++; return undefined; }
if (e.expiresAt <= this.now()) { // expired: remove it and report a miss
this.#drop(key, e);
this.stats.expired++;
this.stats.misses++;
return undefined;
}
this.#map.delete(key); // move to the "most recent" end
this.#map.set(key, e);
this.stats.hits++;
return e.value;
}
set(key, value, { ttlMs = 60_000, size = 1 } = {}) {
if (size > this.maxBytes) return false; // never cache something bigger than the whole cache
const old = this.#map.get(key);
if (old) this.#drop(key, old);
this.#map.set(key, { value, size, expiresAt: this.now() + ttlMs });
this.#bytes += size;
while (this.#bytes > this.maxBytes) { // evict from the least recently used end
const [oldestKey, oldest] = this.#map.entries().next().value;
this.#drop(oldestKey, oldest);
this.stats.evictions++;
}
return true;
}
delete(key) {
const e = this.#map.get(key);
if (e) this.#drop(key, e);
return !!e;
}
#drop(key, e) {
this.#map.delete(key);
this.#bytes -= e.size;
}
hitRatio() {
const total = this.stats.hits + this.stats.misses;
return total ? this.stats.hits / total : 0;
}
}Failure modes and trade-offs
Read more · 231 words
| What breaks | Effect | Mitigation |
|---|---|---|
| A cache node dies | 8% of keys miss; database burst | Single flight; rate limit; spread keys over survivors |
| Whole cache restarts | Cold start; database overload | Warm up gradually; limit load; replicas for critical keys |
| Stampede on one key | Many identical queries | Single flight; jittered TTL |
| Hot key | One node saturated | Local cache; replicate the key |
| Stale fill after a write | Old data until TTL | Short TTL; delayed second delete; versions |
| Cache poisoning with a bad value | Wrong data served | Validate on fill; version the key name; flush by prefix |
| Memory pressure | Evicting useful data | Alert on hit ratio and evictions; resize |
Trade-offs
| Choice | Gain | Cost |
|---|---|---|
| Client-side routing | One hop, nothing central | Every client needs the node list |
| Proxy routing | Thin clients; central policy | Extra hop; proxy must scale |
| Cache-aside | Simple; cache loss is harmless | App code on every read path |
| Write-through | Always fresh | Slower writes; caches unread data |
| Replicas per node | Survives a node loss | Double the memory |
Follow-up questions
LRU or LFU?
suits recency-driven traffic. LFU keeps frequent items through scans but adapts slowly.
Should the cache be replicated?
Only for keys whose loss would overload the database. Otherwise accept 8% misses and protect the database.
How do you warm a new cache?
Load the hottest keys first, throttled so the database is not overwhelmed.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.
Patterns behind this
The coding patterns this design is built on. Practise them and the design questions get easier.