Design a distributed key-value store
Open in the interactive app: practice, quiz, mock interview →In one minute
- Partition with consistent hashing and many virtual nodes. When a node joins or leaves, only about 1/N of the keys move, and they come from many peers at once. No single node is stuck streaming everything.
- Replicate each key to N nodes and make R and W tunable per request. Choosing
R + W > Nmakes read and write quorums overlap.W = 1gives fast, highly available writes. That tunability is the product. - Version every write. Replicas will diverge during failures, so the store needs a rule for "which value is newer" and for "these two writes were concurrent". Last-writer-wins and vector clocks are the two honest answers. Each loses something.
- Repair continuously. Read repair, hinted handoff and Merkle-tree anti-entropy are what make "eventually consistent" actually converge.
- Each node runs an LSM storage engine. Writes become sequential appends to a write-ahead log and a memtable. Bloom filters keep reads from touching every SSTable.
Requirements
Functional
get(key),put(key, value)anddelete(key). Keys are up to 256 bytes and values up to 1 MB, typically around 1 KB.- Consistency per request:
ONE,QUORUMorALL, for reads and for writes. - An optional TTL per key.
Non-functional
- p99 under 10 ms for single-key operations within a region.
- Writes stay available while any W replicas of the key are reachable, including during node failures and while membership is changing.
- Add capacity without downtime. Durable once acknowledged, with 3 replicas spread across availability zones.
Out of scope: multi-key transactions, secondary indexes, range scans and SQL.
Back-of-envelope
- Traffic: 1M reads/s and 200k writes/s at peak. The averages are 400k and 80k.
- Data: 10B keys × about 1 KB = 10 TB raw today. At 300M new keys a day, growth is 300 GB/day, which over 5 years is ≈ 550 TB raw on top of the 10 TB. With N=3 that is about 1.7 PB of replicated data, before compression and before the space amplification compaction causes.
- Replica work: most coordinators send each read to all N replicas and wait for R, so replica reads ≈ 1M × 3 = 3M/s. Replica writes = 200k × 3 = 600k/s.
- Node count by throughput: if a node with NVMe sustains about 50k mixed operations per second, 3.6M / 50k ≈ 72 nodes. Add 30% headroom for compaction and failures and you get about 100.
- Node count by storage today: 30 TB replicated / 100 nodes = 300 GB each. Throughput sets the size of the cluster now. By year 3, storage does, at around 4 TB per node with each disk kept about half full.
- Network: 3.6M × about 1 KB ≈ 3.6 GB/s inside the cluster, about 36 MB/s per node. Rebuild streaming is what saturates links, so throttle it.
API
service KV {
rpc Get(GetRequest) returns (GetResponse);
rpc Put(PutRequest) returns (PutResponse);
rpc Delete(DeleteRequest) returns (PutResponse);
}
message GetRequest { bytes key = 1; Consistency read = 2; } // ONE | QUORUM | ALL
message GetResponse { repeated Versioned values = 1; bytes context = 2; } // >1 value = siblings
message PutRequest { bytes key = 1; bytes value = 2; bytes context = 3;
Consistency write = 4; uint32 ttl_s = 5; }
message PutResponse { bytes context = 1; }
message Versioned { bytes value = 1; bytes version = 2; bool tombstone = 3; }context is an opaque version that the client got from a read. When the client passes it back on a put, it says "this write replaces what I read". That is how the store tells an overwrite apart from a concurrent write.
Data model
- Partition key: the key itself. The ring position is
hash(key)with a 64- or 128-bit hash (Murmur3, xxHash). Data is spread evenly no matter how keys are named. - Ring metadata: a sorted list of
(token, node)with 16 to 256 virtual nodes per physical node. Cassandra 4.0 and later default to 16 tokens per node, placed by an allocator that balances load for replication factor 3 (Cassandra config). Each node gossips its tokens, status and load. Every node and every smart client holds the full map, which is 1,600 to 25,600 tokens for 100 nodes and at most a few hundred KB. - Record on disk:
key | version | flags (tombstone) | expires_at | value. Records are sorted by key inside each SSTable, and the version decides which record wins during compaction. - Per node: - a WAL file (append-only) - a memtable, which is a skiplist or balanced tree - immutable SSTables, each with a block index and a Bloom filter - a hinted-handoff queue keyed by target node - one Merkle tree per token range for anti-entropy
High-level design
flowchart LR CL["Client (ring-aware)"] -->|"put k"| CO["Coordinator node"] CO -->|"replicate"| A["Replica A"] CO -->|"replicate"| B["Replica B"] CO -.->|"down: hint stored"| H["Hint queue"] H -.->|"on recovery"| C["Replica C"] A --> W1["WAL + memtable"] W1 --> S1["SSTables + Bloom"] A <-->|"gossip, Merkle sync"| B B <-->|"gossip, Merkle sync"| C
Write path.
- The client hashes the key and sends the request to a coordinator, usually the first healthy node in the key's preference list.
- The coordinator stamps a version and sends
Putto all N replicas. - Each replica appends to its WAL, which group commits and then fsyncs. It inserts into the memtable and acknowledges.
- After W acknowledgements, the coordinator returns success.
- If a replica is down, the coordinator writes to the next healthy node on the ring instead (a sloppy quorum). That node stores a hint naming the intended owner and hands the data back when the owner recovers.
Read path.
- The coordinator asks all N replicas for the key. It can ask for a full value from one replica and digests from the others, to save bandwidth.
- It waits for R responses.
- It returns the newest version, or several siblings if versions are concurrent.
- Stale replicas get the newest version asynchronously. This is read repair.
On each replica, a read checks the memtable first, then SSTables from newest to oldest. A Bloom filter check skips the SSTables that cannot contain the key.
Deep dives
1. Partitioning and rebalancing
Hash mod N moves almost every key when N changes, which is unworkable.
Consistent hashing with one token per node moves only the keys that neighbour the changed node. But load is uneven, with arcs differing by several times, and a new node takes load from only one neighbour.
Virtual nodes give each node many tokens. Its share of the ring is then the sum of many small arcs. With purely random tokens you need about 256 per node for that to average out, which was Cassandra's old default; a token allocator that picks positions to balance load gets there with 16, and Cassandra's docs warn that more tokens raise the chance that a double failure causes an outage (Cassandra docs). A joining node pulls small ranges from many peers in parallel. Heterogeneous hardware is handled by giving bigger machines more tokens.
Fixed partitions split the hash space into, say, 4,096 ranges up front and assign ranges to nodes through a metadata service. This makes moving data a clean "move partition p" operation. Dynamo itself ended up here: its paper compares three strategies and moved production to equal-sized partitions with a fixed number per node, because random tokens made bootstrapping and archiving slow (Dynamo paper).
I chose virtual nodes for a Dynamo-style peer system, and I'd switch to fixed partitions if we already ran a consensus-backed metadata store. Both beat one token per node. The cost of vnodes is that a node participates in many replica groups, which widens the set of keys affected when any two nodes fail together.
2. Quorums, sloppy quorums and repair
With N=3, the common settings are below. Dynamo's most common production setting was (N, R, W) = (3, 2, 2) (Dynamo paper).
W=2, R=2: overlapping quorums, which tolerate one replica down for both reads and writesW=1, R=3: fast writesW=3, R=1: fast reads, but writes block when any replica is down
What overlap does and does not give you. R + W > N guarantees that a read sees at least one replica holding the latest acknowledged write. It is still not linearizable:
- a write in flight can be visible to one reader and not the next
- sloppy quorums write to stand-in nodes outside the preference list, which breaks the overlap until the hints are delivered
Convergence takes three mechanisms:
- Read repair fixes hot keys cheaply.
- Hinted handoff covers short outages, minutes to hours.
- Anti-entropy covers the rest. Each replica keeps a Merkle tree over each token range. Two replicas compare roots, then walk down only the subtrees that differ, so data that is already in sync costs a few KB of hashes to verify, not a full scan.
Choice: QUORUM/QUORUM by default, hinted handoff on, and anti-entropy running continuously with a throttle. Know the alternative too: Amazon's managed successor, DynamoDB, dropped leaderless quorums. Each partition is a Multi-Paxos group whose leader holds a lease and takes all writes and strongly consistent reads, and its replicas store data in a B-tree rather than an LSM tree (DynamoDB paper).
3. Versioning and conflicts
Last-writer-wins on timestamps is simple and gives one value per key. But clock skew silently discards writes: a node 50 ms fast beats a later write from a correct node. Hybrid logical clocks reduce the damage but cannot remove it, because two genuinely concurrent writes still have to lose one.
Vector clocks (or dotted version vectors) record which coordinator has seen which counter. If one version dominates the other, it wins. If neither does, the store keeps both as siblings and returns both on the next read, and the client merges them and writes back with the context. Nothing is lost. The costs are client complexity and version metadata that grows with the number of coordinators. Dynamo dropped the oldest entry once a clock reached a threshold of about 10 pairs. Conflicts are rarer than this machinery suggests: over 24 hours, 99.94% of requests from Amazon's shopping cart service saw exactly one version (Dynamo paper).
CRDT values merge automatically, but only for counters, sets and maps.
Choice: LWW with hybrid timestamps by default, since most keys are cache-like or single-writer. Siblings with dotted version vectors are an opt-in per table for data where losing a write is a bug, such as shopping carts.
Deletes write tombstones that must outlive the longest possible outage plus the repair interval, typically about 10 days (Cassandra's gc_grace_seconds default of 864,000 seconds, per the CQL docs). If a tombstone is purged too early, a replica that missed the delete can bring the value back.
Variant: distributed cache
A cache in front of a database inverts most of the choices above, because the cache is not the source of truth. The requirement that drives everything is the hit rate: at 95% the database sees 5% of reads, at 80% it sees four times that, which is often an outage (AWS Builders' Library). Targets shift accordingly, to sub-millisecond p99 reads, millions of reads per second, and values of 100 B to 10 KB, with durability optional and a restart allowed to lose data.
- No quorums, no repair. There is one owner per key and no replicas to reconcile. Losing a node means its keys miss and fall through to the database. Rehashing them onto survivors evicts healthy data, so a small gutter pool with short TTLs can absorb failed-node traffic instead.
- Routing is a deployment choice. A sidecar or shared proxy (such as mcrouter) keeps consistent hashing out of every client library and lets the cache team move pools without application deploys.
- Staleness replaces conflicts. The enemy is not concurrent writers but a slow reader setting an old value after a writer's delete. Leases, which reject that late set, plus invalidations derived from the database commit log, with TTLs as a backstop, close it. Version vectors do not apply.
- Stampedes. When a popular key expires, every request misses together. Layer in-process coalescing, leases so one caller loads, stale-while-revalidate, and probabilistic early refresh (Vattani et al., VLDB 2015). Cache "not found" results briefly too.
- Hot keys. Hashing cannot split one key across machines. Detect it in the proxy, then serve it from a short-lived in-process copy or replicate it under several suffixed keys, remembering that a write must delete every copy.
- Eviction matters. Memory is bounded, so a policy such as LRU decides what survives. Expired items count as well as evicted ones, since TTLs often limit the working set more than memory does.
Name which of the two systems you are building in the first minute of an interview. A cache that quietly gains replicas and quorums is a slow database, and a store that quietly gains eviction loses data.
Down the stack
Layer 1The contract
- A put acknowledged at
Wis in the WAL of at least W replicas, or of stand-in nodes holding hints for them. - A get at
Rreturns every version that is not dominated by another, among the R replicas that answered. - With
R + W > N, no sloppy substitutions and no concurrent writes, a get observes every write acknowledged before it began. - Without new writes, all N replicas converge within the anti-entropy period, for example 24 hours, and usually within seconds through read repair and hints.
- Latency: p99 under 10 ms at QUORUM within one availability zone set.
Layer 2The mechanism
Messages. Coordinators send MutationRequest{key, value, version, ttl} and replicas answer MutationAck. Reads send ReadRequest{key, digest_only} and get back either a full value or a hash.
Failure detection. Gossip runs every second. Each node exchanges heartbeat generations and versions with a few peers. A phi-accrual failure detector turns heartbeat arrival gaps into a suspicion level instead of making a binary timeout decision. Cassandra works this way (Cassandra docs).
Storage engine. Writes append to a WAL segment (Cassandra's commit log uses 32 MiB segments by default, per the Cassandra config) and go into a memtable. When the memtable fills (say 64 to 256 MB), it is flushed as an SSTable, which holds sorted data blocks of about 4 to 64 KB, a sparse index, a Bloom filter at about 10 bits per key (roughly 1% false positives, per the RocksDB wiki) and a footer. Compaction merges SSTables, drops overwritten versions and expired tombstones, and keeps the number of files a read must check small.
Layer 3The machine
- Write cost. The WAL append is a sequential write to the page cache followed by
fsync. On NVMe with power-loss protection,fsynctakes about 20 to 100 µs, and group commit shares one flush among many writes. A replica write therefore costs microseconds of device time, and the network round trip (about 200 µs within an AZ, 1 to 2 ms across AZs) dominates the latency of a W=2 write. - Read cost. A read that misses the page cache costs one random 4 KB NVMe read, about 80 to 100 µs. Bloom filters and block indexes held in RAM keep that to about one device read per lookup. On spinning disks the same read would take about 5 to 10 ms, which is why this design assumes SSDs.
- Compaction. It rewrites data many times. Write amplification for leveled compaction is often above 10× (RocksDB wiki) and 10 to 30× is a fair planning range, so a node absorbing 6k writes/s × 1 KB = 6 MB/s of user data may write 60 to 180 MB/s to disk. That is why compaction is throttled, and why SSD wear is a cost to plan for.
- Memory. In JVM-based stores a 200 ms GC pause makes a node look dead to gossip, one reason ScyllaDB rebuilt Cassandra's design in C++.
Why it's built this way
- Machines and disks fail daily at 100-node scale, so every key needs N replicas on independent failure domains.
- Synchronous agreement among all replicas would block writes during failures. Quorums let a majority, or W, proceed instead.
- Proceeding without everyone means replicas diverge. That makes versions and repair mechanisms mandatory, not optional.
- Random writes are slow on every device, and LSM trees turn writes into sequential appends. Their cost on reads is paid back with Bloom filters and compaction.
- Moving data costs network and disk time, so consistent hashing with vnodes keeps movement proportional to the change and spreads it across peers.
Failure modes
- Load at 10×. Compaction falls behind, the number of SSTables per read grows, and read latency climbs. Mitigations: - throttle writes using a backpressure signal from pending compactions - add nodes early, because bootstrapping a new node adds streaming load before it adds capacity
- Hot key. One key's three replicas saturate while 97 nodes sit idle. Mitigations: - a cache in front of the store, or replica selection that spreads reads across all N - for write-hot keys, split the key in the application, for example
counter#0..15 - Losing an availability zone. With replicas placed across 3 AZs, QUORUM still has 2 of 3 replicas and keeps working. Hints for the missing third pile up, so cap their age at about 3 hours, Cassandra's
max_hint_windowdefault (Cassandra config), and rely on anti-entropy after that. - Losing a region in a multi-region deployment. Writes acknowledged locally but not yet replicated asynchronously are at risk. Use LOCAL_QUORUM for latency and accept the RPO, or use EACH_QUORUM for critical tables and pay the cross-region round trip.
- Network partition with a sloppy quorum. Both sides accept writes to the same key, which produces concurrent versions. LWW silently drops one of them, while vector clocks surface siblings. Know which behaviour each table has.
In the wild
- Monzo, 2019: new Cassandra nodes answered "not found". Monzo added 6 nodes to a 21-node cluster with
auto_bootstrap=false, expecting them to stay idle until data was streamed in. They took ownership of token ranges at once and answered quorum reads with empty results, so balances looked wrong and payments failed for about 2 hours; full reconciliation took until 23:00. Changes: corrected settings, scale-up runbooks and alerts on spikes in "row not found" (Monzo). - Discord, 2017: millions of tombstones behind one message. A user deleted millions of messages, leaving one. Reading that channel scanned millions of tombstones and caused 10-second stop-the-world GC pauses in Cassandra. Discord cut tombstone lifetime from 10 days to 2, which its nightly repairs made safe, and taught the read path to skip empty buckets (Discord).
- Jepsen, 2013: last-write-wins in Riak. With siblings disabled, timestamp resolution lost 1,815 of 1,985 acknowledged writes during a partition, and stricter quorums did not help. With siblings enabled and merged by set union, nothing was lost, which is the case for opt-in siblings on multi-writer data (Jepsen).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Leaderless quorum (Dynamo) | Writes available during failures, no failover | Conflicts, repair machinery, not linearizable | Carts, sessions, profiles, high write availability |
| Leader per partition (Raft) | Linearizable, no conflicts | Election pause, writes need a majority | Metadata, money, locks |
| LWW timestamps | Simple, one value | Skew silently loses writes | Cache-like or single-writer data |
| Vector clocks / siblings | No lost writes | Client merge logic, metadata growth | Multi-writer data where loss is a bug |
| vnodes on a ring | Even load, parallel rebuild | More replica sets exposed to double failures | Peer-to-peer membership |
| Fixed partitions + metadata service | Clean moves, simple reasoning | Needs a consistent metadata store | You already run etcd or ZooKeeper |
| LSM engine | Fast writes, compression | Read and space amplification, compaction I/O | Write-heavy workloads |
Leadership lens
- Build first: a single-node LSM engine with WAL and crash-recovery tests. Then a static ring with N-way replication and quorums. Then gossip membership, hints and anti-entropy, in that order. Correctness tests come before scale: Jepsen-style partition tests and a linearizability checker for the modes that claim it.
- Ownership: the storage engine, the cluster layer (membership and replication) and the client library are three teams or three clear owners. Operations tooling for bootstrapping, decommissioning and repair needs an owner, or it rots.
- Cost drivers: SSD capacity × N × space amplification, cross-AZ traffic charges on every replicated write (often the surprise line on the bill), and compaction CPU.
- Rollout: dual-write from the old store, compare reads in the background, then move tables one at a time. Default to QUORUM and require a design review before a table uses ONE.
Check yourself
A deployment uses N=3 with
R=2, W=2, soR + W > N. Why is a read still not guaranteed to be linearizable?Show answer
Overlap only guarantees that a read sees at least one replica holding the latest acknowledged write. A write still in flight can be visible to one reader and not the next, and sloppy quorums write to stand-in nodes outside the preference list, which breaks the overlap until hints are delivered.
When would you pick vector clocks with siblings over last-writer-wins, and what does each choice cost?
Show answer
LWW is simple and returns one value, but clock skew silently discards writes, so it suits cache-like or single-writer data. Siblings with dotted version vectors lose nothing, which matters for multi-writer data like shopping carts, at the cost of client merge logic and growing version metadata.
Why does the lesson estimate about 100 nodes today, and why does storage rather than throughput size the cluster by year 3?
Show answer
Replica work is about 3M reads/s plus 600k writes/s, and at roughly 50k operations per node that is about 72 nodes, or about 100 with 30% headroom. Those 100 nodes hold only about 300 GB each today, but with 300 GB/day of new raw data replicated three times, storage reaches around 4 TB per node by year 3 and becomes the binding constraint.
What goes wrong if tombstones are purged too early, and how long should they be kept?
Show answer
A replica that missed the delete still holds the old value, and once the tombstone is gone repair can bring the deleted value back. Tombstones must outlive the longest possible outage plus the repair interval, typically about 10 days, as with Cassandra's
gc_grace_secondsdefault.
Sources and further study
Primary
- Cassandra: A Decentralized Structured Storage System (Lakshman and Malik, LADIS 2009): Dynamo's partitioning and replication combined with a Bigtable-style storage engine.
- The Log-Structured Merge-Tree (O'Neil et al., 1996): the original LSM paper behind every node's storage engine.
- Optimal Probabilistic Cache Stampede Prevention (VLDB 2015): the XFetch early-refresh rule behind the cache variant.
Systems docs
- cassandra.yaml reference (Apache Cassandra): real defaults for tokens, hint windows and commit log segments.
- Leveled compaction (RocksDB wiki): how levels, write amplification and compaction priority work in practice.
- mcrouter wiki: the routing proxy behind Facebook's cache pools, with consistent hashing, replication and failover routes.
Books
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, Ch 5 "Replication" (Leaderless Replication) and Ch 6 "Partitioning" (Rebalancing Partitions).
- Database Internals, Alex Petrov, Ch 7 "Log-Structured Storage" and Ch 12 "Anti-Entropy and Dissemination".
- System Design Interview, Volume 1, Alex Xu, Ch 6 "Design A Key-value Store".
Other curricula
- System Design Primer (donnemartin): frames key-value stores as the NoSQL baseline and walks through consistency and availability patterns.
- Sharding pattern (Azure Architecture Center): compares lookup, range and hash sharding strategies and the rebalancing cost of each.
- Azure Architecture Center: Cache-Aside pattern: the application-side loading pattern used when this store is run as a cache.
Watch
- CSE138 L14: Dynamo, Merkle trees, quorum consistency (Lindsey Kuper): a university lecture on the Dynamo design and its repair mechanisms.
- USENIX ATC '22: Amazon DynamoDB (USENIX): how the managed successor moved to per-partition leaders and why.
- Distributed Systems 5.1: Replication (Martin Kleppmann): quorums, read repair and the consistency problems they create.
Free resources
- Dynamo: Amazon's Highly Available Key-value Store (SOSP 2007): the original paper on vnodes, sloppy quorums, vector clocks and Merkle trees.
- Amazon DynamoDB: A Scalable, Predictably Performant, and Fully Managed NoSQL Database Service (USENIX ATC 2022): what changed after a decade of operating at scale.
- Dynamo architecture (Apache Cassandra docs): how a production system implements tokens, gossip, hints and tunable consistency.
- Causal context (Riak KV docs): vector clocks, dotted version vectors and siblings explained with examples.
- Eventually Consistent (Werner Vogels): the N/R/W framing and client-side consistency models.