Design a Distributed Cache

Build the cache cluster itself: hash table and LRU in memory, expiry, slots and routing, replication and what failover loses, hot keys and leases.

Case study32 minLesson 4 of 7
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

Every team in your company puts a cache in front of its database. Now you are asked to build that cache: a cluster of machines that holds two billion small values in memory, answers in well under a millisecond, and keeps working while nodes die. It is the kind of product people mean when they say Redis or Memcached.

Interviewers like it because it spans data structures and distributed systems: the hash table inside one node, how keys spread over many nodes, and an honest account of what a failover throws away. How to use a cache is in Caching. Unlike Design a Key-Value Store, losing data is allowed here, so the design trades durability for speed. The steps follow A Framework for Any Design.

Clarify the problem

The first five minutes
Interviewer

Design a distributed cache.

You

First, is it a cache or a database? If every value can be rebuilt from a database behind it, I can lose some writes on a failure and keep everything in memory.

Interviewer

It's a cache. The source of truth is elsewhere.

You

Which operations? I'm assuming get, set with an optional time to live, and delete, on string values. How big are values, how many keys, and what traffic?

Interviewer

Those three operations. Values average 500 bytes, at most 1 MB. About 2 billion items. 400,000 operations a second on average, a million at peak, and 90% are reads.

You

Then latency and availability come first. I'll aim for gets under a millisecond inside one data center, keep serving when a node dies, and accept losing the last few milliseconds of writes on a failover. I'll leave out rich data types, transactions and cross-region replication.

  • Functional: get(key); set(key, value, ttl); delete(key); expired keys are never returned; when memory is full, some keys are evicted.
  • Non-functional: p99 get under 1 ms within a data center; keeps serving when any one node dies; grows by adding nodes without downtime; a failover may lose the last few milliseconds of acknowledged writes, and that window is bounded.
  • Out of scope: lists, sets and counters; multi-key transactions; durable storage; replication across regions.

Estimate the scale

  • Bytes per item: a 50-byte key, a 500-byte value, and about 50 bytes of bookkeeping (hash chain pointer, eviction pointers, expiry time, sizes). Call it 600 bytes.
  • Usable memory per node: machines with 64 GB, of which about 50 GB holds items. The rest is headroom.
  • Throughput budget: assume one node handles about 100,000 simple operations a second. Leave room below it.
Estimate: How many primaries

How many primary nodes do you need to hold every item in memory?

You know
  • 2 billion items
  • About 600 bytes per item, bookkeeping included
  • About 50 GB usable per node

Show the working
  1. 2 × 109 × 600 B = 1.2 × 1012 B = 1.2 TB
  2. 1.2 TB ÷ 50 GB = 24 primaries
  3. With one replica each: 48 nodes

Estimate 24 (anything from 10 to 60 is a good answer)

Memory sets the node count here, not traffic. Check traffic next.

Estimate: Load on one primary at peak

Keys spread evenly over 24 primaries. How many operations per second does one primary serve at peak?

You know
  • 1 million operations a second at peak
  • 24 primaries
  • All reads and writes go to primaries

Show the working
  1. 1,000,000 ÷ 24 ≈ 41,700, call it 42,000 a second
  2. That is under half the 100,000 budget, so traffic fits

Estimate 42 thousand/s (anything from 15 thousand/s to 120 thousand/s is a good answer)

The average is 400,000 ÷ 24 ≈ 17,000. The headroom matters later, for hot keys and failover.

Bandwidth is 1,000,000 × 600 B = 600 MB/s, or 25 MB/s per node: easy. Writes are 10% of peak, about 4,200 a second per primary; that number returns when we count what a failover loses.

Your turn: Define the API

Write the three operations with their arguments and replies. What does the client get back when it asked the wrong node, or when the value is too large?

Compare with a model answer

Get, set with TTL and conditions, and delete, over long-lived TCP connections rather than HTTP. Replies include a miss, a "not stored" for failed conditions, a size error, and a redirect naming the node that owns the key.

Define the API

Cache protocol
GET key
  VALUE bytes            hit
  MISS lease=501         miss; you may fill it with lease 501
  WAIT                   someone else holds the lease; retry in about 20 ms
 
SET key value [TTL seconds] [NX | XX] [LEASE token]
  OK
  NOT_STORED             NX and the key exists, XX and it does not,
                         or the lease was cancelled by a delete
  TOO_LARGE              value over 1 MB
  OUT_OF_MEMORY          full, and the policy is not to evict
 
DEL key
  DELETED | NOT_FOUND    also cancels any open lease on the key
 
any command
  MOVED 9301 10.0.4.17:7000   slot 9301 lives on another node

The decision worth arguing is the transport. A get takes microseconds on the server and about 0.5 ms on the network, so HTTP headers and parsing would cost more than the work. Use persistent TCP connections and a compact protocol, and let clients pipeline: send 10 gets without waiting, and pay one round trip instead of ten.

Your turn: Model the data

What does one entry hold in memory, and what structures give O(1) lookup, O(1) eviction and cheap expiry? What does the cluster as a whole need to store?

Compare with a model answer

Each node holds a hash table from key to entry, plus an ordering for eviction. Each entry carries its expiry time. The cluster needs one small map: which slot of the key space lives on which node.

Model the data

Inside one node, and the cluster map
entry                                        about 50 B of bookkeeping
  key          bytes   (up to 250 B)
  value        bytes   (up to 1 MB)
  expires_at   8 B     ms since epoch, 0 = never
  hash_next    8 B     next entry in the same bucket
  lru_prev     8 B     neighbours in the recency list
  lru_next     8 B
  sizes, flags ~10 B
 
cluster map                                  a few hundred KB
  slot 0..16383   -> shard id
  shard id        -> primary address, replica addresses
  epoch           -> bumped on every change

Every access is by exact key: no range scans, no indexes, no joins. So there is no database to choose: the store is a hash table in RAM, with a doubly linked list threaded through the same entries to track recency. That pair is the LRU cache from Designing Data Structures, O(1) for every operation. The cluster map is tiny but everyone must agree on it, so it lives in a small consensus-backed config service.

Your turn: High-level design

Sketch the cluster: how does an app server know which node to ask, where do copies live, and who decides that a node is dead?

Compare with a model answer

A client library in each app server hashes the key to a slot and looks it up in a cached copy of the cluster map. Each shard is a primary with one replica. A config service watches health, promotes replicas and publishes new maps.

High-level design

There is no load balancer. The client library is the router: it keeps the cluster map in memory and talks straight to the owning primary.

The client library routes each key to its shard's primary. Primaries copy writes to their replicas asynchronously; the config service tracks health and publishes the map.App servers to Cluster config (cluster map); App servers to Source database (read on miss); Cluster config to Shard A primary (health checks); App servers to Shard A primary; App servers to Shard B primary (get / set / del); App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica (replicate), asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource databaseShard A primaryShard B primaryShard C primaryShard A replicaShard B replicaShard C replicacluster mapread on misshealth checksget / set / delreplicate

request and replyasynchronous message

The client library routes each key to its shard's primary. Primaries copy writes to their replicas asynchronously; the config service tracks health and publishes the map.

The real cluster has 24 shards; three show the shape.

A hit, a miss under a lease, then a primary diesUse the arrows or ← → keys
  1. A hit, a miss under a lease, then a primary dies, step 1App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource databaseShard A primaryShard B primaryslot 9301Shard C primaryShard A replicaShard B replicaShard C replica
    map epoch
    7

    1An app server wants user:42. The client hashes it to slot 9,301; its cached map says shard B. No extra hop.

  2. A hit, a miss under a lease, then a primary dies, step 2App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource databaseShard A primaryShard B primaryhitShard C primaryShard A replicaShard B replicaShard C replica
    map epoch
    7
    db reads
    0

    2Shard B finds the entry, checks expires_at, marks it recently used and replies. About 0.5 ms, nearly all network.

  3. A hit, a miss under a lease, then a primary dies, step 3App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource databaseShard A primarymiss, lease 501Shard B primaryShard C primaryShard A replicaShard B replicaShard C replica
    map epoch
    7
    db reads
    0

    3Next, product:7 on shard A is a miss. Shard A returns lease 501 with the miss: only this caller may fill the key.

  4. A hit, a miss under a lease, then a primary dies, step 4App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource databaseShard A primaryWAITShard B primaryShard C primaryShard A replicaShard B replicaShard C replica
    map epoch
    7
    db reads
    0

    4A second app server asks for product:7 a millisecond later. The lease is still open, so shard A answers WAIT instead of a miss. No second database query.

  5. A hit, a miss under a lease, then a primary dies, step 5App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource databaseShard A primarystoredShard B primaryShard C primaryShard A replicaShard B replicaShard C replica
    map epoch
    7
    db reads
    1

    5The lease holder reads the database (about 5 ms) and sends SET … TTL 300 LEASE 501. Shard A replies OK before the replica has it.

  6. A hit, a miss under a lease, then a primary dies, step 6App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource databaseShard A primaryShard B primaryShard C primaryShard A replicaShard B replicaShard C replica
    map epoch
    7
    db reads
    1

    6Shard A streams the write to its replica a few milliseconds later. The waiting app server retries and gets a hit.

  7. A hit, a miss under a lease, then a primary dies, step 7App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource databaseShard A primarydownShard B primaryShard C primaryShard A replica42 behindShard B replicaShard C replica
    map epoch
    7
    lost writes
    42

    7Shard A's machine dies. Writes it acknowledged in its last ~10 ms never reached the replica: at 4,200 writes a second, about 42 values are gone.

  8. A hit, a miss under a lease, then a primary dies, step 8App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource database+37,500 reads/sShard A primarydownShard B primaryShard C primaryShard A replicaShard B replicaShard C replica
    map epoch
    7
    lost writes
    42

    8Until a replica takes over, calls to shard A time out and the client treats them as misses. At peak that is 90% of about 41,700 operations: 37,500 extra reads a second hit the database.

  9. A hit, a miss under a lease, then a primary dies, step 9App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configepoch 8App serversSource databaseShard A primarydownShard B primaryShard C primaryShard A replicanow primaryShard B replicaShard C replica
    map epoch
    8
    lost writes
    42

    9After 5 seconds of failed health checks from a majority of its members, the config service promotes the replica and bumps the epoch.

  10. A hit, a miss under a lease, then a primary dies, step 10App servers to Cluster config; App servers to Source database; Cluster config to Shard A primary; App servers to Shard A primary; App servers to Shard B primary; App servers to Shard C primary; Shard A primary to Shard A replica, asynchronous; Shard B primary to Shard B replica, asynchronous; Shard C primary to Shard C replica, asynchronous.Cluster configApp serversSource databaseShard A primarydownShard B primaryShard C primaryShard A replicaShard B replicaShard C replica
    map epoch
    8
    lost writes
    42

    10The client fetches the epoch 8 map and sends shard A's keys to the new primary. The 42 lost values are just misses, refilled from the database.

1 / 10

Deep dives

Interviewers push on four things: one node's internals, routing, what replication promises, and hot keys.

Inside one node: memory, expiry and eviction

The hash table. One node holds 2 billion ÷ 24 ≈ 83 million entries, usually served by one thread on an event loop, with no locks. The trap is growing the table. Moving 67 million entries from 226 buckets to 227 in one go, at say 50 ns each, blocks the node for over 3 seconds. Rehash incrementally instead: keep both tables, move a few buckets on every operation, look in both until the old one is empty.

Expiry. A timer per key is too costly. Combine two cheap mechanisms:

  • Lazy: every read checks expires_at and deletes the entry if it has passed. A reader can never see an expired value.
  • Sampling: in one common scheme, about 10 times a second, pick 20 random keys that have a TTL and delete the expired ones. If more than a quarter of the sample was expired, go again. Unread keys still get cleaned up, without a full scan.

The primary decides expiry and sends a delete to the replica, so both copies agree.

Eviction. Exact LRU, as in the data model, rewrites list pointers on every read and costs 16 bytes per entry. Many caches approximate it instead: keep a small "last used" clock in each entry, sample 5 to 10 keys when memory is full, and evict the oldest of the sample. That is close to true LRU. The policy is set per cluster: LRU over all keys (the usual default), LRU over keys with a TTL (keys without one are never evicted), LFU (keeps steady favourites when one-off scans would flush LRU), or no eviction (writes fail with OUT_OF_MEMORY).

Partitioning and routing

Plain hash(key) mod 24 remaps almost every key when a 25th node arrives. Use consistent hashing in its fixed-slot form: hash each key into one of 16,384 slots, and assign slots to shards. Each of 24 shards owns about 683 slots. Adding a 25th moves about 655 slots, 1 ÷ 25 = 4% of the keys, one slot at a time. While a slot moves, the old node answers keys it still has and sends the rest to the new node for that one request; when the move ends, the map changes and the old node replies MOVED.

Who decides which node gets a key
OptionGood atBad atPick it when
Smart client Usual defaultOne network hop. No extra tier to run or scale.A client library for every language, each caching the map and handling MOVED. Every app server connects to every primary.Most traffic comes from a few languages you control. The usual default.
Proxy tierClients stay dumb. Connections are pooled, so primaries see a few hundred instead of thousands.An extra hop, about 0.2 to 0.5 ms, and a fleet of proxies to keep up.Many languages, legacy clients, or thousands of app servers.

Pick the smart client with redirects as the safety net. The cost: clients learn of map changes on their next error, so some requests briefly take two hops.

Replication and what failover loses

Each shard has a primary and one replica on a different rack. The primary acknowledges a write as soon as it is in its own memory, then streams it to the replica (see Replication). So be precise about the guarantee: an acknowledged write can be lost. With 10 ms of lag and 4,200 writes a second, a crash loses about 42 values. For a cache that is fine; those keys become misses and are refilled from the database.

The loss that hurts is a lost delete. An app updates the database and deletes the cached key; the primary acknowledges and dies before the replica hears of it. The new primary serves the old value until its TTL ends. So give every key a TTL, even a long one: it caps how long such a ghost lives.

Failure detection must not be one machine's opinion: the config service runs on 3 or 5 nodes with consensus; a primary is declared dead only when a majority has failed to reach it for the timeout, 5 seconds here. One more race: a primary cut off from the config service but still reachable by some clients keeps taking writes the new primary never sees. Close it with a leadership lease (not the fill lease from the API): a primary accepts writes only while its lease from the config service is valid. It renews every second, each renewal lasts 4 seconds, and the config service promotes the replica only after 5 seconds. So two primaries never take writes at once. The 1-second margin covers clocks that run at slightly different speeds. Writes it took after the cut are lost.

Synchronous replication would add a round trip to every write to protect data the database already has, so keep it asynchronous.

Hot keys and the thundering herd

One key, a celebrity profile, gets 150,000 reads a second. One shard handles about 100,000, and adding nodes does not split a key. Two fixes:

  • Near cache in the client: each app server keeps that key in its own memory for 1 second. With 300 app servers, the shard sees at most 300 reads a second for it, at the cost of up to 1 second of staleness.
  • Copies under several keys: write the value as profile:9#0 to profile:9#7, which hash to different slots, and read a random one. 150,000 ÷ 8 ≈ 18,750 reads per copy. The cost: a write or delete must touch all 8.

Find hot keys by sampling on each node and reporting the top few every second, as in Design Top-K Trending.

The thundering herd is the other side: a hot key expires or is deleted, and hundreds of app servers miss at once and query the database together. The cache server can help through the lease. The first miss gets a lease; later callers get WAIT until the value is set. Leases also fix a race: app 1 reads an old row, app 2 updates the row and deletes the key, then app 1 writes the old value back. The delete cancelled lease 501, so app 1's SET gets NOT_STORED. A lease needs its own short timeout, say 2 seconds, so a holder that crashes does not block the key.

Bottlenecks and failures

At 10x (12 TB, 10 million operations a second) you need about 240 primaries. Connections strain first: 3,000 app servers (10x today's 300) each connected to 240 primaries is 720,000 connections, 3,000 per node, so add a proxy tier or pooling. Slot moves become routine; automate them and cap their bandwidth.

When this diesWhat happensWhat you do
A primaryIts 1/24 of keys miss for about 5 seconds; about 42 recent writes are lost.Promote the replica. Clients fall back to the database and use a circuit breaker so 37,500 extra reads a second cannot pile up.
A replicaNothing visible; the shard has no spare.Start a new replica and copy a snapshot from the primary. 50 GB at 1 Gbit/s (125 MB/s) is under 7 minutes.
Primary and replica togetherThat shard is empty until a fresh node starts cold.Put them on different racks. Warm the new node gradually, or the database takes every read for that shard.
The config serviceNo failovers or slot moves; traffic continues on the current map.Run 3 or 5 members.
A network partitionA cut-off primary may still take writes from nearby clients.Its lease runs out after at most 4 seconds, before the replica is promoted at 5. Writes it took after the cut are lost.

What a strong answer sounds like

LevelWhat the answer does
JuniorDescribes a hash map with LRU on each server and splits keys with hash mod N; with a prompt, moves to consistent hashing.
MidSizes the cluster from memory, explains lazy plus sampled expiry, chooses slots and a smart client, and adds a replica per shard.
SeniorOpens with "cache or database?", states exactly what an acknowledged write promises, handles split brain and lost deletes, plans for hot keys and stampedes with leases, and says what each choice costs.
What does OK mean?Strong answer
Interviewer

A client gets OK on a set. Is the value safe?

You

Only while that primary lives. Replication is asynchronous, so if it dies in the next few milliseconds the value can be gone: about 42 values per failover at our write rate. I accept that because the database has every value. The dangerous case is a lost delete, so every key carries a TTL to cap how long a stale value can come back.

Interviewer

And if a team needs it to be safe?

You

Then it is not cache data. I'd steer them to a replicated store with quorum writes rather than make every cache write wait for a replica.

Follow-up questions

How do you get several keys in one call?

A multi-get works only within one node, so the client splits it by shard and sends the pieces in parallel. If a group of keys must always live together, give them a shared tag, such as {user:42}:cart and {user:42}:prefs, and hash only the tag.

Should a node write to disk?

Only to restart warm. A periodic snapshot lets a rebooted node load 50 GB from disk instead of starting empty. It is not durability: writes since the snapshot are gone.

What about one huge value?

On a single-threaded node, every request waits behind a 1 MB send. Cap the size, split big objects into chunks, and alert on keys above about 100 KB.

How would you serve two regions?

One independent cluster per region, each in front of its local database replica. When data changes, publish an invalidation event that every region applies.

Why not read from replicas?

It would double read capacity, but a replica lags by about 10 ms, so a client can miss its own fresh write or read a value it just deleted. Here memory sets the node count and primaries run under half their budget, so reads stay on primaries. Allow replica reads only for hot keys that tolerate a little staleness.

Self-review: distributed cache0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure