System Design Primer · llms.txt
Beyond the primer: design questions
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
The primer describes a key-value store as a hash table: put a key, get it back. This question asks you to build one that spans dozens of machines, keeps working when some of them die, and lets each caller choose between speed and consistency. It's a favourite because nearly every distributed-systems idea shows up in one design: partitioning, replication, quorums, conflict resolution, failure detection, repair and a storage engine.
The design here follows the Dynamo family: Amazon's 2007 Dynamo paper and the systems it inspired, such as Cassandra, Riak and Voldemort. They are leaderless, always writable, and offer tunable consistency. It isn't the only valid answer: a leader per partition, kept consistent with consensus, is just as legitimate, and the trade-offs section compares the two. Say early in the interview which one you're building and why.
Pin down scope first. A reasonable set:
Functional
put(key, value), get(key) and delete(key).Non-functional
Estimates (round, assumed numbers):
Size the cluster from both storage and throughput:
Settle on 24 nodes, 8 in each of 3 zones. Per node that's 600,000 / 24 = 25,000 reads/s, 180,000 / 24 = 7,500 writes/s and 30 TB / 24 = 1.25 TB of data. The lesson to say out loud is that storage set the floor and read throughput set the final number.
The client API is small. Real stores usually speak a binary protocol, but the HTTP form shows the shape:
GET /v1/kv/{key}?consistency=quorum
-> 200 { "versions": [ { "value": "...", "context": "opaque" } ] }
PUT /v1/kv/{key}?consistency=quorum
body: value bytes
header: If-Context: <context from the get this write is based on>
DELETE /v1/kv/{key}?consistency=quorum
header: If-Context: <context>
Two details matter.
get can return more than one version. If two clients wrote the same key concurrently and the system can't tell which is newer, it hands back both and lets the application merge them. This is how Dynamo and Riak behave. Stores that use last-writer-wins return only one.Each stored record holds:
| Field | Purpose |
|---|---|
| key | Up to 256 bytes |
| value | Opaque bytes, or empty for a delete |
| version | Vector clock, or a timestamp under last-writer-wins |
| tombstone flag | Marks a delete so it can replicate like a write |
| expires_at | Optional TTL |
Every node runs the same software and can play every role. There's no master. That's the big difference from the primer's master-slave replication.
The coordinator is often one of the three replicas itself, which removes a hop. Whether it is or not, it always sends to all N replicas and waits only for W of them.
Three numbers control consistency and availability:
If R + W > N, every read set overlaps every write set in at least one replica, so a read sees at least one copy of the latest successful write. The coordinator returns the newest version among the replies.
| N | W | R | Overlap | What you get |
|---|---|---|---|---|
| 3 | 1 | 1 | No | Fastest. Reads may be stale. Tolerates 2 replicas down. |
| 3 | 2 | 2 | Yes | The common default. Tolerates 1 replica down for both reads and writes. |
| 3 | 3 | 1 | Yes | Fast reads, but writes fail if any replica is down. |
| 3 | 1 | 3 | Yes | Fast writes, but reads fail if any replica is down. |
A nice side effect: with W = 2, write latency is the second-fastest of three replicas, so one slow node doesn't slow every write. Cassandra exposes these choices as consistency levels like ONE, QUORUM, ALL and LOCAL_QUORUM (a quorum within the local data centre).
Be careful not to oversell R + W > N. It doesn't make the store linearizable. A write that reached only one replica before failing isn't rolled back, so it may show up in later reads. Two concurrent writes still conflict. And sloppy quorums, covered below, break the overlap guarantee entirely. For true linearizability, you need consensus.
Because any replica can accept a write, two clients can update the same key at the same time through different coordinators. The store needs a rule for which version survives.
Last-writer-wins (LWW) attaches a timestamp to each write and keeps the highest. It's simple, and reads return one value. The cost is that a concurrent write is silently discarded, and clock skew between machines can make an older write win. Cassandra uses LWW, but per column rather than per row, so concurrent updates to different columns of the same row both survive. LWW is fine when writes overwrite whole values and losing a racing update is acceptable, as with caches, session data and "latest position" records.
Vector clocks record causality instead of wall-clock time. A version carries a list of (node, counter) pairs. Version X descends from version Y if every counter in Y is less than or equal to the matching counter in X. If neither descends from the other, they're concurrent, a real conflict.
Here's an example with a shared shopping list, key list:42. Conflicts don't need a network partition. A client working from a stale read is enough:
[X:1].[X:1], adds "eggs" and writes through coordinator Y, passing the context it read. The new version is [X:1, Y:1], which descends from [X:1], so the old version is dropped.[X:1], adds "bread" and writes through X, which gets [X:2].[X:2] with [X:1, Y:1]. The first has a higher X counter, the second has a Y counter the first lacks, so neither descends from the other. A later get returns both as siblings ("milk, bread" and "milk, eggs"), with a context that covers [X:2, Y:1].[X:2, Y:1, Z:1]. That descends from both siblings, so they're discarded.Under LWW, step 3 would have silently erased the eggs.
The cost moves to the application, which must know how to merge. Some merges are hard: a union-merge of a set can bring back items that one side deleted. Clocks also grow as more coordinators touch a key. Dynamo trimmed the oldest entries past a threshold, and Riak later moved to dotted version vectors to limit sibling growth.
There's no central registry deciding who's alive. Every second or so, each node picks one or a few random peers and exchanges its membership view: which nodes exist, which tokens they own, and a heartbeat counter for each. News spreads to the whole cluster in a number of rounds that grows with the logarithm of the cluster size.
Each node decides locally whether a peer is down. A peer is down if its heartbeat counter stops advancing, or, in Cassandra's phi accrual detector, if the gap since the last heartbeat is unlikely given the usual gaps. A temporary "down" just routes traffic around the node. Permanent removal from the ring is an operator action, so a five-minute reboot doesn't trigger terabytes of data movement.
Suppose replica B, in the preference list for a key, is down, and a client writes with W = 2. A strict quorum would still reach A and C, but in a bad partition the coordinator might reach only A.
Dynamo uses a sloppy quorum: the write goes to the first N healthy nodes on the ring. If B is down, the next node along, D, takes the write with a hint saying "this belongs to B". D stores hints separately and replays them to B when gossip says B is back. Writes stay available, which was Dynamo's goal.
The price is that the W nodes that took the write may not overlap the R nodes a later read contacts, so R + W > N no longer guarantees fresh reads. Practice varies here. Cassandra also stores hints (on the coordinator) but doesn't count them toward the consistency level, except at its weakest level, ANY. So Cassandra keeps strict quorums and treats hints purely as a repair aid. Hints also expire (Cassandra's default window is 3 hours), after which only full repair fixes a replica that was down longer.
Replicas still drift: a hint expired, a node lost a disk, a write reached only one replica. Two mechanisms fix this.
For scale: a token range with 1 million keys and a tree with 2¹⁵ = 32,768 leaves has about 30 keys per leaf (1,000,000 / 32,768 ≈ 30.5). If one key differs, the replicas follow a single path 15 levels down, comparing a few dozen hashes, then exchange about 30 keys instead of 1 million. The cost is building the trees, which means reading all the data. That's why full repair is scheduled and throttled.
Each node needs an engine that absorbs a steady stream of writes and still serves point reads fast. The log-structured merge tree (LSM) fits, and it's used by Cassandra, RocksDB, LevelDB and HBase, among others. The LSM idea dates to a 1996 paper by O'Neil and colleagues, and the memtable and SSTable terms come from Google's Bigtable paper.
fsync is a trade-off. Syncing every write is safest but slowest. Batching syncs every few milliseconds (group commit) is much faster but can lose the last few milliseconds on power loss. That's often acceptable because the other replicas have the write too.No write ever modifies data in place. That's why LSM engines are good at writes.
A Bloom filter is a bit array plus k hash functions. Adding a key sets k bits, and a lookup checks those k bits. It never gives false negatives, only occasional false positives. The false-positive rate is about (1 − e^(−kn/m))^k for n keys in m bits. At 10 bits per key with k = 7, that's kn/m = 0.7, so (1 − e^(−0.7))^7 ≈ (0.503)^7 ≈ 0.8%. So about 1 in 120 lookups for an absent key wastes a disk read.
The memory cost for our cluster: each node holds 5 × 10⁹ × 3 / 24 ≈ 625 million records. At 10 bits per key, that's 6.25 × 10⁹ bits ≈ 780 MB of RAM per node. That's affordable, and it's why every SSTable gets a filter.
SSTables pile up, and reads slow down as they check more files. Compaction merges SSTables, keeps the newest version of each key and writes new, larger files. Two common strategies:
| Size-tiered | Leveled | |
|---|---|---|
| How | Merge several similar-sized SSTables into one bigger one | Levels, each about 10× bigger than the last. Within a level, files don't overlap in key range. |
| Write cost | Lower: data is rewritten fewer times | Higher: data is rewritten as it moves down levels |
| Read cost | Higher: a key may be in many files | Lower: at most one file per level |
| Space | Can briefly need about 2× during big merges | Little overhead |
| Good for | Write-heavy workloads | Read-heavy or update-heavy workloads |
A delete can't just remove the key. A replica that missed the delete would later "repair" the old value back onto the others. So a delete writes a tombstone, which replicates like any write. Compaction drops tombstones only after a grace period that must be longer than your repair interval (Cassandra's default is 10 days). Run repair less often than that, and deleted data can come back.
counter:7#0 … counter:7#9, summed on read).With W = 1 and R = 1, the replica that took the write and the replica that answered the read can differ, since 1 + 1 isn't greater than 3. So a read can miss a write that succeeded. One fix is W = 2, R = 2, which guarantees overlap but adds latency to both paths and fails if two replicas are down. Another, if writes are rare, is W = 3 and R = 1: reads stay as fast as before, but every write waits for the slowest replica and fails whenever any replica is down.
Neither clock descends from the other: the first has a higher A counter, the second a higher B counter. So they're concurrent, and a vector-clock store keeps both as siblings and returns both on the next read for the application to merge. A last-writer-wins store would keep whichever had the later timestamp and silently drop the other, even if the "later" timestamp only came from a fast clock.
Hints covered only the first 3 hours of missed writes. After that, coordinators stopped storing hints for it, so the node is missing writes from the remaining 3 hours. Read repair will fix keys as they're read, but the reliable fix is an anti-entropy repair over the node's ranges. Merkle trees find the differing slices and stream only those. Until repair finishes, reads at R = 1 that hit this node can return stale data.
SSTables are piling up faster than compaction merges them, so each read checks more files, with more Bloom-filter checks, more false positives and more disk reads. The sharp drop is a large compaction finally merging them. Fixes include giving compaction more I/O, switching that table to leveled compaction if it's read-heavy, or checking whether tombstones are piling up and slowing reads.
With hash partitioning, keys sharing a prefix are scattered across the ring, so a prefix query would have to ask every node. One option is to partition by a prefix (the partition key) and keep keys sorted within the partition, as Cassandra does with partition and clustering keys. Then user:42:* lives together on one replica set and an SSTable range scan serves it. The cost is that a single huge or hot prefix becomes a hot partition, so you need limits on partition size.
Previous: Monitoring, logging and tracing · Next: Design a notification system