A13 · Part 3 · Advanced
Walkthrough: a key-value store
A Dynamo-style store is not one clever idea but eight ordinary ones bolted together, and the interview is about the decision you make at each seam.
10On this page
- What you are building, and what you refuse to build
- Capacity estimation
- Partitioning: the ring and its settings
- The request path: who coordinates, and what a quorum costs
- Versioning: vector clocks against last write wins
- Failure handling, in three layers
- Membership and failure detection without a coordinator
- The storage node: a commit log, a memtable and immutable files
- Follow-ups and failure modes to volunteer
- Recognizing it in an unseen problem
What you are building, and what you refuse to build
"Design a key-value store" (it is the sixth chapter of Alex Xu's first volume) sounds like a data-structure question and is really an assembly question. The hash map is the easy part. The task is to make it survive the loss of machines, outgrow one machine, and stay writable while the network is broken, using parts you have already met in this course. So this chapter does not re-teach them. It names each part, links the chapter that explains it, and spends its words on the seams: the choices where one part constrains the next.
The model is the one in Amazon's Dynamo paper (SOSP 2007), which the paper pins down tightly: state is stored as binary objects identified by a key, no operation spans multiple items, there is no relational schema, and the objects are relatively small, usually under 1 MB. That scope is the whole bargain. By refusing transactions, secondary indexes and range scans you get a system where every request touches one key, so every request can be routed by a hash, and capacity grows by adding machines.
| Requirement | Decision | Where it is explained |
|---|---|---|
| More data than one machine holds | Hash-partition the key space on a ring with virtual nodes | Sharding |
| Survive machine and zone loss | N copies of each key on distinct machines, spread over failure domains | Replication |
| Stay writable during a partition | Choose availability over consistency (AP), with tunable quorums | CAP, consistency models |
| Concurrent writes will happen | Version every value: a vector clock or a timestamp | Time and ordering |
| Copies drift apart | Hinted handoff, read repair, Merkle-tree anti-entropy | Replication, and below |
| No coordinator to fail | Gossip membership and a local failure detector | Below |
| Fast, durable local writes | An LSM storage engine: commit log, memtable, SSTables | Storage engines |
The AP choice is the first decision, not a detail
Everything after this row depends on saying out loud that writes keep being
accepted when replicas cannot talk to each other, and that you will reconcile
later. A strongly consistent store (a consensus group per key range, as in the
consensus chapter) is a legitimate
alternative; it needs a different list of components. Amazon DynamoDB, the
managed service, is a different design from the Dynamo paper (see the replication
chapter), so do not answer a Dynamo question by describing DynamoDB.
Capacity estimation
The assumptions are mine, chosen to be round; the structure of the estimate is what to copy.
- Data: 5 billion keys × 1 KB average value = 5 TB of user data. With N = 3 replicas, 15 TB stored.
- Traffic at peak: 100,000 reads a second and 50,000 writes a second.
- Fan-out into replica work. Following Cassandra's read path, a read at R = 2 asks two replicas (one returns the data, one a digest), so 100,000 × 2 = 200,000 replica reads. (The Dynamo paper's coordinator instead asks all N replicas and waits for R answers: 300,000 reads, 450,000 operations in total, about 75 nodes at the same utilization.) A write goes to all three replicas, so 50,000 × 3 = 150,000 replica writes. Total 350,000 replica operations a second.
- Node count. Take 10,000 operations a second per node as a conservative planning figure and plan to run at 60%, so 6,000 usable: 350,000 ÷ 6,000 = 58.3, so 60 nodes, 20 in each of three availability zones.
- Storage per node: 15 TB ÷ 60 = 250 GB. Throughput, not capacity, set the node count, which is typical for this workload and is why I would not pay for 4 TB disks.
- Write path per node: 150,000 ÷ 60 = 2,500 replica writes a second × 1 KB = 2.5 MB/s into the commit log. A 32 MiB commit log segment (33.6 MB) fills in about 13 seconds. At the storage chapter's assumed write amplification of 20, compaction writes about 50 MB/s per node. The compaction budget, not the 2.5 MB/s, is what you size disks for.
- Cross-zone traffic: each write sends the value to two replicas in other zones: 50,000 × 2 × 1 KB = 100 MB/s, about 0.8 Gbps.
- Growth: adding the 61st node moves 15 TB ÷ 61 = 246 GB to it. At an assumed 100 MB/s streaming throttle that takes 2,460 seconds, about 41 minutes. Scaling out is slow and should start well before you need it.
Partitioning: the ring and its settings
Hash each key onto a ring; each node owns tokens, and a key belongs to the first
node clockwise from its hash. The sharding chapter works through why
hash mod N fails and why virtual nodes are the real trick. The
decisions that remain are quantitative. How many tokens? Cassandra 5.0's
num_tokens default is 16 (older versions used 256 random tokens, and
the docs say newer versions use a deterministic allocator that needs far fewer
per node). For 60 nodes that is 960 tokens. What do tokens buy? When a node
dies or joins, its ranges move in many small pieces to or from many neighbours
instead of dumping all its load on one.
The Dynamo paper adds a decision worth knowing because it explains operational pain. Its first scheme gave each node random tokens, so the partition boundaries were set by the tokens. Moving data meant recomputing Merkle trees and archiving the key space was hard, because partitioning and placement were entangled. The paper's third scheme (Q/S tokens per node) divides the hash space into Q equal partitions and then places partitions on nodes. Placement can change without changing partitioning, a partition is one file that can be archived or shipped as a unit, and the paper reports it had the best load-balance efficiency for the same amount of per-node metadata, at a cost: changing the node count needs coordination. The takeaway for an interview is a single sentence: decouple the partition from the node that owns it, so rebalancing moves whole partitions and nothing is rehashed.
Replica placement is the next seam. The coordinator stores the key and copies it to the next N − 1 distinct clockwise nodes. With virtual nodes the next N positions can belong to fewer than N machines, so the paper builds the preference list by skipping positions until it has N distinct physical nodes, and spreads the list across data centers. In practice you extend this to racks and zones. If you remember one rule: the preference list is chosen for failure-domain diversity, not for ring adjacency.
The request path: who coordinates, and what a quorum costs
Any node can receive a request, and the key's coordinator, typically the first of the top N nodes in the preference list, runs it. The paper describes two ways to reach the coordinator. A generic load balancer sends the request to a random node, which forwards it if it is not in the key's preference list: the client needs no special library, at the cost of a possible extra hop. A partition-aware client library routes directly and skips the hop, at the cost of shipping Dynamo-specific code into every client. Either way, every node needs to know the ring, which is what gossip is for (below).
For a put the coordinator generates the new version's vector clock,
writes it locally, sends it to the other replicas and succeeds when at least
W − 1 others respond. For a get it asks the replicas and waits for R
responses. If it gets several versions that are causally unrelated, it returns them
all. The paper's own note on cost is the one to remember: the latency of a get or
put is dictated by the slowest of the R or W replicas it must wait for,
which is why R and W are usually set below N.
The table and the availability sum below assume strict quorums, where only the real replicas count. A sloppy quorum (Dynamo's) raises write availability, because a stand-in node can take the write, and weakens the guarantee that a read overlaps the latest write.
| (N, W, R) | R + W > N? | Replicas that may be down for a write / a read | Choose it for |
|---|---|---|---|
| (3, 2, 2) | 4 > 3, yes | 1 / 1 | The common setting. The Dynamo paper says (3, 2, 2) was the configuration used by several instances |
| (3, 3, 1) | 4 > 3, yes | 0 / 2 | Read-heavy, rarely written; a single dead replica blocks all writes |
| (3, 1, 3) | 4 > 3, yes | 2 / 0 | Write availability above all; any dead replica blocks reads |
| (3, 1, 1) | 2 > 3, no | 2 / 2 | Fastest and weakest: a read may miss the newest write |
The availability cost is easy to compute and impressive to say. With strict quorums, assume each replica is up 99.9% of the time, independently (a generous assumption: zones, racks and bad deploys correlate failures). A write that needs 2 of 3 replicas succeeds with probability 3 × 0.999² × 0.001 + 0.999³ = 0.002994 + 0.997003 = 0.999997, about 1.6 minutes of unavailability a year. A write that needs all 3 succeeds with 0.999³ = 0.997003, which is 26.3 hours a year. Demanding one more replica costs a factor of about a thousand in unavailability, which is why (3, 2, 2) beats (3, 3, 1) for anything user-facing.
Versioning: vector clocks against last write wins
Two clients can update the same key through different coordinators during a failure. The store must either detect that or silently pick a winner, and this is the decision users feel most. The time and ordering chapter explains the clocks; the seam question is which one you attach to a value.
| Vector clock (Dynamo, Riak) | Last write wins (Cassandra) | |
|---|---|---|
| Concurrent writes | Detected; both versions are kept and returned to the reader, who merges | Not detected; the higher timestamp silently wins |
| Cost per value | A list of (node, counter) pairs; the Dynamo paper truncates it at a threshold, its example 10 pairs, dropping the oldest, which it concedes can blur the ancestry check | One timestamp per value (Cassandra keeps one per cell, so concurrent writes to different columns both survive) |
| Who pays | The application, which needs a merge function (the paper's example merges shopping carts, and notes that deleted items can resurface) | The user, whose write may vanish when two clocks disagree |
| Failure mode | Sibling explosion: Riak's documentation describes objects rapidly collecting unreconciled siblings, with higher latency and memory pressure | Clock skew picks the wrong winner and nobody knows |
| Reality check | The paper measured 99.94% of requests to Amazon's shopping-cart service seeing exactly one version in one 24-hour window; it attributes divergence mainly to concurrent writers, often automated clients, not failures | Fine when writes are single-writer-per-key or idempotent overwrites |
Riak's documentation says vector clocks were once its sole means of tracking object history, that they cannot say which value belonged to which update (so duplicate siblings appear), and that dotted version vectors, an option since Riak 2.0, fix that and limit siblings better, so it recommends them. The answer to give is therefore "causal metadata, preferably dotted, and an application merge where concurrent updates matter; timestamps where they do not."
Dry run: the paper's five versions of one key, and what a timestamp would have done
| Version | Written by | Vector clock | Relation to the others |
|---|---|---|---|
| D1 | Sx | [(Sx,1)] | First version |
| D2 | Sx | [(Sx,2)] | Descends from D1, overwrites it |
| D3 | Sy, after a client read D2 | [(Sx,2), (Sy,1)] | Descends from D2 |
| D4 | Sz, after another client read D2 | [(Sx,2), (Sz,1)] | Descends from D2. D3 has Sy = 1 where D4 has 0, and D4 has Sz = 1 where D3 has 0: concurrent, both kept |
| D5 | Sx, after a client read D3 and D4 and merged them | [(Sx,3), (Sy,1), (Sz,1)] | The read context is the element-wise maximum, [(Sx,2), (Sy,1), (Sz,1)]; Sx then increments its own counter. D5 descends from both |
Now replay D3 and D4 with timestamps instead, where Sy's clock is 2 ms ahead of Sz's: D3 is stamped 10.002 and D4 is stamped 10.001. Last write wins keeps D3 and drops D4, and the client that wrote D4 is never told. With vector clocks the store knew the two writes had never seen each other, kept both, and let a reader merge them into D5. The cost was the merge code and the bookkeeping.
Failure handling, in three layers
Replicas will disagree, and each layer repairs on a different timescale and with different guarantees. The replication chapter has the full table; the assembly question is what you promise and when.
- Hinted handoff (seconds to hours). A node that cannot reach a replica stores the write with a hint naming the intended recipient and delivers it when that node returns. The Dynamo paper's variant is a sloppy quorum: reads and writes use the first N healthy nodes of the preference list, so a write succeeds even when two of the three real replicas are unreachable, at the price that the nodes acknowledging it may not be the ones a later read asks. Cassandra deliberately differs: a write always goes to every replica, the consistency level only says how many replies to wait for, and a hint on the coordinator is repair, not an acknowledgement (other than at consistency level ANY). Cassandra's docs also call hints best effort that do not guarantee eventual consistency, and keep them by default for a 3-hour window.
- Read repair (on the read path). In Cassandra the coordinator reads the data from one replica and digests from others; on a mismatch it fetches the full data, reconciles by timestamp and writes the winner back to the stale replicas involved, before answering. In the Dynamo paper the coordinator asks all N replicas for their versions, answers after R, and then, once the stragglers arrive, updates any node that returned a stale version, using the vector clocks. Either way it only repairs keys that are read, and Cassandra does nothing at consistency level ONE, because no digests are requested.
- Anti-entropy with Merkle trees (days). Replicas compare hash trees over the key ranges they share and stream only what differs. This is the only layer that fixes data nobody reads, and it must run often enough to beat the tombstone grace period: Cassandra's default is 10 days, so repair every node at least every 7 days.
The Merkle arithmetic is worth doing once, using the paper's traversal (two replicas compare roots, then descend only into subtrees whose hashes differ). Cassandra instead has each replica build its whole tree by scanning the range and then compares the trees, so there the saving is in what gets streamed, not in the comparison. Suppose a node's range holds 250 GB and each side builds a tree with 216 = 65,536 leaves (an illustrative size). Each leaf covers 250 GB ÷ 65,536 ≈ 3.8 MB. Compare roots: if equal, the range is in sync after one hash. If not, descend: each level compares two child hashes, and one differing key makes exactly one path mismatch, so the comparison costs 1 + 16 × 2 = 33 hash exchanges instead of 65,536, and the repair streams one 3.8 MB leaf range rather than the whole 250 GB. The trade-off is the leaf size: more leaves mean less re-streamed data and more memory to build the tree. Dynamo notes the weakness that motivated its partitioning change: each node keeps a tree per key range it hosts, and when a node joins or leaves, many ranges change and their trees must be recalculated.
Membership and failure detection without a coordinator
There is no master to ask who is alive, so nodes tell each other. Cassandra's gossip runs every second: each node updates its own heartbeat, exchanges state with one random node, probabilistically tries nodes it believes are unreachable, and talks to a seed node if it has not already. State is versioned by a (generation, version) pair so newer information wins. Because each exchange spreads what a node knows to another, news reaches the cluster in a number of rounds that grows with the logarithm of the cluster size, so seconds for tens of nodes at one round a second (a planning estimate, not a measured figure). The Dynamo paper adds seeds: well-known nodes that every node eventually reconciles membership with, which prevents the cluster splitting into two logical rings that never learn of each other.
Deciding that a node is dead is a separate problem. Cassandra runs a variant of
the phi accrual failure detector: instead of a binary timeout, each node
turns the silence since the last heartbeat into a suspicion level, phi, and marks
a peer down when phi reaches phi_convict_threshold (default 8, "most
users should never need to adjust this"). Two seam decisions follow. First, the
verdict is local: Cassandra's docs say UP and DOWN status is not
propagated by gossip, so two nodes can disagree about a third, and a quorum
system tolerates that. Second, the detector trades speed against false alarms.
Convicting too fast during a garbage-collection pause triggers hints and
re-routing for a node that was merely slow, and convicting too slowly sends
requests to a dead node and adds timeouts to your p99.
The storage node: a commit log, a memtable and immutable files
Each node persists with an LSM engine, explained in the storage engines chapter; here is the path through Cassandra's version with its documented settings.
- Write path. Each write is appended to a local commit log (segments of 32 MiB by default) and inserted in the memtable, which keeps data sorted by key. A memtable flush writes a sorted, immutable SSTable with a partition index; background compaction merges SSTables, which is the write amplification the capacity estimate charged for.
- Read path. The node consults the memtable and the SSTables that may hold the key. Each SSTable has a Bloom filter over its partition keys to skip files, a partition index that maps keys to positions in the data file, and a summary that samples every 128th index entry by default (in the BIG sstable format; Cassandra 5.0's newer BTI format uses a trie-based index instead). Versions found in different files are reconciled by timestamp.
- The durability seam. The commit log defaults to
periodicsync: writes are acknowledged immediately and the log is fsynced every 10,000 ms by default, so an unlucky machine crash can lose up to that window on that node.batchmode waits for the fsync, at the cost of latency. The quorum is what makes the default acceptable: an acknowledged write sits in at least two nodes' logs, and with replicas in different zones one machine failure cannot lose it. The Dynamo paper makes the same trade: it describes a write buffer that cut 99.9th-percentile latency by a factor of 5 at peak, and refines it so the coordinator picks one replica to do a durable write while still waiting only for W responses.
Dry run: a write during a failure, then three ways the dead replica catches up
The ring from the figure: nodes A to E at 100, 300, 500, 700 and 900. Key k hashes to 250, so its coordinator is B and its preference list is B, C, D. N = 3, W = 2, R = 2, Dynamo-style sloppy quorum.
| Time | Event | B | C | D | E (hints) |
|---|---|---|---|---|---|
| t0 | put v1 via B. Clock [(B,1)]. B stores it, sends to C and D. Replies from C and D (W − 1 = 1 needed): success | v1 | v1 | v1 | none |
| t1 | C crashes | v1 | down | v1 | none |
| t2 | put v2 (client passes the context [(B,1)]). Clock [(B,2)]. B stores it, sends to D, and to E as a hinted replica for C because C is unreachable. D replies: success (W = 2 met by B and D even under a strict quorum) | v2 | down | v2 | v2 for C |
| t3 | get with R = 2 asks B and D: both return v2, so one version, no repair needed | v2 | down | v2 | v2 for C |
| t4a | Path A, hint delivered. C returns, gossip marks it up, E delivers v2 to C and deletes its copy | v2 | v2 | v2 | empty |
| t4b | Path B, hint lost. E crashed and lost the hint; C returns holding v1 | v2 | v1 | v2 | empty |
| t5b | get asks C and D. D returns v2 with clock [(B,2)], C returns v1 with [(B,1)]. [(B,2)] descends from [(B,1)], so v2 wins and is written back: read repair | v2 | v2 | v2 | empty |
| t6c | Path C, nobody reads k. The range containing k differs between C and B in the Merkle comparison: 33 hash exchanges locate the 3.8 MB leaf range (assuming the 250 GB range from the capacity estimate), which is streamed to C | v2 | v2 | v2 | empty |
Three things to say about it. The client never saw a failure: W = 2 was met by real replicas. The correctness of path B relied on the vector clock to prove v2 newer, which is exactly what timestamps cannot prove under skew. And path C is the only one that works for keys that nobody reads, which is why anti-entropy must exist and must be scheduled, even though hints and read repair usually do the work. (At t2, a Cassandra-style strict quorum keeps the hint on the coordinator B instead of E, and E is never involved.)
Follow-ups and failure modes to volunteer
- Hot keys. Partitioning spreads keys, not requests. The Dynamo paper's design assumes there are enough popular keys to spread their load; a single hot key stays on its N replicas. Answer with a cache in front, key splitting (a counter as several keys summed on read) or client-side batching.
- Deletes. A delete is a write of a tombstone; purge it only after every replica has it, which is the repair-within-grace-period rule above. Otherwise deleted data returns.
- Multi-region. Put replicas of a preference list in several data centers, as the paper describes. Cassandra additionally lets you choose per request between a local and a global quorum (LOCAL_QUORUM and EACH_QUORUM); waiting globally costs cross-region latency of 60 to 230 ms (the course's round-trip figures).
- Large values. Dynamo targets objects under 1 MB. Beyond that, store a pointer and keep the blob in object storage.
- Overload on recovery. A node that returns after hours is flooded by hint replay and repair streaming at the same moment it is cold. Throttle both.
Say it like this → "I'll build a Dynamo-style AP store,
because for a key-value workload I'd rather accept a write and reconcile than
refuse it. Keys hash onto a ring with 16 virtual nodes per machine, and each key
lives on three nodes in different zones. I'll run N = 3, W = 2, R = 2 with strict
quorums, so reads and writes overlap and any one replica can fail without anyone noticing. Versions
carry vector clocks where concurrent writes matter and timestamps where they do
not. A missed write is repaired three ways: a hint when the node returns, read
repair when a stale replica is read, and Merkle-tree anti-entropy on a schedule
shorter than the tombstone grace period. Nodes learn membership by gossip and
decide failure locally with a phi-accrual detector. Each node is an LSM engine, so
writes are a log append and a memory insert. For the numbers, 5 TB of data is 15 TB
replicated, but 350,000 replica operations a second need about 60 nodes, so
throughput, not disk, sets the size."
Recognizing it in an unseen problem
- Signals: "design a key-value store", "a cache that survives restarts", "a session store", "a shopping cart that is always writable", "a distributed hash table", or any prompt with simple get and put by key at large scale and a strict availability target.
- First fork: must two readers never disagree (then a consensus-backed store, as in the consensus and replication chapters) or must the system always accept writes (then this design)?
- What to refuse: multi-key transactions, secondary indexes and range scans. Naming that these are out of scope is part of the design.
- The numbers an interviewer wants: replica fan-out (R reads and N writes per client operation), node count from throughput, write amplification, and the cost of one more replica in the write quorum.
- The pitfalls to name unprompted: last write wins and clock skew, the hint window, tombstone resurrection if repair runs too rarely, hot keys, and that R + W > N guarantees overlap only for strict quorums: a sloppy quorum breaks it, and even strict quorums are not linearizable (see the consistency models chapter).
- The follow-up you will get: "a node is down for a day, then comes back: what happens?" Walk the three layers in order, and say the hint window has expired so Merkle repair and read repair do the work.
Before you move on
- Rebuild the capacity estimate: from 100,000 reads and 50,000 writes a second, get 350,000 replica operations and about 60 nodes, and say why storage did not set the size.
- Explain preference-list construction with virtual nodes and why it skips positions owned by the same machine.
- Compute the availability of W = 2 of 3 against W = 3 of 3 with 99.9% replicas, and say what the independence assumption hides.
- Walk the D1 to D5 version example and say what a timestamp would have lost.
- Trace the dry run through all three recovery paths and say which one handles a key nobody reads.
- Say why gossip-based failure detection is local, and what a phi threshold trades.
- Explain what the commit-log sync default risks, and what makes the risk acceptable.
Sources and further reading
- DeCandia et al., Dynamo: Amazon's Highly Available Key-value Store (SOSP 2007) — scope, preference lists, vector clocks and the D1 to D5 example, sloppy quorum and hinted handoff, Merkle trees, seeds, (3, 2, 2), partitioning strategies
- Cassandra architecture: Dynamo — tokens and virtual nodes, replication factor, consistency levels, gossip each second, phi accrual detection, local UP and DOWN status
- Cassandra: storage engine — commit log, memtable, SSTables, compaction, Bloom filter, index summary, periodic and batch commit-log sync
- Cassandra: hints — best-effort hinted handoff and the 3-hour default window
- Cassandra: read repair — data and digest reads, repair by timestamp, no repair at consistency level ONE
- Cassandra: repair — Merkle trees, incremental and full repair, repairing inside the grace period
- Cassandra: cassandra.yaml reference — num_tokens 16, phi_convict_threshold 8, max_hint_window 3h, commit-log defaults
- Riak: causal context — vector clocks, dotted version vectors, siblings and sibling explosion
Claims in this chapter were checked against these sources in October 2026, using the Cassandra 5.0 documentation. Figures marked "planning figure", "assumed" or "illustrative" (10,000 operations a second per node, a write amplification of 20, a Merkle tree of 65,536 leaves, 99.9% replica availability, the ring positions) are this chapter's own inputs, not measurements. The Dynamo paper was read as extracted text, so wording was matched approximately.
5 questions drawn from 7, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.
Design a key-value store for session and cart data
"Our product teams keep asking for one place to put per-user session and cart data, and they want it to keep taking writes when a machine or a zone is down. Design the key-value store behind it. get(key) returns the stored value, or several if the store holds conflicting ones, plus an opaque version token. put(key, value, token) writes a value, passing back the token from the read when there is one. Walk me through the design, and tell me what happens when a node is down for a day."
In scope: single-key reads and writes, sizing the cluster, and failure handling. Out of scope: multi-key transactions, secondary indexes, range scans, multi-region deployment and authentication.
Planning assumptions (figures for this exercise, not facts about any real service):
- 2 billion keys, average value 1 KB, no value larger than 1 MB.
- Every key is stored on N = 3 replicas, and the cluster spans 3 availability zones with the same number of nodes in each.
- Peak load is 60,000 reads and 30,000 writes per second.
- Each read is sent to 2 replicas (R = 2) and each write is sent to all 3 replicas.
- One node handles 10,000 replica operations per second, and you plan to run nodes at 60% of that.
- Each node has 2 TB of disk, filled to at most 50%.
- The store must keep accepting writes while one replica, or one whole zone, is unreachable.
Work it out on the whiteboard or on paper first. Nothing on this card is saved, and the checklist stays hidden until you ask for it.
Finished reading?
0 of 36 chapters read in this series