BackendHard45 min

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.

Say it in 30 seconds
  • 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.

0 of 0 sections ticked
30-second answer

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

QuantityCalculationResult
Required hit ratio1 − 20K database reads ÷ 1M reads98%
Working set100M objects × 1 KB × 1.3 overhead130 GB
Nodes by memory130 GB ÷ 24 GB usable (75% of 32 GB)6
Nodes by throughput1M ops/s ÷ 100K per node10, so use 12
Load per node1M ÷ 12≈ 83K ops/s
Bandwidth1M × 1 KB = 1 GB/s ÷ 12≈ 0.7 Gbps per node
One node lost1 ÷ 128.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
CallBehaviour
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.

FieldNotes
Keytype:id, such as user:42; add a version to the name when the format changes
ValueSerialized bytes, about 1 KB; refuse values over 1 MB
ExpiryTTL with up to 10% jitter
RecencyPosition 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.

Press Play to watch it run · 3 steps

App servers use a cache client library. It first checks a tiny local cache for very hot keys, then picks the cache node for the key by rendezvous hashing and talks to it directly. On a miss the app loads from the database, with a single-flight guard so many identical misses become one read, then fills the cache with a jittered TTL.

Distributed cache architectureApp servers use a cache client library. It first checks a tiny local cache for very hot keys, then picks the cache node for the key by rendezvous hashing and talks to it directly. On a miss the app loads from the database, with a single-flight guard so many identical misses become one read, then fills the cache with a jittered TTL.hot key?get / setget / setget / seton missnode listApp serverscache client libA small in-process cache for keys that are being requested more than about 1,000 times a second. It keeps one hot key from overloading a single cache node.Local L1hot keys, 1 sA small service (or etcd) that tells clients which cache nodes exist and which are healthy. Clients refresh it every few seconds.Node listhealth + membershipCache node A32 GB, LRUCache node B32 GB, LRUCache node C32 GB, LRUThe source of truth. It can only handle about 20K reads a second, so the cache must absorb 98% of 1M reads a second.Database~20K reads/s limit

Tap any box to see what it is and what it talks to.

  • Service
  • Cache
  • Database
Open in playground
Text description
  1. A request for user:42. The client first checks its local cache: not a hot key, nothing there.
  2. Rendezvous hashing scores each node for 'user:42'. Node B scores highest, so the client asks B directly. There is no proxy hop.
  3. 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.

Press Play to watch it run · 7 steps

A reader misses and loads an old row; a writer updates the database and deletes the cache key; then the reader fills the cache with the old value. The key now holds stale data until its TTL ends. A short TTL bounds the damage; a delayed second delete or versioned values removes it.

The stale-fill raceA reader misses and loads an old row; a writer updates the database and deletes the cache key; then the reader fills the cache with the old value. The key now holds stale data until its TTL ends. A short TTL bounds the damage; a delayed second delete or versioned values removes it.get price:7missSELECT priceprice = 10 (old)UPDATE price = 12DELETE price:7alt [race]SET price:7 = 10 (old)Rare but real: the cache now holds the old price until the TTL ends
  • Service
  • Cache
  • Database
Text description
  1. Reader → Cache: get price:7
  2. Cache replies to Reader: miss
  3. Reader → Database: SELECT price
  4. Database replies to Reader: price = 10 (old)
  5. Writer → Database: UPDATE price = 12
  6. Writer → Cache: DELETE price:7
  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
  1. 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.

  2. 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 .

  3. Route in the client

    Client library with and a refreshed node list. No proxy hop, no central router to scale.

  4. Size the fleet

    Nodes by and by memory, take the larger, add headroom. Say what one node's loss does to the database.

  5. Evict and expire

    by bytes, TTL with . Negative caching for missing keys.

  6. Protect the database

    on misses, a or on the load path, and gradual warm-up after a restart.

  7. 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.

  8. 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 breaksEffectMitigation
A cache node dies8% of keys miss; database burstSingle flight; rate limit; spread keys over survivors
Whole cache restartsCold start; database overloadWarm up gradually; limit load; replicas for critical keys
Stampede on one keyMany identical queriesSingle flight; jittered TTL
Hot keyOne node saturatedLocal cache; replicate the key
Stale fill after a writeOld data until TTLShort TTL; delayed second delete; versions
Cache poisoning with a bad valueWrong data servedValidate on fill; version the key name; flush by prefix
Memory pressureEvicting useful dataAlert on hit ratio and evictions; resize

Trade-offs

ChoiceGainCost
Client-side routingOne hop, nothing centralEvery client needs the node list
Proxy routingThin clients; central policyExtra hop; proxy must scale
Cache-asideSimple; cache loss is harmlessApp code on every read path
Write-throughAlways freshSlower writes; caches unread data
Replicas per nodeSurvives a node lossDouble 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 3

Score 2 out of 3 or better to mark this page complete.

1. The database can serve 20K reads a second. Traffic is 1M reads a second. What hit ratio must the cache reach?

Patterns behind this

The coding patterns this design is built on. Practise them and the design questions get easier.