Design a Key-Value Store

Design a distributed, highly available key-value store: consistent hashing, replication with tunable quorums, conflicts, repair and the storage engine.

Case study32 minLesson 3 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
  10. What to practise next

A key-value store does two things: put(key, value) and get(key). On one machine that is a hash map with a log. Spread it over thirty machines that fail, and you must decide where each key lives, how many copies to keep, what a read returns while copies disagree, and how they heal.

Interviewers ask it to test whether you are precise about guarantees; "it's eventually consistent" is not an answer. It follows A Framework for Any Design and leans on Consistent Hashing and Replication.

Clarify the problem

The design swings on one answer: during a failure, do you refuse writes or accept them and sort out conflicts later? Ask it early.

The first five minutes
Interviewer

Design a distributed key-value store.

You

What are keys and values like, and do we need anything beyond get, put and delete, like range scans or transactions?

Interviewer

Keys up to 256 bytes, values up to 1 MB but usually around 10 KB. Just get, put and delete.

You

How much data and traffic?

Interviewer

About 2 billion keys. At peak, 150,000 reads and 50,000 writes a second.

You

If a node or a network link fails, would you rather reject writes, or accept them and resolve conflicts afterwards? Think of a shopping cart.

Interviewer

Accept them. Losing an "add to cart" costs us money. Some callers want stronger reads, though.

You

Then I'll favour availability, make consistency tunable per request, and never lose a write once it's acknowledged. One region with three zones; multi-region as a follow-up.

  • Functional: get, put and delete by key; the caller chooses how many replicas must answer.
  • Non-functional: writes are accepted even when some replicas of a key are down; p99 under 10 ms within the region; at the default write level, an acknowledged write survives any single node failure; the cluster grows by adding nodes without downtime.
  • Out of scope: range scans, secondary indexes, multi-key transactions, multiple regions.

Estimate the scale

  • Raw data: 2 billion keys × 10 KB = 2 × 1013 B = 20 TB.
  • Write bandwidth: 50,000 × 10 KB = 500 MB/s from clients; with 3 copies, 1.5 GB/s of replica writes across the cluster.
  • Read bandwidth: 150,000 × 10 KB = 1.5 GB/s back to clients.
Estimate: Disk and node count

How much disk do the replicated values need in total?

You know
  • 2 billion keys, about 10 KB each
  • 3 copies of every key
  • Ignore index and compaction overhead

Show the working
  1. Raw: 2 × 109 × 104 B = 2 × 1013 B = 20 TB
  2. Replicated: 20 TB × 3 = 60 TB

Estimate 60 TB (anything from 20 TB to 200 TB is a good answer)

Keep each 4 TB node half full, leaving room for compaction and growth: 60 ÷ 2 = 30 nodes.

Estimate: Load on each node

With N = 3, writes to all 3 replicas, reads from 2, and 30 nodes, about how many replica operations does each node handle at peak?

You know
  • 150,000 reads and 50,000 writes a second at peak
  • Each write goes to 3 replicas; each read asks 2
  • 30 nodes, load spread evenly

Show the working
  1. Replica reads: 150,000 × 2 = 300,000 a second
  2. Replica writes: 50,000 × 3 = 150,000 a second
  3. (300,000 + 150,000) ÷ 30 = 15,000 a second per node

Estimate 15 thousand/s (anything from 5 thousand/s to 50 thousand/s is a good answer)

Comfortable for one node with SSDs. The node count is set by disk, not by traffic.

Your turn: Define the API

Write the three operations. What does get return when replicas hold two different values, and what must the caller send back on put?

Compare with a model answer

A get can return several values (siblings) plus an opaque context holding their versions. The next put sends it back, so the store knows which versions the new value replaces.

Define the API

Client API
get(key, read = "quorum")
  -> { values: [v1, ...], context }   one value normally; several after a conflict
  -> NOT_FOUND
  -> UNAVAILABLE                      fewer than R replicas answered in time
 
put(key, value, context?, write = "quorum")
  -> OK                               W replicas have it in their commit logs
  -> UNAVAILABLE                      fewer than W confirmed in time (it may
                                      still be stored on some replicas)
  -> TOO_LARGE                        key over 256 B or value over 1 MB
 
delete(key, context?, write = "quorum")   writes a tombstone
 
read / write levels: "one" | "quorum" | "all"   (R or W out of N = 3)

The decision worth arguing is the context. Without it, put overwrites and the newest timestamp wins, which silently drops one of two concurrent writes. With it, the store can tell "this replaces what you read" from "these happened in parallel" and hand both back to merge. It costs callers complexity, so make it optional: no context means last-write-wins.

Note the honest UNAVAILABLE on put: a write that reached 1 of 2 required replicas reports failure but may still be read later. Callers treat it as "unknown" and retry.

Your turn: Model the data

What does one stored record hold besides the key and value? And what does each node need to know about the cluster?

Compare with a model answer

Each record carries a version vector, a timestamp and a tombstone flag. Each node also holds the ring and which nodes are alive, learned by gossip.

Model the data

What each node stores
record   (on disk, sorted by key)
  key              up to 256 B
  value            up to 1 MB, about 10 KB on average
  version          version vector, e.g. {B: 3, C: 1}, about 16 B per entry
  timestamp        8 B, used only for last-write-wins and tombstone expiry
  tombstone        1 bit: this key was deleted
 
cluster metadata (same on every node, a few MB)
  ring             node -> its virtual-node positions on the hash ring
  membership       node -> zone, status (up / suspected / leaving), heartbeat

Every operation is a single-key lookup or overwrite. Writes are a quarter of the traffic, but that is 5,000 replica writes a second per node, every one to a random key. An LSM tree turns them into sequential appends; Indexing explains how. Its cost is reads that may check several files, which Bloom filters keep cheap. A B-tree would be the pick if writes were rare.

Your turn: High-level design

Sketch a put: who receives it, how it finds the nodes that own the key, and when it can say "done". Then sketch what happens when one of those nodes is down.

Compare with a model answer

No leader. The node that receives the request hashes the key, walks the ring to the next 3 distinct nodes, sends the write to all 3 and replies after 2 confirm. If an owner is down, a stand-in keeps the write as a hint and passes it on later.

High-level design

There is no leader. Any node can coordinate any request, and every node knows the ring. Keys sit on a hash ring with about 100 virtual nodes per server. A key's preference list is the first 3 distinct servers clockwise from its hash, chosen so the 3 copies land in 3 different zones.

The node that receives a request coordinates it and sends it to the key's 3 replicas. When one replica is down, a stand-in node holds its writes as hints.Client library to Coordinator (any node) (get / put); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in) (hint for D); Node E (stand-in) to Replica D (zone 3) (hand back), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1)Replica C (zone2)Replica D (zone3)Node E(stand-in)get / puthint for Dhand back

request and replyasynchronous message

The node that receives a request coordinates it and sends it to the key's 3 replicas. When one replica is down, a stand-in node holds its writes as hints.
Write, read, and a write with a replica downUse the arrows or ← → keys
  1. Write, read, and a write with a replica down, step 1Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1)Replica C (zone2)Replica D (zone3)Node E(stand-in)
    N
    3
    W
    2
    acks
    0

    1The client calls put("cart:42", [milk]). Whichever node receives it becomes the coordinator.

  2. Write, read, and a write with a replica down, step 2Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)owners: B, C, DReplica B (zone1)Replica C (zone2)Replica D (zone3)Node E(stand-in)
    N
    3
    W
    2
    acks
    0

    2The coordinator hashes the key and walks clockwise on the ring: the first 3 distinct servers are B, C and D, one per zone. Every node has the ring, so no lookup.

  3. Write, read, and a write with a replica down, step 3Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1)Replica C (zone2)Replica D (zone3)Node E(stand-in)
    N
    3
    W
    2
    acks
    0

    3It sends the write to all 3 in parallel but waits for only 2: every copy stays current, and latency is set by the second-fastest.

  4. Write, read, and a write with a replica down, step 4Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1)loggedReplica C (zone2)loggedReplica D (zone3)Node E(stand-in)
    N
    3
    W
    2
    acks
    2

    4Each replica appends the write to its commit log on disk, then updates its in-memory table. Only then does it confirm. B and C confirm first.

  5. Write, read, and a write with a replica down, step 5Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1)Replica C (zone2)Replica D (zone3)loggedNode E(stand-in)
    N
    3
    W
    2
    acks
    3

    52 confirmations = W, so the client hears OK. The write is on disk in two zones, at version {B: 1}: B, the key's first owner, stamped it. D confirms a moment later; nobody waited.

  6. Write, read, and a write with a replica down, step 6Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1){B: 1}Replica C (zone2)Replica D (zone3){B: 1}Node E(stand-in)
    N
    3
    R
    2
    answers
    2

    6A read, get("cart:42") with R = 2. The coordinator asks the replicas and uses the first 2 answers: B and D, both at version {B: 1}. They agree, so it returns [milk].

  7. Write, read, and a write with a replica down, step 7Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1)Replica C (zone2)Replica D (zone3)downNode E(stand-in)
    N
    3
    W
    2
    acks
    0

    7Now D stops answering, rebooting or cut off. A new write arrives for the same key.

  8. Write, read, and a write with a replica down, step 8Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1)Replica C (zone2)Replica D (zone3)downNode E(stand-in)hint for D
    N
    3
    W
    2
    acks
    2

    8B and C get the write as usual. Instead of D, the coordinator sends a copy to E, the next healthy node on the ring, tagged "this belongs to D". That is a hinted handoff.

  9. Write, read, and a write with a replica down, step 9Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1)Replica C (zone2)Replica D (zone3)downNode E(stand-in)
    N
    3
    W
    2
    acks
    3

    9B and C confirm, which meets W = 2 with real owners. Had only one owner been up, E's copy would count toward W instead: a sloppy quorum that stays available but weakens the read guarantee.

  10. Write, read, and a write with a replica down, step 10Client library to Coordinator (any node); Coordinator (any node) to Replica B (zone 1); Coordinator (any node) to Replica C (zone 2); Coordinator (any node) to Replica D (zone 3); Coordinator (any node) to Node E (stand-in); Node E (stand-in) to Replica D (zone 3), asynchronous.Client libraryCoordinator (anynode)Replica B (zone1)Replica C (zone2)Replica D (zone3)caught upNode E(stand-in)hints sent

    10D comes back 10 minutes later. E sees it in gossip, sends it the hinted writes and deletes them. No full repair needed.

1 / 10

Deep dives

Partitioning with consistent hashing

Each server gets about 100 points on the ring (virtual nodes); Consistent Hashing covers why. What a replicated store adds:

  • Skip duplicates when walking the ring. Neighbouring points may belong to one server, so take the next 3 distinct servers in 3 distinct zones. Otherwise one zone outage can take every copy of a key.
  • Rebuilds are parallel. A dead node's 2 TB is spread in small ranges over the other 29. If each streams its share at 100 MB/s, the rebuild takes 2 × 1012 ÷ (29 × 108) ≈ 690 s, about 11 minutes. From one peer it would take 5.6 hours.

Replication and tunable quorums

Each key has N = 3 replicas. A write waits for W of them, a read for R. Replication traces why R + W > N makes read and write sets overlap. In the interview, pick values per use case:

Setting (N = 3)What you getWhat it costs
W = 2, R = 2Reads see the latest acknowledged write in the normal case; survives one replica down for both.Each op waits for the second-fastest replica. The default.
W = 1, R = 1Fastest; writes succeed with two replicas down.A read can miss a write that just succeeded. Fine for caches and session data.
W = 3, R = 1Fast reads that see every acknowledged write.Any one replica down blocks all writes to its keys.

Be precise about the limits. R + W > N guarantees overlap, not linearizability: concurrent writes both succeed, a failed write can still appear on one replica, and two reads during a write can see new then old. A sloppy quorum breaks even the overlap: if stand-ins like E met W, a read from the real owners can miss the write until the hint arrives. Say: "Quorum reads see the latest acknowledged write while the home replicas are reachable; during failures we choose availability and accept stale reads." More in CAP and Consistency.

Resolving conflicts

Because the store takes writes during failures, two clients can update one key without seeing each other.

Two devices edit one cartUse the arrows or ← → keys
  1. Two devices edit one cart, step 1Phone to Node B; Laptop to Node C; Node B and Node C (replicate).PhoneLaptopNode B{B:1}Node C{B:1}replicate
    B holds
    milk
    C holds
    milk

    1Both replicas hold the cart [milk] at version {B: 1}: one write, coordinated by B.

  2. Two devices edit one cart, step 2Phone to Node B; Laptop to Node C; Node B and Node C (replicate).PhoneLaptopNode B{B:2}Node C{B:1}replicate
    B holds
    milk, eggs
    C holds
    milk

    2The link between B and C fails. The phone reads [milk] with context {B: 1} and adds eggs. B coordinates, so it bumps its own entry: {B: 2}.

  3. Two devices edit one cart, step 3Phone to Node B; Laptop to Node C; Node B and Node C (replicate).PhoneLaptopNode B{B:2}Node C{B:1,C:1}replicate
    B holds
    milk, eggs
    C holds
    milk, bread

    3The laptop also read [milk] at {B: 1}. It adds bread through C, which adds its own entry: {B: 1, C: 1}.

  4. Two devices edit one cart, step 4Phone to Node B; Laptop to Node C; Node B and Node C (replicate).PhoneLaptopNode B2 siblingsNode C2 siblingsreplicate
    siblings
    [milk, eggs] and [milk, bread]

    4The link heals and the versions meet. {B: 2} has the bigger B count; {B: 1, C: 1} has the bigger C count. Neither contains the other, so the writes were concurrent. Keep both as siblings.

  5. Two devices edit one cart, step 5Phone to Node B; Laptop to Node C; Node B and Node C (replicate).PhonemergeLaptopNode B2 siblingsNode Creplicate
    merged
    milk, eggs, bread

    5The next read returns both carts and the merged context {B: 2, C: 1}. The cart code knows what a cart is, so it merges by union: [milk, eggs, bread].

  6. Two devices edit one cart, step 6Phone to Node B; Laptop to Node C; Node B and Node C (replicate).PhoneLaptopNode B{B:3,C:1}Node C{B:3,C:1}replicate
    B holds
    milk, eggs, bread
    C holds
    milk, eggs, bread

    6It writes the merged cart with that context. B makes it {B: 3, C: 1}, which covers both siblings, so both are replaced. Nothing was lost.

  7. Two devices edit one cart, step 7Phone to Node B; Laptop to Node C; Node B and Node C (replicate).PhonedownLaptopNode BLWW keeps CNode CLWW keeps Creplicate
    B holds
    milk, bread
    C holds
    milk, bread

    7Contrast last-write-wins: the store keeps whichever write has the later timestamp, say the laptop's, and the eggs vanish without any error. If the laptop's clock ran fast, it wins even if it wrote first.

1 / 7

A version vector detects concurrency; it does not resolve it. Someone still merges, and a plain union has a flaw: an item removed on one device comes back from the other.

Ways to resolve concurrent writes
OptionGood atBad atPick it when
Last-write-winsSimple. One value per key, no work for callers.Silently drops concurrent writes, and clock skew decides which one.Values are written once, or one writer owns each key, or losing an update is harmless (a cache).
Version vectors + siblings Usual defaultNever loses a write. The application merges with real knowledge of the data.Every caller must handle siblings. Vectors grow with the number of coordinators.Concurrent updates matter and callers can merge: carts, user settings. My default here.
Merge-friendly data types (CRDTs)The store merges automatically and correctly: counters, sets, maps.Only for those data shapes; removals need extra metadata.The value is a counter or a set and you want merges without client code.

What would change my mind: if nearly every key has one writer, last-write-wins is simpler and safe enough.

Detecting failures and repairing data

No central monitor decides who is dead. Every second, each node bumps its heartbeat counter and sends its membership table to a few random peers (gossip). If a node's counter has not moved for, say, 10 seconds, peers mark it suspected. That is an opinion: a slow node and a dead one look the same.

Treat temporary and permanent failures differently. A suspected node gets hinted handoff and no data moves; streaming 2 TB for a node that reboots in a minute does harm. Only when an operator, or automation with a long timeout, declares it gone do its ranges rebuild.

Three mechanisms repair data:

  • Hinted handoff covers short outages. A node down for 10 minutes misses 5,000 writes a second × 600 s = 3 million writes, about 30 GB at 10 KB each, held by the stand-ins.
  • Read repair fixes stale copies a read notices.
  • Anti-entropy catches everything else, including keys nobody reads. Two replicas compare a Merkle tree, a tree of hashes over sub-ranges, and descend only into branches whose hashes differ. With 200 million keys per node and 215 leaves, one wrong key costs a walk down 15 levels and a sync of one leaf of about 6,000 keys, not 200 million.

The storage engine, in outline

A write is appended to the commit log, flushed to disk before the node confirms, and added to a sorted table in memory, later written out as an immutable sorted file. A read checks memory, then files from newest to oldest; a Bloom filter per file answers "definitely not here" without touching disk. Compaction merges files in the background.

Deletes are the trap. A delete writes a tombstone, which compaction may drop only after a grace period, typically days. If a replica was down when the delete happened and the tombstone is purged before repair reaches it, anti-entropy sees the old value on that replica, finds nothing on the others, and copies it back. The deleted key returns. So the grace period must be longer than the longest outage you repair from.

Bottlenecks and failures

At 10x (600 TB replicated, 2 million operations a second), you need about 300 nodes. Consistent hashing handles the growth, but two things strain. Hot keys do not spread: a popular key still lives on 3 nodes. And compaction debt, where writes outrun merging, makes reads check more files; watch files per read and throttle writes early.

When this failsWhat happensWhat you do
One node, brieflyIts keys have 2 of 3 replicas. W = 2 and R = 2 still succeed.Hinted handoff; it catches up on return.
One node, for goodIts 2 TB is down to 2 copies.Declare it gone; 29 peers rebuild its ranges in about 11 minutes.
A whole zoneEvery key loses one replica, since each key has one copy per zone.Quorum operations continue on the other two zones.
A network partitionBoth sides may accept writes to the same keys.Version vectors keep both as siblings; callers merge when the link heals.
A disk silently corrupts dataOne replica returns bad bytes.Checksums on every block detect it; that replica re-fetches from the others.

What a strong answer sounds like

LevelWhat the answer does
JuniorShards by hash, adds replicas, and says "eventually consistent". Needs prompting on what happens when replicas disagree.
MidUses consistent hashing with virtual nodes, explains R + W > N, and describes read repair and hinted handoff.
SeniorTies choices to the availability requirement, is precise about what quorums do not guarantee, names the cost of last-write-wins and of version vectors, separates temporary from permanent failure, and knows why tombstones need a grace period.
Being precise about guaranteesStrong answer
Interviewer

With N = 3, W = 2, R = 2, is this strongly consistent?

You

Not in the linearizable sense. Every read overlaps every acknowledged write, so normally it sees the latest one. But concurrent writes both succeed as siblings, a write reported as failed can still show up, and if stand-ins met the quorum during an outage, a read can miss that write until the hint is delivered.

Interviewer

So when would you not use this design?

You

For anything like an account balance or a uniqueness check. Those need one leader per range with consensus; writes pause for a few seconds during failover, and that trade is right for them.

Follow-up questions

How would you run it in two regions?

Keep N replicas per region, acknowledge after a local quorum, and replicate to the other region asynchronously. Version vectors catch cross-region conflicts too; a region failure loses the last moments of unreplicated writes.

How do you support range scans?

Hashing scatters neighbouring keys. Partition by key range instead, which keeps order but needs splitting of hot or large ranges. You cannot have both with one layout.

How do you add expiry (TTL)?

Store an expiry time with the record; reads treat expired records as missing. Every replica expires it on its own, so no delete is sent, but the expired record must act as a tombstone through the same grace period. Drop it early and an older copy without the expiry, on a lagging replica, comes back through repair.

How does a new node join without downtime?

It takes about 100 new points on the ring. For each range it now owns, the current owners stream it the data while new writes go to it as well. It serves reads only once streaming ends; then the old owners drop their extra copies.

One key gets 100,000 reads a second. What now?

With R = 2, that is 200,000 replica reads on 3 nodes, about 67,000 each, over 4 times a node's normal 15,000. Cache it for a second in the coordinators, or read it with R = 1 if slight staleness is fine.

Self-review: key-value store0 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

What to practise next

A news feed stores its timelines in a store like this one. See how fan-out decides what gets written in Design a News Feed.