Design a Distributed Key-Value Store (Dynamo)
Design a horizontally scalable, highly available key-value store in the style of Amazon's Dynamo paper (and its descendants Cassandra and Riak). The design tour covers consistent hashing for placement, quorum reads/writes for tunable consistency, vector clocks for conflict detection, hinted handoff and merkle trees for repair, and gossip for membership.
1Requirements
Functional
- • get(key) returns the value (or, under conflict, a set of divergent versions for the client to reconcile).
- • put(key, value) writes with a version context; supports values up to ~1 MB.
- • delete(key) removes a key (implemented as a tombstone write).
- • Cluster scales incrementally: adding or removing a node moves only a small fraction of keys.
- • Per-operation tunable consistency: callers choose R and W per request or per table.
Non-functional
- • Always writable: the store accepts writes during node failures and network partitions (AP in CAP terms).
- • P99 latency budget in single-digit milliseconds for reads and writes at the coordinator.
- • No single point of failure; any node can coordinate any request.
- • Eventual consistency with bounded, observable convergence; conflicting versions are surfaced, never silently dropped.
- • Incremental scalability to hundreds of nodes and hundreds of TB with near-linear throughput.
2Back-of-envelope estimation
| Dataset | 100 TB logical, 300 TB raw | Replication factor N=3 triples the footprint before compression. |
| Cluster size | ~75 nodes | 300 TB / ~4 TB usable NVMe per node; also check per-node QPS fits. |
| Throughput | 500K reads/s, 100K writes/s | With N=3, R=W=2: each read touches ~2-3 replicas, each write ~3, so internal traffic is ~1.5M reads/s and ~300K writes/s across the fleet, ~20-25K ops/s per node. |
| Virtual nodes | ~200 per physical node, ~15K total | Smooths the hash ring so per-node load varies by a few percent instead of 2x, and spreads rebalancing across the whole cluster. |
| Gossip convergence | ~O(log N) rounds ≈ 7 rounds ≈ 7s | With 1s gossip intervals and 75 nodes, membership changes propagate cluster-wide in seconds. |
3API design
GET /v1/kv/{key}?r=2Coordinator reads from R of the N replicas, returns the highest version, or multiple siblings with their vector-clock contexts if versions are causally concurrent.
PUT /v1/kv/{key}?w=2 (body: value + context)Write with the version context from a prior read; the coordinator increments its vector-clock entry and returns success once W replicas ack.
DELETE /v1/kv/{key}?w=2Writes a tombstone through the same quorum path; tombstones are garbage-collected after a grace period longer than the maximum repair window.
GET /v1/admin/ringOperational endpoint exposing the current ring: token ranges, node ownership, and per-node health as seen via gossip.
4High-level design
Clients (or a thin smart-client library) send requests to any node. Every node knows the full ring via gossip, so a node receiving a request either coordinates it directly or forwards it to a natural coordinator. There is no master, no config service on the hot path, and therefore no single point of failure.
Placement uses consistent hashing: keys hash onto a ring of tokens, each physical node owns many virtual-node tokens, and a key's preference list is the next N distinct physical nodes clockwise from its hash. Virtual nodes make load and rebalancing granular; when a node joins it takes small slices from everyone rather than half of one neighbor's range.
A write goes to the coordinator, which stamps the vector clock, sends it to all N preference-list replicas, and acks the client after W responses. A read fans out to the preference list and returns after R responses; if the responses disagree, the coordinator returns the causally latest version, or all concurrent siblings, and performs read repair by writing the winner back to stale replicas.
Each replica persists writes to a local LSM-tree storage engine: append to a commit log, apply to an in-memory memtable, flush to immutable SSTables, and compact in the background. This makes the per-node write path sequential I/O, which is what sustains high write throughput on commodity disks.
Failures are handled in layers: sloppy quorum with hinted handoff keeps writes flowing when a replica is briefly down; anti-entropy with merkle trees repairs longer divergence; and gossip-based membership with failure detection tells everyone which layer applies. Together these implement 'always writable' without a coordinator database.
5Data model
item
key VARCHAR(1024) PK, value BLOB, vector_clock LIST<(node_id, counter)>, timestamp TIMESTAMP, tombstone BOOLEANThe vector clock travels with the item; the wall-clock timestamp is only a tiebreaker/GC aid, never the correctness mechanism.
ring_state (gossiped)
node_id UUID, tokens LIST<BIGINT>, status VARCHAR, heartbeat_generation BIGINT, version BIGINTEvery node holds a full copy, updated via gossip; versioned so newer state always wins a merge.
hint
target_node UUID, key VARCHAR, value BLOB, vector_clock LIST<(node_id, counter)>, stored_at TIMESTAMP, PK (target_node, key, stored_at)Writes accepted on behalf of a down replica; replayed to the target when gossip marks it alive, expired after a few hours to bound buildup.
6Deep dives
Consistent hashing and virtual nodes
Naive placement (hash(key) mod N) reshuffles almost every key when N changes, which at 100 TB means a cluster-wide data migration for every node added. Consistent hashing fixes this: both keys and nodes hash onto the same ring, each node owns the arc between its token and its predecessor's, and adding a node moves only the keys in the slice it takes over, about 1/N of the data.
Raw consistent hashing has two problems: random token placement gives some nodes arcs several times larger than others, and when a node dies its entire load lands on exactly one successor. Virtual nodes solve both: each physical node owns 100-300 tokens scattered around the ring, so ownership variance drops to a few percent, a dead node's load spreads across many successors, and heterogeneous hardware is handled by assigning proportionally more vnodes to bigger machines.
Replication composes naturally: the preference list for a key is the first N distinct physical nodes walking clockwise (skipping vnodes that map to an already-chosen machine, and ideally skipping same-rack nodes for fault-domain diversity). Every node can compute any key's preference list locally from gossiped ring state, which is what lets any node coordinate any request.
Quorums: R + W > N and what it actually buys
With N replicas, requiring W write acks and R read responses gives overlap when R + W > N: at least one replica in any read quorum saw the latest committed write. Typical setting N=3, R=W=2 tolerates one down replica for both reads and writes while keeping read-your-write-ish behavior. R=1, W=1 maximizes availability and speed at the cost of stale reads; W=N gives strong write durability but any single replica failure blocks writes.
It is worth saying in an interview that R + W > N is weaker than linearizability. The overlap guarantees the read quorum contains the newest version, but concurrent writes to different coordinators still produce siblings, and a failed write that reached one replica can 'leak' into future reads. This is why Dynamo pairs quorums with versioning (vector clocks) rather than pretending quorums alone give strong consistency.
Dynamo further uses sloppy quorums: if a preference-list node is unreachable, the coordinator uses the next healthy node on the ring as a stand-in, so W acks are still achievable during failures. That preserves availability but explicitly weakens the overlap guarantee (the stand-in is not in the read set), which is exactly the availability-over-consistency trade the system advertises. Strict-quorum systems like Cassandra with QUORUM consistency make the opposite call per-query.
Vector clocks and conflict resolution
Wall-clock last-write-wins silently loses data whenever clocks skew or writes race. Vector clocks fix detection: each item carries a list of (coordinator, counter) pairs, and a coordinator handling a write increments its own counter. Version A is an ancestor of B if every counter in A is <= the corresponding counter in B; then B simply supersedes A. If each has a counter the other lacks, the versions are causally concurrent: a true conflict.
On conflict, Dynamo's choice is to keep both siblings and return them on read, pushing semantic resolution to the application, the canonical example being merging two divergent shopping carts by unioning items (an add is never lost; a concurrent delete may resurrect, which Amazon deemed acceptable). The client then writes back the merged value with a context descending from both siblings, collapsing the branches.
The costs are real: clocks grow with the number of distinct coordinators (pruned by keeping the most recent ~10 entries, which can rarely cause false concurrency), and every read-modify-write must round-trip the context. This is why later systems diverged: Cassandra dropped vector clocks for per-cell timestamps plus LWW (simpler, lossy), while CRDTs formalize the merge so it is automatic and provably convergent. Knowing this trade-space is the senior-level answer.
Failure handling: hinted handoff, merkle trees, gossip
Hinted handoff covers short outages. When replica C is down during a write, the coordinator writes to a stand-in node D with a hint 'this belongs to C'. D stores hints separately and replays them when gossip reports C alive. Writes stay available and C converges quickly, but hints are best-effort: if D also dies, or the outage outlasts hint TTL, the write survives only on the other replicas, which is why a deeper repair layer is required.
Anti-entropy with merkle trees covers long divergence. Each node maintains, per owned key range, a hash tree whose leaves cover buckets of keys. Two replicas compare roots; identical roots mean the range is in sync at the cost of one hash exchange, and differing roots are chased down the tree in O(log n) exchanges to find exactly the divergent buckets, which are then synchronized. This makes full-replica repair proportional to the amount of divergence, not the amount of data.
Gossip ties it together. Every second, each node exchanges versioned membership state (heartbeat generations, node statuses, token ownership) with a few random peers; information spreads epidemically in O(log N) rounds. Failure detection is local and probabilistic (e.g., phi-accrual on heartbeat arrival intervals) and drives only routing decisions, never data deletion: a node marked down gets hints, and permanent removal is an explicit operator action. Interviewers probe this exact point: temporary failure handling (hints) and permanent membership change (rebalance) must be distinct mechanisms.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Python + FastAPI nodes talking plain HTTP to each other, SQLite per node for storage, 3 processes via docker-compose on one laptop.
- 01Write the consistent-hash Ring class with ~100 vnodes per node; unit test that removing a node remaps only about 1/N of 10K sample keys.
- 02Build a FastAPI node exposing internal GET/PUT /local/{key} backed by a SQLite table (key, value, version).
- 03Add coordinator logic: any node computes the preference list for a key and fans the PUT to N=3 replicas, acking the client after W=2 responses.
- 04Implement quorum GET: read from R=2 replicas, return the highest version, and write it back to any stale replica (read repair).
- 05Bring up 3 nodes with docker-compose, kill one, and verify reads and writes still succeed at R=W=2.
- 06Add hinted handoff: when a replica is down, write to the next healthy ring node with a hint row, and replay hints on a background timer.
- 07Add GET /admin/ring showing token ownership and a smoke script that writes 10K keys and checks the distribution is roughly even.
Consistent-hash ring with virtual nodes
pythonimport hashlib
from bisect import bisect_right
class Ring:
def __init__(self, nodes, vnodes=100):
self.tokens = [] # sorted (hash, node) pairs
for node in nodes:
for i in range(vnodes):
self.tokens.append((self._hash(node + ":" + str(i)), node))
self.tokens.sort()
@staticmethod
def _hash(s):
return int(hashlib.md5(s.encode()).hexdigest(), 16)
def preference_list(self, key, n=3):
idx = bisect_right(self.tokens, (self._hash(key), chr(0)))
picked = []
for i in range(len(self.tokens)):
node = self.tokens[(idx + i) % len(self.tokens)][1]
if node not in picked: # skip vnodes of already-chosen hosts
picked.append(node)
if len(picked) == n:
break
return pickedQuorum read with read repair
pythonasync def quorum_get(ring, key, r=2, n=3):
replies = []
for node in ring.preference_list(key, n):
try:
# each reply: {"value": ..., "version": int}
replies.append((node, await http_get(node, key)))
except Exception:
continue # dead replica, try the next one
if len(replies) >= r:
break
if len(replies) < r:
raise QuorumError("read quorum failed: " + str(len(replies)))
newest = max(replies, key=lambda p: p[1]["version"])[1]
for node, reply in replies:
if reply["version"] < newest["version"]:
# read repair: push the winner back to stale replicas
await http_put(node, key, newest)
return newest
async def quorum_put(ring, key, value, version, w=2, n=3):
item = {"value": value, "version": version + 1}
acks = 0
for node in ring.preference_list(key, n):
try:
await http_put(node, key, item)
acks += 1
except Exception:
continue
if acks < w:
raise QuorumError("write quorum failed")
return itemBottlenecks & failure modes
- ⚠Hot keys concentrate on one preference list regardless of ring quality; mitigations are request-level caching in front, key salting/splitting, or read replicas for the hot range.
- ⚠Sloppy quorum under partition can accept writes on stand-ins that a strict read quorum never sees until handoff completes; consistency-sensitive callers must use strict quorum settings.
- ⚠LSM compaction competes with foreground I/O; unthrottled compaction causes read latency spikes, throttled compaction risks unbounded SSTable buildup.
- ⚠Tombstone accumulation: deletes are writes, and ranges with heavy delete churn slow reads until GC grace expires and compaction purges them.
- ⚠Merkle-tree rebuilds and full repairs are I/O-heavy; running repair on many ranges at once can saturate disks, so repairs are scheduled and rate-limited.
Key takeaways
- ▸Consistent hashing with virtual nodes gives incremental scalability: node changes move ~1/N of data, spread across the fleet.
- ▸R + W > N is a tunable overlap knob, not linearizability; it must be paired with versioning to handle concurrent writes honestly.
- ▸Vector clocks detect conflicts instead of hiding them; someone (app, CRDT, or LWW policy) must own the merge, and that choice defines the store's semantics.
- ▸Layer the failure handling: sloppy quorum + hinted handoff for seconds-to-hours outages, merkle-tree anti-entropy for deep repair, gossip for membership truth.
- ▸Every design choice here spends consistency to buy availability; be able to say precisely where (sloppy quorum, async repair, LWW pruning) that spend happens.