Skip to content

Key-value store

advancedCommon atAmazonGoogleMetaAnthropicStripeRead firstDistributed thinkingHash mapSorted containersVersioningPractise in the Sandbox →

A distributed key-value store is put(key, value) and get(key) over thousands of machines that never stop taking writes. Two layers make it: a storage engine per node that turns random writes into sequential ones (write-ahead log, sorted memtable, immutable SSTables merged by compaction, Bloom filters to skip files on reads), and a distribution layer that places each key on N replicas around a consistent-hash ring and lets each request choose R and W. With R + W > N reads overlap the latest acknowledged write; with smaller quorums you get lower latency and higher availability, and repair (read repair, hinted handoff, Merkle-tree anti-entropy) makes replicas converge. Writes are O(1) appends; reads are O(log n) per file checked.

Shopping carts, session state, user preferences, feature flags, message metadata: huge volumes of small records, always looked up by key, never joined. Amazon built Dynamo (2007) because its cart had to accept an “add to cart” even while servers and links were failing. Cassandra, Riak, ScyllaDB and DynamoDB’s ancestors followed.

The goals pull against each other:

Goal What it means Pulls toward
Always writable Accept writes during node failures and partitions Leaderless replication, sloppy quorums, conflicts resolved later
Read your data back A read returns the latest acknowledged write Larger quorums, a leader per key, consensus
Scale linearly Twice the nodes, twice the throughput and storage Partitioning with no central coordinator
Predictable latency p99 in single-digit ms Sequential I/O, no read-before-write, wait only for the fastest replicas

The CAP theorem puts a name on the first two: during a network partition you choose between availability and consistency. Dynamo-style stores choose availability and let each request tune how much consistency it wants.

In scope (typical) Ask about
Functional put, get, delete by key; values up to ~1 MB; optional TTL; per-request consistency level. Range scans by key prefix? Conditional writes (compare-and-set)? Secondary indexes?
Scale 100k writes/s, 300k reads/s, 50 TB logical data, growing. Read:write ratio? Record size distribution? Growth per year?
Latency p99 under 10 ms for reads and writes inside a region. Is cross-region replication needed, and synchronous or async?
Consistency Tunable per request: eventual by default, quorum when asked. Is read-your-writes required? Can two clients write the same key concurrently, and what should win?
Durability / availability No acknowledged write lost if one node dies; writes accepted with a node or rack down. How many simultaneous failures? Rack and zone awareness?
Out of scope Multi-key transactions, joins, a query language.

Storage multiplies by N. Disk bandwidth multiplies by N and by the engine’s write amplification: compaction rewrites each byte many times. Watch the per-node disk bandwidth row.

Back of the envelope
drag the assumptions
assumptions
so you need
raw storage with replicas150 TB
nodes (4 TB disks, 50% full for compaction headroom)75
replica writes/s (each write lands N times)300K/s
disk write bandwidth per node40 MB/scompaction rewrites each byte ~wa times; SSDs sustain a few hundred MB/s
replica reads/s per node (QUORUM)8K/sR = majority of N
50 TB × 3 replicas is 150 TB, so about 75 nodes with 4 TB disks kept half empty for compaction. 100k writes/s becomes 300k replica writes/s, and with 10× write amplification each node writes ~40 MB/s just to keep up. Push the record size or write rate up and the disks, not the CPUs, become the limit.

What to say out loud: “150 TB with replication, roughly 75 nodes. The interesting number is disk write bandwidth: every logical write lands on three nodes and compaction rewrites it about ten times, so I’ll pick an engine and compaction strategy for writes, and leave headroom on disk for compaction to run.”

pseudocode: the contract
put(key, value, context?, consistency = QUORUM) -> ok | timeout
get(key, consistency = QUORUM) -> [ (value, context) ] -- usually one; siblings on conflict
delete(key, context?) -> ok -- writes a tombstone
consistency ∈ { ONE, QUORUM, ALL, LOCAL_QUORUM } -- picks R or W for this request
Stored per replica, per key:
key bytes; hashed to a ring position (token)
value bytes, or TOMBSTONE
version write timestamp (µs) -- last-writer-wins
or a vector clock -- [(node, counter), ...]
ttl optional expiry

Two decisions hide in that sketch:

  • get returns a list. With vector clocks, two writes that didn’t see each other are both kept as siblings, and the client merges them (Dynamo’s cart unions the items). The context the client passes back on the next put says which versions it has seen. With last-writer-wins the list always has one element, and the losing write disappears silently.
  • Deletes are writes. You can’t just remove a key from one replica: another replica that missed the delete would “resurrect” it during repair. A delete writes a tombstone that must survive until every replica has seen it, then compaction drops it (Cassandra’s gc_grace_seconds, default 10 days).

Step through the flows. There’s no leader and no master: every node runs the same code, and any node can coordinate any request.

Key-value store: quorum writes, reads and repair
step through
flow
Send to all N replicas, acknowledge after W. No leader, so any node can coordinate.
hinthandoffClient (smart driver): clientClientsmart driverCoordinator (any node · ×N): workerCoordinatorany node · ×NReplica A (WAL + LSM): storeReplica AWAL + LSMReplica B (WAL + LSM): storeReplica BWAL + LSMReplica C (WAL + LSM): storeReplica CWAL + LSMNode D (next on ring): storeNode Dnext on ringRepair (Merkle anti-entropy): workerRepairMerkle anti-entropy
opput(cart:77, {...})
N, R, W3, 2, 2
The client sends the write to any node (or straight to a replica, if its driver knows the ring). That node becomes the coordinator for this request.
1 / 4
Component Role Why it’s there
Smart client / driver Knows the ring; sends a request straight to a replica when it can. Saves a hop; spreads coordination over the cluster.
Coordinator Any node. Finds the N replicas for the key, fans out, waits for R or W replies, resolves versions. Leaderless: no single node whose failure stops writes to a key.
Replicas Each runs a local storage engine: WAL + memtable + SSTables. Durable, write-optimised storage per node.
Gossip + failure detector Every second, each node swaps membership and ring state with a few peers; a phi-accrual detector marks nodes as suspect. Membership without a central registry.
Hinted handoff Stores writes for an unreachable replica and replays them on recovery. Short outages don’t need a full repair.
Anti-entropy repair Background Merkle-tree comparison per token range. Catches everything else: long outages, dropped hints, bit rot.

A node must absorb tens of thousands of writes per second on its disks. Updating a B-tree in place means a random read and a random write per update. An LSM tree (log-structured merge tree) never updates in place:

  1. Append to the write-ahead log (WAL, Cassandra’s commit log), so a crash loses nothing that was acknowledged.
  2. Insert into the memtable, a sorted in-memory structure (a skip list or balanced tree; see Sorted containers).
  3. Flush a full memtable as an immutable, sorted file: an SSTable (sorted string table), with a sparse index and a Bloom filter. Then truncate the WAL.
  4. Compact in the background: merge SSTables, keep the newest version of each key, drop overwritten values and expired tombstones.

Drive it by hand. Put a few keys, delete one, read a key that’s in several files, then turn automatic flushing off and read a key that doesn’t exist.

LSM tree: write path, flush, compaction and reads
sandbox
WAL
append-only
#7 put j#8 put h#9 put a
memtable
RAM, sorted · 3/4
a:v9h:v8j:v7
L0
ranges overlap
SST-1 [a..i]newest first
a:v3b:v4c:v5i:v6
L1
no overlaps
no tables
write amp: 1.44×WAL 9 · flush 4 · compaction 0space amp: 0.67×SSTables read by last get: –
foundtombstone / Bloom false positivechecked, absentjust written
put a = v9: append to WAL, insert into memtable
put h = v8: append to WAL, insert into memtable
put j = v7: append to WAL, insert into memtable
flush: memtable → SST-1 in L0 (4 entries, sequential write); WAL truncated
put i = v6: append to WAL, insert into memtable
put c = v5: append to WAL, insert into memtable
put b = v4: append to WAL, insert into memtable
Writes never update in place: they append to the WAL and land in the sorted memtable, so a write costs one sequential disk append. The price is paid later. Every entry is rewritten by flushes and compactions (write amplification), overwritten and deleted keys linger until compaction (space amplification), and a read may have to check several tables (read amplification). Bloom filters let reads skip most tables; turn automatic off, flush a few times without compacting, and run get on a key that isn't there.

The read path is the price of cheap writes. Newer data shadows older data, so a read checks from newest to oldest and stops at the first version it finds:

pseudocode: a point read in an LSM engine
get(key):
if key in memtable: return memtable[key] -- may be a tombstone: "not found"
for table in L0 tables, newest first: -- L0 files can overlap each other
if not table.bloom.might_contain(key): continue -- no disk I/O at all
if found(table, key): return it -- index lookup + one block read
for level in L1, L2, ...: -- deeper levels: non-overlapping ranges
table <- the one file whose key range covers key -- binary search over file ranges
if table.bloom.might_contain(key) and found(table, key): return it
return not found

A Bloom filter with about 10 bits per key and 7 hash functions has a false-positive rate near 1%, so a read for a missing key touches the disk about once per hundred files it would otherwise check. The two compaction families trade the three amplifications differently:

Size-tiered (STCS) Leveled (LCS)
How Merge a few SSTables of similar size into one bigger one Each level is ~10× the previous; a file is merged into the overlapping files of the next level
Write amplification Low (about one rewrite per tier, a handful in total) Higher (≈10× per level, 10-30× total)
Read amplification Higher: many overlapping files Low: about one file per level
Space amplification Up to 2× during a big merge ≈10%
Fits Write-heavy, append-mostly Read-heavy, many updates of the same keys

Cassandra also has time-window compaction (for TTL’d time series, where whole files expire together). B-trees (InnoDB, Postgres) are the other side of the trade: one copy of each key, good reads and range scans, more random writes.

Each key lives on N replicas. Each request picks how many must answer: W for a write, R for a read. If R + W > N, any read quorum shares at least one replica with any write quorum, so a read sees the latest acknowledged write. Tune it below, then take replicas down.

Tune N, R, W: staleness, latency and availability
interactive
every (R, W) for N = 3: click one
W3W2W1R1R2R3
Green: R + W > N, every read overlaps the last acknowledged write. Numbers: % chance a read just after the ack misses it.
example: write acked, then a read
A
v1
read
B
v2
C
v2
read
The read reached a replica with v2 and returns it.
write latency (W-th fastest)
p50
1.0 ms
p99
1.4 ms
read latency (R-th fastest)
p50
1.0 ms
p99
1.4 ms
R + W > N: overlapstale chance just after ack: 0.0%writes availablereads available
has the new write (v2)asked by the readreplica downstand-in holding a hint
The stale chance assumes the worst moment: right after the acknowledgement, before the other N − W replicas catch up (usually milliseconds later). Raising W or R buys overlap and costs latency, because you wait for the slowest replica in the quorum: compare p99 at W = 1 and W = N.
Setting (N = 3) Read sees latest ack? Survives 1 down? Latency Use
W = 3, R = 1 Yes Reads yes, writes no Writes wait for the slowest replica Read-heavy, rarely written
W = 2, R = 2 Yes Yes 2nd fastest replica Common default (Cassandra QUORUM)
W = 1, R = 1 Not guaranteed Yes Fastest replica Highest availability; stale reads for milliseconds
W = 1, R = 3 Yes Writes yes, reads no Reads wait for the slowest Rare

The coordinator’s job, as a building block:

pseudocode: coordinator for a quorum write and read
coordinate_write(key, value, W):
replicas <- preference_list(key, N) -- next N distinct nodes on the ring
version <- now_micros() or context.increment(me)
send (key, value, version) to every replica in parallel
for any replica that is unreachable: store a hint for it on a healthy node
wait until W acks or timeout -> ok | timeout -- a timeout is NOT "failed": some replicas may have it
coordinate_read(key, R):
ask R replicas (one full value, the rest a digest)
if digests all agree: return the value
fetch full values, newest <- resolve(versions) -- LWW: max timestamp; vector clocks: keep all concurrent
send newest to every replica that was behind -- read repair, in the background
return newest

Deep dive: partitioning, membership and conflicts

Section titled “Deep dive: partitioning, membership and conflicts”

Partitioning. Keys are hashed onto a ring, and each node owns positions (tokens) on it. With one token per node, adding a node takes load from just one neighbour and node sizes vary widely. Virtual nodes give each physical node many tokens (Cassandra 4.0 defaults to 16, earlier versions 256), so load spreads evenly and a new node takes a little from everyone. The replicas of a key are the next N distinct physical nodes clockwise, skipping other vnodes of the same machine and, with rack awareness, the same rack. The load balancing page has a ring to play with.

Membership. Nodes gossip ring state every second. A phi-accrual failure detector turns heartbeat gaps into a suspicion level instead of a yes/no, so a slow GC pause isn’t treated like a dead machine. Adding a node streams the token ranges it now owns from the current replicas; the ring change itself is a small piece of metadata.

Conflicts. Two clients write the same key during a partition. What survives?

Last-writer-wins (LWW) Vector clocks
Version One timestamp per write A list of (node, counter) pairs
Concurrent writes Highest timestamp wins; the other is silently lost Detected: neither descends from the other, both kept as siblings
Who merges Nobody The client, on the next read (or a CRDT does it automatically)
Clock dependence Yes: a node with a fast clock wins every race None (logical clocks)
Used by Cassandra (per cell), DynamoDB global tables Dynamo, Riak (dotted version vectors)

The comparison at the heart of vector clocks:

pseudocode: comparing two vector clocks
descends(a, b): -- a has seen everything b has
for each (node, count) in b: if a[node] < count: return false
return true
resolve(a, b):
if descends(a, b): keep a
else if descends(b, a): keep b
else: keep both as siblings -- concurrent; the application merges

Dynamo truncates a clock to about ten entries, dropping the oldest, which can rarely turn a true descendant into a false conflict. The versioning page covers version numbers and history more generally.

What breaks What you see Design response
A replica is down for minutes Writes still succeed at W; that replica falls behind Hinted handoff; read repair on hot keys
A replica is down for days Hints expire (Cassandra: 3 h window) Run anti-entropy repair; replace the node and stream its ranges
Node crash Unflushed memtable lost Replay the WAL on restart; with periodic fsync (Cassandra’s default: every 10 s) the other replicas cover the gap
Clock skew between nodes LWW drops the newer write NTP with alerts; client-supplied timestamps; vector clocks where loss matters
Deleted data reappears A replica missed the tombstone and repair copied the old value back Repair every node within gc_grace_seconds; never drop tombstones earlier
Compaction falls behind Read latency climbs; disk fills with SSTables Throttle writes, add nodes, keep ≥ 50% free disk, pick leveled for read-heavy tables
Hot partition One replica set at 100% while others idle Better key design (add a bucket suffix); cache in front; split the hot key
Large partitions / wide rows GC pauses, slow repairs Cap values and partition sizes; bucket by time
Operation Time Space / I/O
Write on one replica O(1) WAL append + O(log m) memtable insert One sequential append now; ≈ write-amp × bytes later through compaction
Point read on one replica O(log m) memtable + per checked SSTable O(log n) index search + one block read Bloom filters skip ≈99% of irrelevant files; leveled: ≈1 file per level
Bloom filter O(k) hashes per check ≈10 bits per key for ≈1% false positives
Quorum write / read latency of the W-th / R-th fastest of N replicas N copies of every byte
Locate replicas O(log T) binary search over T ring tokens ring of nodes × vnodes tokens, on every node
Merkle repair, d differing leaves of L O(d log L) hash comparisons one tree per token range per replica
Compaction O(bytes merged), sequential needs free disk ≈ size of the files being merged

A good 45-minute arc:

  1. Clarify (5 min). Access pattern, value sizes, scale, consistency needs, and above all the concurrent-write question.
  2. Single node (10 min). WAL, memtable, SSTables, compaction, Bloom filters. Say why it beats in-place updates for writes, and what reads pay.
  3. Distribute (10 min). Consistent hashing with vnodes, replicas as the next N physical nodes, a coordinator, N/R/W.
  4. Failures (10 min). Hinted handoff, read repair, Merkle anti-entropy, tombstones, gossip and failure detection.
  5. Conflicts and stretch (5-10 min). LWW vs vector clocks; multi-region (LOCAL_QUORUM per region, async between regions); compare-and-set needs consensus.

Follow-ups to expect: “How do you add a node without downtime?” It takes over some token ranges; stream those ranges from current replicas, then switch reads. “How would you support range scans?” Order-preserving partitioning (by key range, like Bigtable) instead of hashing, at the cost of hot spots on sequential keys. “What if you need strong consistency?” A consensus group per range (Raft), so each range has one leader that orders writes. “How do you test it?” Fault injection (kill nodes, partition the network, skew clocks) with a history checker such as Jepsen that looks for lost or stale reads.

Check yourself
pick one
N = 3, W = 2, R = 2. A write is acknowledged by replicas A and B. Immediately, a read asks B and C. What does it return?
Check yourself
pick one
A user deletes a key. Replica C is down during the delete and comes back two weeks later. Tombstones are purged after 10 days, and nobody ran repair. What happens on the next repair?
Check yourself
pick one
Your LSM-based store's write throughput is fine, but read p99 keeps climbing over the day and drops after compaction catches up. What's the most likely cause?

Concept pages to drill: Distributed thinking · Sorted containers · Hash map · Versioning · Snapshots · Load balancing · TTL and expiry