Design a Key-Value Store
Partitioning onto a ring, quorum reads and writes, conflict resolution with vector clocks, and the LSM tree underneath.
put(key, context, value); get(key) -> { versions: [value, ...siblings], context }A key-value store maps opaque keys to opaque values through get and put, and offers nothing else. Giving up range scans, joins and secondary indexes is precisely what buys the ability to spread it over hundreds of machines with no coordinator. Amazon's 2007 Dynamo paper is the design most such stores still follow.
Partitioning and replication
Keys hash onto a ring. Each physical node claims many virtual nodes on that ring, so joining or leaving moves roughly 1/n of the keyspace instead of rehashing everything. The N nodes clockwise from a key are its preference list. A write is sent to all N and returns once W acknowledge; a read asks all N and returns once R answer. When R + W > N the read set and the write set must overlap, so a read sees at least one copy of the most recent acknowledged write. N=3, W=2, R=2 is the common setting.
Conflicts
During a partition two clients can both write the same key successfully. The store can pick a winner by timestamp, which silently discards one client's work, or keep both versions and return them as siblings with a vector clock recording which descends from which. Dynamo chose the latter: merging two shopping carts is the application's problem, because only the application knows what a merge means.
Underneath
Writes append to a log, then land in an in-memory sorted table that is periodically flushed as an immutable sorted file; background compaction merges those files and drops superseded versions and tombstones. That is an LSM tree: fast sequential writes, reads that may consult several files and therefore rely on Bloom filters. A B-tree updates pages in place instead: slower writes, more predictable reads.
Example
// Dynamo-style coordinator for a single key: ring placement plus a quorum
// across the N replicas. Nothing here depends on the storage engine.
import { createHash } from 'node:crypto';
const N = 3; // replicas per key
const W = 2; // acknowledgements required for a write
const R = 2; // responses required for a read
// R + W > N, so every read set intersects every successful write set.
function hash(s) {
// First 32 bits of MD5. This is a placement hash, not a security hash.
return createHash('md5').update(s).digest().readUInt32BE(0);
}
// Virtual nodes: each physical node owns many points on the ring, so adding or
// removing one shifts ~1/n of the keyspace rather than remapping every key.
export function buildRing(nodes, vnodes = 128) {
const points = [];
for (const node of nodes) {
for (let v = 0; v < vnodes; v++) {
points.push({ hash: hash(`${node.id}#${v}`), node });
}
}
return points.sort((a, b) => a.hash - b.hash);
}
// The N distinct physical nodes clockwise from the key.
export function preferenceList(ring, key) {
const h = hash(key);
let start = ring.findIndex((p) => p.hash >= h);
if (start === -1) start = 0; // wrapped past the last point
const list = [], seen = new Set();
for (let i = 0; i < ring.length && list.length < N; i++) {
const { node } = ring[(start + i) % ring.length];
if (seen.has(node.id)) continue; // skip other vnodes of the same host
seen.add(node.id);
list.push(node);
}
return list;
}
// Resolve as soon as `need` promises succeed; reject only once so many have
// failed that `need` is unreachable. Slower replicas keep writing in the
// background - they are not cancelled, they are just not waited for.
function quorum(promises, need) {
return new Promise((resolve, reject) => {
const ok = [];
let failed = 0;
for (const p of promises) {
p.then((r) => { ok.push(r); if (ok.length === need) resolve(ok); },
(e) => { if (++failed > promises.length - need) reject(e); });
}
});
}
export async function put(ring, key, value, context) {
const replicas = preferenceList(ring, key);
const coordinator = replicas[0];
const prev = context?.clock ?? {};
// longhand: context != null && context.clock != null ? context.clock : {}
// The coordinator advances only its own entry in the vector clock that the
// client received from its last read.
const clock = { ...prev, [coordinator.id]: (prev[coordinator.id] ?? 0) + 1 };
// `{ ...prev, [k]: v }` is shorthand for Object.assign({}, prev, { [k]: v }).
await quorum(replicas.map((n) => n.write(key, value, clock)), W);
return { clock }; // the context the client passes to its next put
}
export async function get(ring, key) {
const replicas = preferenceList(ring, key);
const responses = await quorum(replicas.map((n) => n.read(key)), R);
return reconcile(responses.flat()); // may legitimately return siblings
}When to use it
- Store user sessions or feature-flag state with single-digit-millisecond reads across three regions.
- Hold a write-heavy event or device-state table that no single database server can absorb.
- Serve a product catalogue where availability during a node failure matters more than reading the newest price.
- Decide, for an existing service, whether R=1 reads are acceptable or whether you need a quorum read.
More examples
Sloppy quorum and hinted handoff
This is the trade most people skip when they recite R + W > N. Sloppy quorums are why a Dynamo-style store stays writable through a node failure or a network partition, and also why it cannot promise you read-your-writes. Reach for it when write availability matters more than a read being up to date; avoid it, or use a strict quorum, for anything where a missing read is a correctness bug.
// A strict quorum fails the write when W of the N owners are unreachable.
// A sloppy quorum writes to the next healthy nodes on the ring instead, tagging
// each write with a hint naming the node it really belongs to. The stand-in
// keeps hinted writes in a separate area and replays them when the owner
// returns, then deletes its copy.
export async function putSloppy(ring, key, value, clock) {
const owners = await Promise.all(
preferenceList(ring, key).map(async (n) => ({ node: n, up: await n.isHealthy() })),
);
const targets = [];
const hintedFor = new Map(); // stand-in id -> owner id
for (const { node, up } of owners) {
if (up) { targets.push(node); continue; }
const standIn = nextHealthyAfter(ring, node, [...owners.map((o) => o.node), ...targets]);
if (!standIn) continue; // nothing left to take it; W may not be reached
hintedFor.set(standIn.id, node.id);
targets.push(standIn);
}
return quorum(
targets.map((n) => n.write(key, value, clock, { hintedFor: hintedFor.get(n.id) ?? null })),
W,
);
}
// Durability survives; the quorum guarantee does not. A read that asks only
// the N nodes in the preference list can miss a write that only stand-ins
// acknowledged, so a client can read its own write as absent. Anti-entropy
// (Dynamo compared Merkle trees of key ranges between replicas) eventually
// closes the gap, but "eventually" is the whole point of the word.Resolving siblings with vector clocks
Shows what "the application resolves conflicts" actually costs: every read path has to be able to receive more than one answer. Reach for a real merge whenever losing a write is user-visible - carts, tag sets, counters, collaborative state. Last-write-wins is the alternative, and it is fine only when you can say out loud which writes you are willing to lose.
// Two writes whose clocks neither dominates the other are genuinely concurrent.
// The store cannot order them. The application can merge them.
function dominates(a, b) {
// `a` dominates `b` if a >= b at every node and strictly greater somewhere.
let strictly = false;
for (const node of new Set([...Object.keys(a), ...Object.keys(b)])) {
const x = a[node] ?? 0;
const y = b[node] ?? 0;
if (x < y) return false;
if (x > y) strictly = true;
}
return strictly;
}
export function reconcile(versions) {
// Discard any version that some other version descends from.
const siblings = versions.filter(
(v) => !versions.some((other) => dominates(other.clock, v.clock)),
);
if (siblings.length === 1) return { value: siblings[0].value, context: { clock: siblings[0].clock } };
// Cart merge, the example the Dynamo paper uses. Union the additions, then
// apply the removals: union alone makes deleted items reappear, which is the
// behaviour the paper reports as visible to customers.
const lines = new Map();
for (const s of siblings) {
for (const line of s.value.items) {
lines.set(line.sku, Math.max(lines.get(line.sku) ?? 0, line.qty));
}
}
for (const s of siblings) for (const sku of s.value.removed ?? []) lines.delete(sku);
// The merged context descends from all siblings, so writing it back collapses
// them into one version.
const clock = {};
for (const s of siblings) {
for (const [node, n] of Object.entries(s.clock)) clock[node] = Math.max(clock[node] ?? 0, n);
}
return { value: { items: [...lines].map(([sku, qty]) => ({ sku, qty })) }, context: { clock } };
}The storage engine on one node
Worth reading even if you never write a storage engine, because it explains behaviour you will meet as a user: why bulk deletes make reads slower rather than faster, why read latency has a long tail that depends on compaction, and why a key-value store has a background process that can saturate your disk. Reach for a B-tree engine instead when reads dominate and you want predictable latency more than write throughput.
// LSM tree, which is what the node under the quorum usually is (Bigtable,
// Cassandra, RocksDB all take this shape).
export class LsmNode {
constructor({ memtableBytes = 64 * 1024 * 1024 } = {}) {
this.limit = memtableBytes;
this.memtable = new Map(); // in a real engine: a skip list, kept sorted
this.bytes = 0;
this.tables = []; // newest SSTable first
}
async write(key, value, clock) {
// 1. Append to the write-ahead log. This is the only durability point; the
// memtable is lost on a crash and rebuilt by replaying the log.
await this.wal.append({ key, value, clock });
// 2. Insert in memory. Both steps are sequential writes: no page to find,
// no page to rewrite. That is why LSM write throughput is high.
this.memtable.set(key, { value, clock });
this.bytes += key.length + value.length;
if (this.bytes > this.limit) await this.flush(); // freeze, write an SSTable
}
async read(key) {
const hot = this.memtable.get(key);
if (hot) return hot; // the newest version is in memory
// Newest SSTable first, stop at the first hit - an older file may hold a
// superseded version. The Bloom filter answers "definitely not in this
// file" without touching disk; a false positive costs one wasted read, and
// a false negative cannot happen.
for (const table of this.tables) {
if (!table.bloom.mightContain(key)) continue;
const found = await table.lookup(key);
if (found) return found.tombstone ? null : found;
}
return null;
}
}
// A delete writes a tombstone rather than removing anything: the key may exist
// in several older files, and only compaction can drop them all. Until then,
// deleted keys still cost reads - and a tombstone deleted too early lets an
// old value come back from a replica that never saw the delete.
Discussion