Skip to content
SeeItFlow
System Design · 11 chapters · ~45 min read

Key-Value Store Architecture: Sharding, Replication & LSM Trees

A distributed key-value store works by giving every key one authoritative owner at a time and making every acknowledged write survive failures. Inside each replica, a write-ahead log and an LSM tree (log-structured merge tree: memtable, immutable SSTables, background compaction) make writes durable and reads findable. Across machines, a partition map with epochs routes each key to one partition, and a Raft-style replicated log commits a write only when a majority of replicas has stored it, so a stale leader or stale router is fenced rather than obeyed. A leaderless Dynamo-style quorum design is shown as a labeled alternative, because R+W>N is not linearizability. This page walks through API semantics, storage formats, partitioning, replication, read modes, tombstones and TTL, tenancy, multi-region failover, capacity and the core algorithms, using a fictional Meridian Profiles store and hypothetical numbers.

Mangesh MandlikLast updated: How lessons are made
Chapter 01

API and Semantics: What a Call Promises

A key-value API is small on purpose, and every verb in it has to say what happens when the network, a replica or the client misbehaves. The reference store exposes Put, Get, Delete and optional BatchGet, and each response either names a committed version or says plainly that the outcome is unknown.

The running example is fictional: Meridian Profiles stores user preferences and tenant configuration for tenants Atlas, Birch and Cedar. Every size and rate on this page is a teaching assumption, not a measurement — 2 billion records, an average record of 1 KiB, a key limit of 1 KiB and a value limit of 256 KiB. Keys are always tenant-scoped composites such as atlas/user42/settings, and the tracked mutation evt-024 is a Put to exactly that key.

The four calls

Put(tenant, key, value, ifVersion?, idempotencyKey) writes an opaque value (bytes or JSON) and returns a committedVersion the server assigned, also exposed as an ETag. Get(tenant, key, readMode) returns the value plus its version. Delete(tenant, key, ifVersion?) is a write like any other: it creates a *tombstone* version rather than erasing anything on the spot. BatchGet fans independent point reads across partitions and returns per-key results; it is a convenience, not a transaction, so two keys in one batch may reflect different moments. A limited Scan(prefix/range) exists only when the partitioning scheme keeps neighbouring keys together (the partitioning chapter explains why hashing gives it up). V1 has no joins, no cross-key transactions, no secondary indexes and no full-text search — each of those is a different product.

httpA conditional Put and its two outcomes. The header and field names are this lesson's illustration, not a standard.
PUT /v1/t/atlas/kv/user42/settings
If-Match: "v7"
Idempotency-Key: 6b1f0c2e-evt-024
Content-Type: application/json

{"theme":"dark","locale":"de-DE"}

--- 200 OK ---
ETag: "v8"
{"committedVersion": 8, "status": "OK"}

--- 412 Precondition Failed (someone else already wrote v8) ---
{"error":"VERSION_MISMATCH","currentVersion":8}

Reading the status codes

404 means the key has no live value. Whether the API tells a deleted or expired key apart from one that never existed is a product choice; exposing it helps debugging and sync clients, but it also reveals history, so say which you picked. 412 Precondition Failed is reserved for a compare-and-set mismatch: the caller supplied ifVersion and the committed version differs. 409 Conflict is a different situation — reusing an idempotency key with a different payload, or hitting a key range that is mid-migration. A stale routing view produces a redirect (421 or 307 style) or a retryable WRONG_EPOCH. If no majority of replicas can commit, the answer is 503 / UNAVAILABLE, never a cheerful success. 429 is per-tenant throttling. The point of splitting these is that clients react differently: 412 means re-read and merge, 409 means fix the request, 503 and 429 mean back off.

OutcomeMeaningClient should
200 / 201Committed at committedVersionStore the version for the next conditional write
404No live value (never existed, deleted or expired; product decides whether to distinguish)Treat as absent; do not retry blindly
409Idempotency-key reuse with a different payload, or active migration conflictFix the request or retry later as documented
412ifVersion does not match the committed versionRe-read, merge, retry with the new version
421 / WRONG_EPOCHRouting view is staleRefresh the partition map and resend
429Tenant is over its quotaBack off with jitter
503 / UNAVAILABLENo quorum or no safe leaderRetry with the same idempotency key, bounded
TimeoutOutcome unknown — it may have committedRetry with the same idempotency key
Response semantics of the reference store

Timeouts are not failures

Suppose Atlas's client sends evt-024, the leader commits it, and the acknowledgement is lost on the way back. The client sees a timeout. If it concludes 'failed' and sends the same update again without protection, a non-idempotent mutation (a counter increment, an append) applies twice. The fix is an idempotency key: the server records (tenant, idempotencyKey) → result *inside the same replicated log entry* as the write, so a retry after a leader change still finds it. The record is kept for a documented window — say 24 hours, an assumed policy — after which a reused key is treated as new. A retried conditional write is also safe from double application, because the second attempt carries the now-stale ifVersion and gets 412. On its own, though, a 412 cannot tell the client whether it was its own first attempt or a competing writer that produced the new version; the idempotency key is what lets the server answer with the original result instead.

Swipe to pan
Ambiguous timeout and idempotent retryA timeout means unknown, so retries need an idempotency keyPut evt-024 with idempotencyKey = idem-9f; the response is lost on the way backClientLeaderDedup record1 Put idem-9f2 commit v8, record idem-9f → v83 200 OK v8✕timeout: success or failure? unknown4 retry Put idem-9f (same payload)found idem-9f → v8, do not re-apply5 200 OK, v8 (replayed result)What the retry achievesThe write applied once, not twice.The caller gets the original result,including the committed version.Records expire after a retentionwindow; say how long.Key reuse with new dataSame key, different payload is aclient bug. Answer 409 Conflict;never replay the old result for adifferent request.Alternative: reconcileConditional writes can read back:Get shows v8 with my value, so mywrite applied. Without a key or acondition, blind retries may duplicate.
A timeout after commit is an unknown outcome; the retry with the same idempotency key returns the original result instead of applying twice

Values, limits and versions

The version is a monotonically increasing per-key number assigned when the entry is applied in log order, so it is identical on every replica. It is not a wall-clock timestamp, which is what makes CAS comparisons immune to clock skew. Size limits (key ≤ 1 KiB, value ≤ 256 KiB in this lesson) exist because a single oversized record occupies a whole replication round trip, inflates compaction and can block a leader's apply loop; Google documents guidance of roughly 10 MB maximum per Bigtable value, but a settings store has no business approaching that.

Key takeaways
  1. 01Every mutation returns a committed version or an explicit non-success; a timeout is neither.
  2. 02412 is a CAS mismatch, 409 is a request conflict, 503 is lost quorum — clients treat them differently.
  3. 03Idempotency records live in the replicated log, scoped by tenant and retained for a documented window.
  4. 04Deletes are versioned writes that leave tombstones.
Chapter 02

The Storage Engine: WAL, Memtable, SSTables and Compaction

Each replica needs a local engine that makes writes durable quickly and reads findable cheaply. The widely used answer is a log-structured merge (LSM) tree: append every change to a log, keep recent data sorted in memory, and periodically write sorted immutable files that are merged in the background.

The write-ahead log

A write-ahead log (WAL) is an append-only file where a change is recorded *before* it is applied anywhere else. Appending is sequential, which storage handles well, and it gives crash recovery something to replay. Each record carries enough to verify itself:

textOne possible record layout. Real engines differ in framing, compression and batching.
WAL record (little-endian, illustrative layout)
+--------+--------+-----+--------+---------+-----------+----------+-------+
| len u32| crc32c | seq | op u8  | keyLen  | key bytes | valueLen | value |
+--------+--------+-----+--------+---------+-----------+----------+-------+
  crc32c covers everything after itself; seq is the replica's local, gap-free sequence
  op: 1=PUT  2=DELETE(tombstone)  3=CHECKPOINT
  A torn tail (crash mid-record) fails its checksum -> recovery truncates there.

The write call write() only copies bytes into the operating system's page cache. Durability on stable storage requires fsync (or an equivalent flush), and even that assumes the storage stack honours flushes — drives or controllers with volatile write caches can still lose an acknowledged flush unless the stack is configured correctly. So the durability boundary has to be stated: *acknowledged after the record is flushed on this replica* (and, in the replicated design, committed by a majority).

Swipe to pan
Write-ahead log before acknowledgementWrite-ahead log: make it durable, then apply, then acknowledgeOrder of operations for one Put; fsync is the durability lineClientServerWAL on diskMemory map1 Put evt-0242 append record(write into OS page cache)3 fsyncdurable on stable storage4 apply5 200 OKFailure windowsCrash before step 3Record may be lost. The clientnever got an ack, so no promisewas broken; it retries.Crash after 3, before 5Record is durable but the clientsaw a timeout: unknown outcome.Retry must be idempotent.fsync skippedA write() alone only reaches theOS page cache. Power loss drops it.Even fsync needs a storage stackthat honours flushes.
The failure windows around a write: before append, appended but not flushed, flushed but not acknowledged, acknowledged

fsync is expensive relative to memory, so engines use group commit: concurrent writers queue behind one flush. With the assumed figure of 6,250 write-applies per second on a node and a 1 ms commit window, one flush covers about 6.25 records on average — the cost per record falls as load rises, at the price of up to a millisecond of added latency. That is a latency knob, and it is a durability knob too: flushing less often is faster and widens the loss window.

Recovery

After a crash, startup reads the WAL from the last checkpoint, verifies each record's checksum, replays valid records into a fresh memtable, and truncates at the first bad or incomplete record. Records the client was never acknowledged for may or may not survive; acknowledged ones must. A concrete timeline: evt-024 was appended and flushed at seq=88213, acknowledged, and the process died before the memtable was flushed to an SSTable. On restart the replay reaches 88213, rebuilds the value for atlas/user42/settings, and a Get returns v8.

Swipe to pan
WAL recovery replay after a crashRecovery: replay the log to rebuild memoryCheckpoint at record #2; the last record was half-written when power failedWAL on disk (append-only, each record carries a checksum)#1Put a#2Put b#3Put evt-024#4Delete c#5torn tailcheckpoint covers #1–#2replay in order: #3, #4checksum fails → stop, discard1 · StartMemory map is empty after the crash.Load checkpoint, if there is one,then open the WAL after its position.2 · Rebuilt mapa, b from checkpointevt-024 applied, c removedWhy the checkpoint existsWithout it the log grows forever andrecovery replays everything since day one.Checkpoints bound restart time and letold log segments be deleted.What recovery guaranteesEvery record acknowledged after its fsyncis rebuilt. A torn, unacknowledged tail issafely dropped. Replay must be idempotentbecause a crash can interrupt recovery too.
Recovery replays flushed WAL records past the last checkpoint and truncates at the first torn record

Memtable, SSTables and compaction

New writes land in a mutable in-memory sorted structure, the memtable (a skip list or balanced tree). When it reaches a size threshold it is frozen and written out as an SSTable — a sorted, immutable file of key-value entries divided into blocks with an index, optionally a Bloom filter, and a checksum footer. After the flush completes, the WAL segment covering it can be recycled.

textLayout is illustrative; production formats add prefix compression, restart points and per-block checksums.
SSTable (sketch)
[data block 0][data block 1] ... [data block N]
[filter block  : Bloom bits for all keys]
[index block   : last key of each data block -> block offset,length]
[footer        : offsets of filter+index, crc, format version]
Entry in a block: key | version | kind(PUT/DEL) | value
Blocks are sorted by (key asc, version desc) so the newest version comes first.
Swipe to pan
Memtable to SSTable to compactionLSM write path: memtable, SSTables, compactionTurn random writes into sequential ones; pay for it in background rewritesPutevt-024WALappend + fsyncMemtablesorted, in RAMflushLevel 0: new SSTablessst-1 {a:1, b:1}sst-2 {a:2, c:1}mergeAfter compactionsst-3 {a:2, b:1, c:1}sst-1, sst-2 deletedMemtableSorted structure in memory.Reads check it first.When full it becomes read-onlyand is flushed in key order.SSTableImmutable sorted file with anindex and a Bloom filter.Never edited in place; newerversions live in newer files.CompactionMerges files, keeps the newestversion, drops shadowed data.Tombstones are dropped onlyafter the GC grace period.One logical write is several physical writes (assumed illustrative 10× write amplification)Peak 204.8 MB/s of client writes × 3 replicas × 10 ≈ 6.1 GB/s of cluster disk writes.Per node over 96 nodes: about 64 MB/s at peak, 12.8 MB/s at the average write rate.The factor depends on compaction strategy and workload: measure it, do not assume it.Trade: compaction debt (a backlog of merges) slows reads and can fill disks if it falls behind.
Writes flow WAL then memtable then SSTable; compaction merges overlapping runs and drops shadowed versions

Since SSTables are never modified, updates and deletes simply add newer entries (keeping several versions of one key side by side is the core of multi-version concurrency control, MVCC: readers pick the version they are entitled to see, and writers never edit a version in place); older ones are shadowed until compaction merges runs and discards what is no longer visible. Strategies trade differently: size-tiered merges runs of similar size (lower write amplification, more overlap for readers to check), leveled compaction keeps non-overlapping sorted runs per level (fewer files to check per read, more rewriting), and time-window compaction groups data by age for time-ordered workloads. Cassandra documents all three; which one is right depends on workload and must be measured.

Reading through many files

A point read checks the memtable, then SSTables from newest to oldest, stopping at the first entry for the key. Naively that is one disk read per file. A Bloom filter is a compact bit array that can say 'definitely not in this file' or 'possibly in this file'. With 10 bits per key and 7 hash functions the false-positive rate is about 0.82% (classic math: (1 − e^(−7/10))^7 ≈ 0.0082), and a correctly built filter has no false negatives. For one partition replica holding 1,953,125 keys, that is 19,531,250 bits ≈ 2.44 MB; across 32 replicas on a node, about 78 MB of filters. A lookup for an absent key probing 5 SSTables expects only about 5 × 0.0082 ≈ 0.041 needless disk reads.

Swipe to pan
Point read path with Bloom filtersPoint read: skip files you can prove do not hold the keyGet(atlas/user42/settings), newest SSTable firstMemtablenewest data, in RAMBloom filterper SSTableSparse indexfinds the blockData blockone disk readBloom filter answers“Definitely not here”Trusted: a correct filter has no false negatives.The SSTable is skipped with no disk read.“Maybe here”Go to the sparse index and read one block.For an absent key, about 0.82% of filters still say this.The arithmetic (classic Bloom math, assumed sizing)10 bits per key, k = 7 hashesfalse-positive rate = (1 − e^(−7/10))^7 ≈ 0.0082Absent key probing 5 SSTables: 5 × 0.0082 ≈ 0.041 needless disk reads.Memory: 1,953,125 keys × 10 bits ≈ 2.44 MB per partition replica.× 32 replicas per node ≈ 78 MB of filters per node.Present keys still cost a disk read per level they might sit in; compaction keeps the file count, and so the read cost, down.
Point read: memtable, then per-SSTable Bloom filter, sparse index and one block read; false positives cost a wasted read, never a wrong answer

Filters answer membership for *point* lookups; range scans cannot use them, which is one reason scans over many overlapping files are costlier. Read amplification is the number of places consulted per read, write amplification is physical bytes written per logical byte (WAL, flush and every compaction rewrite — this lesson uses an assumed illustrative 10×), and space amplification is disk used over live data, high while old versions and tombstones await compaction.

Swipe to pan
LSM tree versus B-tree across cost axesStorage engine: LSM tree vs B-treeTypical tendencies, not guarantees; real behavior depends on implementation and tuningAxisLSM tree (reference choice)B-treeWrite pathAppend to WAL + memtable; sequential flushFind page, modify in place, WAL for safetyPoint readMemtable then several SSTables; Bloom filters skip mostOne root-to-leaf walk, few page readsRange scanMerge across sorted runs; fine, costlierWalk linked leaves; very naturalWrite amplificationCompaction rewrites data multiple timesPage rewrites and WAL; often lower for small updatesRead amplificationGrows when many SSTables overlapLow and steadySpace amplificationOld versions and tombstones until compactionPage fill and fragmentationLatency shapeFast writes; periodic compaction pressureSteadier reads; random-write cost on large dataWhy LSM here40k–200k writes/s and sequential disk I/O favor appends.When B-tree winsRead-heavy, scan-heavy, latency-sensitive, smaller data.
LSM trees buy sequential writes with background compaction and extra read paths; B-trees update pages in place with more random writes
Key takeaways
  1. 01The WAL is the durability boundary; fsync (honoured by the storage stack) turns an append into durable data.
  2. 02SSTables are immutable; newer entries shadow older ones until compaction reclaims space.
  3. 03Bloom filters cut wasted reads for absent keys: false positives are possible, false negatives are not.
  4. 04Amplification, not request count, sizes the disks.
Chapter 03

Partitioning: Hashing, Ranges, Virtual Partitions and Safe Moves

Once the data no longer fits on one node's disks or throughput budget, the key space is divided into partitions (shards), each owned by a set of nodes. The routing rule must give every key exactly one owner at a time and must survive adding capacity without reshuffling everything.

Why `hash mod N` fails

Hash the key, take it modulo the node count: simple and uniform. Then grow from 3 to 4 nodes. A key stays put only when h mod 3 == h mod 4, which holds for 3 of every 12 residues — 25% stay and 75% move. For the 2.048 TB logical data set that is 1.536 TB to relocate (4.608 TB counting three copies), during which routers and nodes disagree about owners. Consistent hashing or, more simply, a fixed number of virtual partitions fixes this: keys map to a stable partition id, and only the *partition-to-node* assignment changes. Adding a fourth node to three should move ≈25% (0.512 TB logical) — all of it onto the new node — and a scene illustrates this with 12 virtual partitions: 4 per node becomes 3 each, with 3 partitions moving. Movement is reduced, not zero.

Meridian's worksheet (assumed) uses 1,024 logical partitions: 2.048 TB / 1,024 = 2.0 GB each, about 1,953,125 records. With RF=3 that is 3,072 partition replicas, and on 96 nodes 32 replicas per node. Partitions are small enough to copy in minutes-to-hours and numerous enough to balance.

Two classic ways to get that stable mapping are worth naming. Consistent hashing places nodes (and keys) on a hash ring and gives each key to the next node clockwise, so adding a node steals only the arc next to it. Giving every physical node many small arcs, called virtual nodes, smooths out uneven arcs and lets a bigger machine own more of them; when a node dies its arcs scatter across many survivors instead of landing on one neighbor. Range-based stores go the other way and divide the sorted key space into tablets that split and merge as they grow. Meridian's fixed 1,024 partitions are effectively virtual nodes with an explicit map: simple to reason about, with the map held in the control plane rather than derived from the ring.

Swipe to pan
Hash partitioning versus range partitioningHash vs range partitioning: what queries each supportsSame six keys, two layoutsHash partitionedpartition = hash(key) mod spaceP1atlas/m…cedar/b…P2birch/x…atlas/a…P3cedar/q…birch/c…get(key): one hop · load spreads evenlyscan atlas/*: asks every partitionRange partitioned (sorted)tablet = contiguous key rangeT1atlas/a…atlas/m…T2birch/c…birch/x…T3cedar/b…cedar/q…scan atlas/*: one tablet · ordered resultssequential keys → one hot tabletHashing gives upOrdered iteration, prefixscans, cheap range deletes.Needs a scatter-gather.Ranges give upAutomatic spread. Needssplits, merges and care withtimestamps as key prefixes.Reference choiceHash into virtual partitions:point lookups dominate. Scanonly where range-partitioned.RulePick the layout from the queries you must serve; hybrids hash a prefix, then sort inside it.
Hash partitioning spreads keys evenly but scatters ranges; range partitioning keeps order for scans but needs splitting and can hotspot

Hash versus range

Hash partitioning applies a hash to tenantId/key (hashing the whole composite so a tenant prefix cannot be forged into another namespace and so tenants spread out). Distribution is even, point operations are cheap, and a Scan would have to ask every partition. Range partitioning (Bigtable-style tablets, or ordered stores such as FoundationDB, described only as an ordered transactional key-value store) keeps keys sorted so prefix scans touch few partitions, but monotonic keys such as timestamps pile onto the last range and splits must be managed. Meridian's primary workload is point lookup, so it hashes; a limited Scan is promised only on a range-partitioned deployment.

PropertyHash of tenant/keyRange of tenant/key
Point lookupOne partition, directOne partition via range map
Prefix or range scanFans out to every partitionFew adjacent partitions
Load spread for random keysEven by constructionDepends on split points
Monotonic keys (time, counters)Spread evenlyHot tail range until split
Split / mergeFixed partition count, move whole partitionsDynamic split of hot or large ranges
Fit for Meridian V1Chosen for point reads/writesOption if ordered scans become a requirement
Hash and range partitioning compared

The partition map and epochs

A partition map (kept in a small, strongly consistent control-plane store, which could be a Raft-replicated group such as etcd) records who owns each partition. Every entry carries an epoch that increments on any ownership change:

jsonA partition map entry. Requests carry the epoch the router saw; a mismatch returns WRONG_EPOCH.
{
  "partitionId": 417,
  "hashRange": ["0x1a40", "0x1a7f"],
  "epoch": 12,
  "replicas": ["node-14", "node-52", "node-77"],
  "leader": "node-14",
  "state": "ACTIVE"
}

Routers cache the map. A request carries the epoch the router believes; the node compares it with its own. If they differ, the node refuses with WRONG_EPOCH plus the new epoch, and the router refreshes and retries. That is how stale routing becomes a redirect instead of two nodes both accepting writes for the same key.

Moving a partition without losing writes

Rebalancing is a state machine: the control plane marks the partition MOVING at epoch *e*, the destination installs a snapshot and then tails the source's log until it is caught up, checksums of both sides are compared, writes to the source are paused for a brief fence while the last entries drain, the map flips to the destination at epoch *e+1*, and only then does the source stop serving and later delete its copy. A failure before the flip rolls back by discarding the destination copy; after the flip the epoch makes the source refuse. Throttling the copy keeps foreground P99 latency visible and bounded.

Two practical details sit around that state machine. Before the flip, the destination can serve *shadow reads*: the router sends a small sample of reads to both owners, discards the destination's answer, and compares the two, so a bad copy shows up before any client depends on it. After the flip, routers learn the new epoch lazily, so some requests still reach the old owner for a while; those are refused with WRONG_EPOCH and retried, which is why the old copy is kept for a grace period rather than deleted at once. When many partitions have to move (a new node joining), the control plane staggers them, a few at a time under a bandwidth cap, rather than shifting all traffic in one step; rollback is a pre-flip abort that discards the half-built copy, and a post-flip problem is handled by moving the partition back as a new, ordinary migration at epoch *e+2*.

Swipe to pan
Safe shard migration with epochsMoving partition p9 from N1 to N4 safelyOwnership changes only by epoch; the data moves first, the owner flips onceSTABLEepoch 7owner N1COPYINGsnapshot + tailN1 still ownerCAUGHT UPlag ≈ 0N1 fences writesHANDOFFepoch 8owner N4CLEANUPN1 deletes p9after graceabort before the flip: back to epoch 7A stale router meets the new ownerRoutermap epoch 7Put p9 @7N1knows epoch 8WRONG_EPOCH (current 8)Router refreshes its ownership map, then resends to N4.Nobody writes to a partition they no longer own, andno write is silently dropped: the client sees a retryable error.Why the short fenceIf N1 kept accepting writes after N4 tookover, both would ack the same key. The fencegives a single writer at every instant.A 409 is returned if the key is mid-move.Failure modes this preventsData lost by deleting the source too early.Two owners (split brain) during the switch.Stuck migrations that no one can see: theybelong on the operations dashboard.
A migration moves through snapshot, catch-up, verify, fence and flip, and the epoch increment is the single point of authority change

Skew and the hot tenant

Averages hide concentration. With a simplified 100-partition illustration, 200,000 reads/s averages 2,000 per partition; one hot key such as atlas/feature=checkout_policy at 60,000 reads/s is 30× that average and 30% of average fleet reads. Adding nodes does not redistribute a single key. Chapter 10 treats mitigation, and tenant-level isolation appears in the multi-tenancy chapter.

Key takeaways
  1. 01Fixed virtual partitions let capacity change by moving whole partitions, not rehashing every key.
  2. 02Adding one node to three moves about a quarter of the data, not zero and not three quarters.
  3. 03Epoch checks turn stale routing into a retry.
  4. 04Hash favours point operations; range favours scans and costs split management.
Chapter 04

Replication Mechanics: Replicated Log, Terms and Quorum Commit

Copying a partition to three machines does not by itself make it safe. What makes a replicated key-value partition correct is an agreement rule: a write counts as committed only once a majority of voting replicas have durably stored it in the same log order, and only a replica holding every committed entry can become leader.

The reference design uses a Raft-like model per partition. 'Raft-like' is deliberate: this is a conceptual reference for explaining the mechanics, drawing on the Raft consensus algorithm, not a claim that every product behaves this way, and not a promise that a replication factor of 3 alone yields consensus. Three copies written by an ad-hoc script with no term, no log matching and no election rule is just three copies that can disagree. etcd, covered in a live lesson on this site, is a production example of a Raft-based store.

The log and its vocabulary

jsonA replicated log entry and the state each replica keeps.
{
  "partitionId": 417,
  "term": 9,
  "logIndex": 88213,
  "key": "atlas/user42/settings",
  "value": "{\"theme\":\"dark\"}",
  "requestId": "6b1f0c2e-evt-024",
  "version": 8
}
// per-replica state: currentTerm, votedFor, log[], committedIndex, appliedIndex
// per-partition role: leaderId (valid only for the current term)

A term is a monotonically increasing election number; at most one leader exists per term. logIndex orders entries; committedIndex is the highest index known to be stored on a majority; the state machine (the key-value map) applies entries in log order up to committedIndex. The entry's version is derived from the apply order, so replicas agree on it.

A write through three replicas

Replica A is leader, B a follower, C a follower that is lagging. evt-024 arrives at A. A does a cheap pre-check of the idempotency table and the ifVersion precondition to reject obvious failures early (the authoritative check repeats at apply time, as the conditional-write section shows), appends the entry at index 88213 in term 9 and flushes its own WAL. It sends AppendEntries to B and C. B appends, flushes and replies; C is slow. As soon as A has the entry durable on itself and B — two of three, a majority — it advances committedIndex, applies the entry (producing v8) and replies to the client. C catches up later from the log. The client never waited for C, and no follower committed anything before the leader said it was committed.

Swipe to pan
Three replicas with a leader and quorum commitRF 3: leader append, majority acknowledge, then commitRaft-like conceptual model: term 3, log index 41; majority of 3 = 2ClientA · leaderterm 3BfollowerClagging1 Put evt-0242 append #41 + fsync3 replicate #41slow link, still behind4 ack (B has #41, fsynced)A + B = 2 of 3 → committed5 200 OK, version v8State after step 5A last #41 commit #41B last #41 commit #40*C last #39 commit #39* B learns commit #41 from the next append or heartbeatRules that keep it safeLeader counts its own fsynced copy.Commit needs a majority, never fewer.Followers do not apply an entry ascommitted until the leader says so.Ack to client only after commit.What RF 3 does not meanOne replica down: commits continue.Two down: no majority, no commit,strong reads unavailable too.RF 3 is not “any 2 lost and writesstill succeed”. RF 5 survives 2.
The leader acknowledges after a majority has durably appended; the slow follower catches up afterwards and no follower commits ahead of the leader
Replication factorMajority needed to commitReplica failures commits survive
321
532
743
With three replicas, losing two means no commit: the majority of two can no longer be formed

Raft requires that a leader never overwrites its own log and that a follower rejects an AppendEntries whose preceding entry (index and term) it does not hold; that log-matching check lets a returning replica truncate its divergent tail and adopt the leader's. A new leader also may not count replicas of an old-term entry as committed on its own; it commits one entry from its current term, which indirectly commits everything before. These are the details that make 'copy to a majority' actually safe.

Elections, fencing and stale leaders

Followers expect heartbeats. If one hears nothing for a randomized election timeout (the Raft paper that introduced the algorithm suggests values such as 150–300 ms; implementations differ), it increments its term and asks for votes. A replica grants at most one vote per term, and only to a candidate whose log is at least as up to date as its own. That second rule is what guarantees that any winner holds every committed entry: a committed entry sits on a majority, any winning majority overlaps it, and a voter with the entry will not vote for a candidate lacking it.

Now the dangerous case. A is partitioned from B and C but does not know it. B and C elect B in term 10. When the partition heals, A still believes it leads term 9. Any message from A carries term 9; B and C reject it because their term is higher, and A, on seeing the higher term, steps down. This is fencing: the term is a token that makes an old leader's requests harmless. For side effects outside the replicated log, a fencing token must also be checked by the resource itself.

Swipe to pan
Term fencing rejects a stale leaderTerms fence off the old leaderEvery message carries a term; replicas ignore anything older than the highest term they have seenAwas leader, term 4Bleader, term 5CfollowerA is cut off (pause or partition); B and C elect B for term 5B: append entry (term 5)ack → entry committedA wakes up and still believes it is the leaderA: append entry (term 4)reject: my term is 5A steps down, adopts term 5What the term doesIt is a monotonic fencing token.Higher term wins every comparison.A leader that cannot gather a majorityin its own term cannot commit.What it preventsTwo leaders both committing the same indexwith different contents. The writethe old leader tried is neveracknowledged as committed.Clients tooA client still routed to A gets arejection or redirect and re-resolvesthe leader. Retries must carry anidempotency key.
A partitioned old leader keeps its term; replicas with a newer term reject it and it steps down on healing

Conditional writes in the replicated state machine

Two clients read v7 of atlas/user42/settings and both send ifVersion=7. Both requests are ordered by the leader's log. The first is applied: version becomes 8, response 200. The second is applied against version 8, the precondition fails and it returns 412 with the current version — and importantly the check is evaluated deterministically at apply time on every replica, so the outcome is the same everywhere.

Swipe to pan
Compare-and-set with one winnerCAS: the version check happens inside the logBoth clients read v7 of atlas/user42/settings, then race with Put ifVersion = 7Client 1Client 2Leaderlog + stateFollowersPut ifVersion=7 (theme)Put ifVersion=7 (locale)orders: first, then secondreplicate first, commit200 OK, committed v8412: expected 7, current 8re-read v8, merge, retryWhy it is atomicThe leader evaluates ifVersion when itapplies the second to the state machine, in logorder. Exactly one of two writers with thesame expected version can succeed.Followers apply the same decision.The broken versionClient does Get, checks the version, thenPut. Between the two calls another writercan commit: a lost update.CAS must be one server-side operation.
Two ifVersion=7 writers are serialized by the log: one commits v8, the other receives 412

Membership changes, snapshots and catch-up

Changing the replica set (replacing a dead node, moving a partition) is itself dangerous: if old and new configurations could each form an independent majority, there would be two leaders. Raft handles this with joint consensus (decisions need a majority of both old and new configurations) or with one-server-at-a-time changes. A new replica is brought up by installing a snapshot — a point-in-time copy of the state machine at some logIndex — and then replaying the log after it; the leader can compact its log up to the snapshot point, which bounds disk but means a replica that was down longer than the retained log must take the snapshot path.

Key takeaways
  1. 01Commit means durably stored on a majority in log order, in the current leadership's term.
  2. 02Elections only succeed for candidates whose logs contain every committed entry.
  3. 03Terms fence stale leaders; an old leader's request is rejected by anyone with a newer term.
  4. 04CAS is evaluated in log order at apply time, so exactly one of two conflicting writers wins.
Chapter 05

The Alternative: Leaderless, Dynamo-Style Replication

Some stores skip the leader. Any replica among the N that hold a key can coordinate a request; writes go to W replicas, reads ask R, and conflicts are reconciled afterwards. It is a different contract, and everything below is labeled ALTERNATIVE because its guarantees must not be mixed into the reference design.

The moving parts

A coordinator (any node, often the one the client contacted) finds the key's preference list — the N replicas responsible, derived from consistent hashing — and sends the operation to them. With no central map, nodes usually learn who is alive and who owns which arcs by gossip: each node periodically swaps its membership view with a few random peers, so news spreads through the cluster in a handful of rounds, though two nodes can disagree about membership for a short time. With N=3, R=2, W=2 a write succeeds after two acknowledgements and a read waits for two replies. R+W = 4 > N = 3, so every read set intersects every write set in at least one replica. That is quorum-overlap intuition: the read will contact at least one replica that saw the latest *completed* write.

Swipe to pan
Dynamo-style quorum overlap and its exceptionsALTERNATIVE: leaderless N/R/W overlap, and where it breaksAssumed N=3, W=2, R=2: R + W = 4 > 3, so the sets must intersectALTERNATIVE · not the referencereplica r1replica r2replica r3Write W = 2put(k, v8) ackedafter 2 replicas ackv8ackedv8ackedv7not reached yetRead R = 2asks any 2 replicas,keeps the newestnot askedv8askedv7askedoverlap: r2 holds v8read returns v8newest of {v8, v7}R + W > N is quorum intuition, NOT linearizability, when:Sloppy quorum / hinted handoff: acks may come from stand-in nodesConcurrent writes: two "newest" versions, so siblings or LWWClock skew: last-write-wins can keep the older writeFailed partial write: 1 of 3 replicas may hold a value never ackedMembership change: the two quorums may be different node sets
Read and write sets of two out of three always intersect, yet a sloppy quorum, a partial write or a concurrent write can still hand back stale data

Repair mechanisms

Hinted handoff lets a stand-in node hold a write for an unreachable replica and replay it later; Cassandra treats it as best-effort: hints do not replace anti-entropy repair as a guarantee of eventual consistency, and hints are only kept for a limited window (recent default configs show three hours — check your version). Read repair compares replies on a read and writes the newest back to stale replicas. Anti-entropy uses Merkle trees (hash trees over key ranges) to find divergent ranges between replicas cheaply and stream the difference. Repair is scheduled work with real I/O cost, and it has to run more often than the tombstone retention window, as the lifecycle chapter explains.

Merkle trees: finding the one range that differs

Comparing two replicas key by key would mean shipping every key. A Merkle tree avoids that. Each replica splits a key range into leaf ranges and hashes the content of each (every key with its version, deletions included), then hashes pairs of hashes upward to one root. Take four leaf ranges A to D. Replica R1 has leaf hashes 3f, 9c, 71, e2; replica R2 has 3f, 9c, 5a, e2 (illustrative values, not real hashes). The roots differ, so they compare children: the A+B side matches and is skipped entirely, while the C+D side differs. Comparing C and D shows that only C differs. Only the keys in range C are exchanged.

Inside range C the two replicas swap (key, version) pairs. R1 holds atlas/user42/settings at v7 and R2 holds v6, because R2 was down when v7 was written. The newer version wins and is streamed to R2. A delete must be compared as a version too: if tombstones were ignored, a deleted key would look like a missing key and be copied back to life. The cost is that building a tree means reading the data, and every write changes a leaf hash, so trees are built from snapshots or maintained incrementally, and repair is scheduled work rather than something a request waits for.

Keep gossip and Merkle repair apart. Gossip spreads small facts between nodes: who is alive, who owns which range. Merkle comparison finds differences in the data itself. A node can be marked alive by gossip and still hold stale data that only repair will fix. In the leader-based reference, membership and ownership come from the epoch-numbered partition map and from terms and heartbeats, not from gossip, and a follower that fell behind catches up from the leader's ordered log or a snapshot. The reference still uses a Merkle diff in repairReplica (see the pseudocode chapter), but for a narrower job: confirming that replicas that have been away for less than the tombstone retention window agree again. In a leaderless design there is no single log to replay, so anti-entropy is the main way replicas converge.

Concurrent writes: siblings or last-write-wins

Two clients update atlas/user42/settings on different coordinators at the same moment. Without a leader there is no log order. Version vectors attach a counter per writer-node so replicas can tell whether one version descends from another or the two are concurrent; concurrent ones are kept as siblings and returned to the application to merge (the Dynamo paper's shopping cart is the classic example). Last-write-wins instead keeps the value with the larger timestamp: simple, but it silently discards one of the writes, and with clock skew can discard the later one. Bigtable's documentation describes server-time last-write-wins for conflicting same-cell writes across clusters in multi-cluster routing.

Swipe to pan
Concurrent writes become siblings and need reconciliationALTERNATIVE: concurrent writes, siblings, reconciliationBoth clients read version v9 of atlas/user42/settings, then write without seeing each otherClient Atheme = darkClient Blocale = deStore holds two siblingssibling 1{theme: dark} vv [A:1]sibling 2{locale: de} vv [B:1]neither vv dominates the other = concurrentRead returns bothClient or app merges:{theme: dark, locale: de}writes back with the contextof both siblingsnew vv [A:1, B:1]supersedes bothAlternative rule: last-write-wins (LWW)Higher timestamp wins; the other write isdropped with no signal to its author.Skewed clocks can even keep the older write.Simple and available, but it loses data.Choose it only where a lost update is acceptable.Reference design avoids the forkOne leader orders writes in the log.B's put with ifVersion=9 fails 412 onceA has committed v10, so B re-readsand retries: no siblings to merge.Cost: writes need a quorum and a leader.
Two concurrent writes become siblings under version vectors; last-write-wins would keep only the higher timestamp
AspectReference (leader + log)Alternative (leaderless)
Order of writes to a keyTotal, from the logNone; concurrent versions possible
Conditional writes (CAS)Natural, evaluated at applyNeeds extra coordination (e.g., lightweight consensus)
Write availability with one replica downYes if majority remainsYes, even with sloppy quorum and hints
Under partitionMinority side refuses writesBoth sides can accept writes, reconciled later
Conflict handlingPreventedDetected and merged, or one write lost under LWW
Operational burdenElections, log compactionRepair, hints, tombstone windows
Contracts differ, so the API differs; the two designs cannot be swapped without redesigning clients

Neither is better in the abstract. A shopping-cart-like record that can be merged and must always accept writes suits the alternative; tenant settings guarded by CAS suit the reference. DynamoDB sits partly outside this framing: its documentation describes eventually consistent reads by default with optional strongly consistent reads within a Region, and global tables with multi-Region modes — check the vendor's current documentation rather than relying on summaries.

Key takeaways
  1. 01R+W>N guarantees overlapping sets, not a single latest value.
  2. 02Hints are best-effort; anti-entropy repair is the backstop.
  3. 03Concurrent writes are resolved by siblings (merge) or LWW (lossy), never by magic.
  4. 04Switching models changes the API contract, not just the backend.
Chapter 06

Reads: Linearizable, Follower, Read-Your-Writes and Eventual

A read mode is a promise about *how stale* an answer may be. The reference store offers four, named the same everywhere: linearizable, follower-stale, read-your-writes and eventual. Linearizability means every operation on a key appears to take effect at a single instant between its start and its response, consistent with real time; it concerns one object and is not the same as serializability, which concerns multi-object transactions.

Why the leader alone is not enough

Suppose A was leader in term 9 and has been silently replaced by B in term 10. A client still talking to A reads A's local state and gets a value missing a write B committed. Answering from the leader without checking it is still leader is not linearizable. Two correct techniques: a read-index round (the leader notes its current commit index, confirms with a majority heartbeat that it is still leader, waits to apply up to that index, then answers), or a leader lease (the leader assumes it stays leader for a bounded time that followers promise not to undermine; correctness then depends on bounded clock drift). Both cost something: a round trip or a clock assumption.

Follower reads, honestly labeled

A follower that serves its local state may be behind. In the example, C is lagging at v7 while A has committed v8; follower-stale read from C returns v7. That is acceptable when labeled — the response carries the applied index, optionally a staleness bound such as 'at most 500 ms behind' — and unacceptable when presented as current. Follower reads spread load, for instance for the 60,000 reads/s hot key, but only for callers who opted into staleness.

Swipe to pan
Replica lag and read modesSame key, three read modes, three guaranteesatlas/user42/settings after evt-024 committed as v8; C has not applied it yetA leadercommit #41 · holds v8latestB followercommit #41 · holds v8caught upC followercommit #39 · holds v7laggingRead modeServed byResultlinearizableleader A (read-index or lease check)v8 — sees every acknowledged writefollower-staleany follower, e.g. Cmay return v7 — labeled stale, bounded if you seta limitread-your-writesany replica at or above the session’sversion floorC is below v8, so it waits or redirects to A or BWhy offer the cheaper modesFollowers add read capacity and survive aleader change. Callers who tolerate lag tradefreshness for throughput on purpose.The trapServing a “normal” read from a laggingfollower silently breaks read-your-writes.The mode must be explicit in the API.
With replica C lagging, a follower read returns the old version while a linearizable read goes through the leader's confirmed state

Read-your-writes gives a client its own writes without full linearizability: after evt-024 commits at v8 the client holds a session token (the version, or log index, at which its write committed) and sends it as a floor. A follower serves the read only if it has applied at least that point; otherwise it waits briefly or forwards to the leader. Eventual reads (a remote region, or the leaderless alternative) promise only convergence if updates stop. Causal consistency, which preserves cause-before-effect across keys, sits between these but needs dependency tracking and is outside V1.

Swipe to pan
Consistency models contrasted: linearizable, read-your-writes, causal, eventualFour consistency models, strongest to weakestSame key, same write (put v8): what may a later read return?stronger guarantee, higher latency and less availabilityweaker, cheaper, more availableLinearizableGUARANTEEBehaves like one copy;a read after an ackedwrite sees it or newer.EXAMPLEput v8 acked→ any client reads≥ v8COSTLeader or quorumround trip; blockswithout a majorityRead-your-writesGUARANTEEYour own session seesits writes. Others maystill read older data.EXAMPLEyou wrote v8→ your read ≥ v8peers may see v7COSTSession token / versionfloor; wait or redirectif a replica lagsCausalGUARANTEECause before effect:if B saw A, everyonewho sees B sees A.EXAMPLEreply visible→ original postvisible firstCOSTTrack dependencies;unrelated writes maystill differ in orderEventualGUARANTEEReplicas converge ifwrites stop. No boundon when or in what order.EXAMPLEread may be v7or v8 for a whilethen convergesCOSTCheapest, highestavailability; conflictsneed a resolution ruleDon't conflateLinearizability is about one object's recency; serializability is about multi-object transactions.Neither follows from "eventual replication", and quorum overlap alone is not linearizability.
Four read modes ordered by what they promise, from linearizable to eventual, and what each costs
Read modeGuaranteeCost / riskTypical use
linearizableReflects every completed writeLeader round trip or lease; unavailable without quorumBalances, CAS preparation, admin changes
read-your-writesCaller sees its own committed writesSession token; may wait for a replica to catch upSettings page after Save
follower-staleSome past committed state, with a known applied indexMay be old; bounded only if a bound is enforcedDashboards, hot read-mostly config
eventualConverges when writes stopArbitrary staleness during partitions or cross-region lagRemote-region reads, alternative design
The four read modes the API exposes
Key takeaways
  1. 01A leader must prove it is still leader (read-index or lease) before a linearizable answer.
  2. 02Follower reads are valuable but must be labeled stale, optionally bounded.
  3. 03Read-your-writes uses a version floor from the client's own writes.
  4. 04Linearizable is per key; it is not transaction isolation.
Chapter 07

Data Lifecycle: Deletes, Tombstones, TTL and Backups

Deleting from a system that keeps immutable files and several replicas is a write, not an erase. The delete has to be replicated and remembered long enough that no stale copy can bring the value back.

Tombstones

A tombstone is a versioned marker meaning 'this key was deleted at version v'. It goes through the same log as any write, so every replica learns of it; reads that find a newer tombstone than any value return 404. The old value still sits in older SSTables on disk until compaction merges the files and sees that the tombstone shadows it. The value is *logically* absent immediately and *physically* present for some time.

Zombie resurrection

Suppose replica C is down when atlas/user42/settings is deleted. A and B hold the tombstone. They compact and, having kept the tombstone past its retention window, purge it. Later C returns carrying the old value. Repair sees C has a value and the others have nothing, and copies it back: the deleted record is alive again. The rule to prevent it: tombstone retention must exceed the longest time a replica can be out of the cluster or unrepaired before it is repaired or replaced. A replica gone longer than that must be rebuilt, not allowed to rejoin with old data. Cassandra's documented gc_grace_seconds default is 864,000 seconds (10 days); this lesson uses 10 days as its assumed teaching policy.

Swipe to pan
Tombstone garbage collected too early resurrects a deleted recordTombstone timeline and zombie resurrectionAssumed grace period 10 days (a Cassandra-style default; set it from your own repair cadence)day 0deleteday 10: GCday 11Replica Alive valuetombstone (delete marker)purgedReplica Blive valuenode down, never saw the delete, not repairedback: live valueReplica Clive valuetombstone (delete marker)purgedrepair copies itZombie: delete undoneA and C no longer have a marker, so B lookslike the only (newest) copy of the recordWhy the purge was legalAfter day 10 the tombstone was dropped by compaction.B stayed away for 11 days, longer than the grace.Rule: tombstone retention > longest time a replica can go unrepairedRepair every replica inside the grace window, or wipe and rebuild a node that was away longer.In the leader-based reference design the log and snapshot carry the delete to a returning follower;the zombie risk is sharpest in leaderless stores that rely on repair.
A replica that misses a delete and returns after the tombstone is purged re-seeds the deleted value

In the leader-based reference, followers apply tombstones in log order and a lagging follower catches up from the log or a snapshot containing them, which narrows the window; but restores from backup, a long-isolated replica or an operator copying files reintroduce the same risk, so the retention rule still applies.

TTL is not cache eviction

A time-to-live attaches an expiry to a record, and Meridian's TTL is a durable, replicated rule: a cache may evict whenever it likes, this store may not drop an unexpired record. Expiry is typically evaluated at read time (a record past its deadline is treated as deleted) and materialised later by a background sweep that writes a real tombstone through the log, so all replicas agree. Because 'now' differs between machines, clock skew can make a record vanish early on one node, so the leader stamps the expiry deadline into the entry, and reads compare against a bounded-skew clock.

Swipe to pan
TTL expiry is logical first and physical much laterTTL: logical expiry now, physical purge lateratlas/user42/session-hint with ttl 24 h; the retention periods are assumed policy examplesLivettl = 24 h setExpirednow ≥ expiresAtMarkedtombstone / filterCompactedbytes droppedBackups age oute.g. 35-day policyWhat a client can observeGet = 200Get = 404 from the expiry instant (read path checks expiresAt)Where the bytes still areon disk in SSTables / memtable until compactionin backups and snapshots until their own retention endsClock caveatExpiry compares wall clocks on the servingnode. Skew of a few seconds makes a recordvanish early or linger; leaders should stampexpiresAt so replicas agree.Erasure promiseSay "unreadable at once, physically removedafter compaction, from backups within theretention window". Never promise instanterasure.
A record passes through live, logically expired, tombstoned and finally physically purged after compaction

Physical erasure and backups

Privacy requests need honesty about timing. After a delete commits, the live value is gone from reads. Compaction later removes it from the replicas' files — when that happens depends on strategy and load. Snapshots and backups hold older copies until they expire: with an assumed 35-day backup retention, physical removal from backups could take up to 35 days. State this window; never promise instant erasure. Redaction of specific records from backups, or per-tenant encryption keys that can be destroyed (crypto-shredding), are options with their own cost.

Deleting a whole tenant is harder than deleting a key, because hashing scatters a tenant's records over every partition. A workable sequence: first mark the tenant deleted in the control plane (and bump a per-tenant generation number that is part of every stored key), so reads and writes for it fail immediately and old-generation entries are logically invisible; then run a throttled background sweep that writes tombstones for the tenant's keys partition by partition, so the work is visible and cannot starve foreground traffic; then let compaction remove the bytes. If the tenant's data is encrypted under its own data key, destroying that key (crypto-shredding) makes the remaining copies, including those in backups, unreadable well before the 35-day backup window ends. The tenant should be told which of these have completed and which are still pending.

Swipe to pan
Point-in-time restore: snapshot plus WAL replay to a chosen indexBackup + WAL archive: restore to a chosen pointIllustrative log indexes for one partition; the idea is snapshot consistency point + replayindex 9001500archived WAL replayedsnapshot @ 1000consistency pointrestore target 1341bad bulk delete @ 1342not replayedRestore procedurePick target indexLoad snapshotReplay WAL 1001…1341Verify checksumsFence & switch overWhat makes it consistentThe snapshot records the log index it covers,so replay starts at exactly 1001: no gap, nodouble apply. Taken per partition from areplica, then replay brings it to one point.What goes wrongNo WAL archive: restore only to snapshot time.Untested restores: the first try is the incident.A restored partition must be fenced from oldleaders and match other partitions' cut point.Replicas are not backupsReplication copies the bad delete to every follower within moments. Backups are the onlydefense against logical errors, so keep them separate, encrypted, and restore-tested.
A restore applies a snapshot plus WAL up to a chosen consistency point and must re-apply deletes made after the snapshot

Restore needs a consistency point: a snapshot per partition is paired with log entries up to a chosen index or timestamp. Anything deleted after that point must be replayed again, or removed through a deletion ledger, or the restore itself resurrects deleted data.

Key takeaways
  1. 01A delete is a versioned write; the old value lingers physically.
  2. 02Tombstones must outlive the maximum replica absence plus repair interval.
  3. 03TTL here is durable and replicated, with the deadline stamped by the leader.
  4. 04Erasure is bounded by compaction plus backup retention, never instant.
Chapter 08

Multi-Tenancy and Security

Many tenants share the same partitions, WAL and compaction threads, so isolation has to be built into the key, the request path and the background work, not added as a filter at the end.

The tenant identity comes from a verified credential (a signed token or mutual-TLS identity) and never from a field the caller types. The server prepends it to the key, so user42/settings from Atlas becomes atlas/user42/settings, and every later layer — routing hash, storage key, log line, admin API — sees the composite. A caller cannot ask for birch/… because the namespace is applied after authentication, not parsed from input. Key length (≤ 1 KiB) and value size (≤ 256 KiB) bounds apply before anything reaches the log.

Swipe to pan
Multi-tenant namespace, authorization and noisy-neighbour isolationTenant namespace, authorization, and noisy neighborsFictional tenants Atlas, Birch and Cedar share one clusterAtlasburstingBirchsteadyCedarsteadyGateway (every request)1 authenticate: who is this caller?2 authorize: may it touch this tenant?3 scope key: tenant/… prefix forced4 quota: per-tenant ops/s and bytesAtlas over quota → 429 + Retry-AfterShared partitionsatlas/user42/settingsbirch/user17/settingscedar/…Atlas throttled at the door:Birch and Cedar keep their latencyNamespaceThe tenant is part of the key,never a client-supplied fieldtrusted on its own.AuthorizationCredentials map to tenantsand verbs (get, put, delete).Deny by default.Noisy neighborQuota per tenant, plus fairqueuing inside the node so onetenant cannot fill the queue.Limits of logical isolationTenants still share disks, compaction and leaders. A very hot or regulated tenant may need dedicatedpartitions or a separate cluster; encryption keys per tenant help with erasure and audit.
Authenticated tenant namespace, per-tenant quota and shared-resource limits keep a noisy tenant from starving the others

Noisy neighbors

Per-tenant quotas (requests, bytes written, scan work) are enforced at the edge with a token bucket (each tenant's bucket refills at its allowed rate and every request spends tokens; an empty bucket means rejection) and answered with 429; the rate limiting pattern explains the mechanism. Inside the cluster, bulkheads (separate queues or thread pools per tenant tier) prevent one tenant's burst from filling shared queues. If Atlas's traffic dominates a partition, the hot tenant can be given dedicated partitions or nodes, moved with the same safe migration path.

Encryption, keys and operators

Data is encrypted in transit (TLS between clients, nodes and regions) and at rest. Envelope encryption lets a key-management service hold a master key and wrap per-tenant or per-volume data keys, so rotation re-wraps small keys instead of rewriting terabytes. Administrator access is a separate, audited path with least privilege and break-glass approval; operator actions such as moving a partition or reading raw data are logged with who, what and when.

  • Derive the tenant from the verified credential and apply it to namespace, routing, storage, logs and admin operations.
  • Redact keys and values from traces and metrics; log tenant id and key hash instead.
  • Enforce size limits and quotas before the write path.
  • Audit every administrative read and mutation.
  • Accepting tenant ids from request bodies.
  • Putting raw values or full keys in error messages and metric labels.
  • Using tenant id as an unbounded metric label (cardinality explosion).
Chapter 09

Multi-Region: Home Region, Async Replicas and Failover

Spreading a key-value store across regions trades latency, consistency and availability explicitly. The reference design keeps a home region (EU) as the authoritative location for each partition's leader and quorum, and ships the log asynchronously to a remote replica (US).

What asynchronous replication gives

Writes commit within EU at EU latency. The US replica applies the log with a lag; in the running example it is 300 ms behind. A US eventual read may return v10 when EU has acknowledged v11, and must be labeled so. Callers who need the latest value route to the EU leader and pay the cross-ocean round trip. Asynchronous replication does not make the whole system globally strongly consistent, because the remote copy is allowed to be behind.

Swipe to pan
EU home region with an asynchronous US replicaHome region and an asynchronous remote replicaReference: single home region per record. Assumed async lag 300 ms (illustrative)EU — home region (leader here)A leaderv11B followerv11C followerv10, laggingput v11 commits when A + B (majority) hold it,then it is acknowledged to the client.EU read: linearizable → v11async ≈300 msUS — async replicaUS replicasv10 (not yet v11)read: stale v10Reads here are labeled eventual /follower-stale, never linearizable.US write → forwarded to EU leaderGainLocal reads in the US, and theEU quorum never waits on atransatlantic round trip.CostUS reads can lag by secondsor more under load. A USwrite pays the EU round trip.Read-your-writesAfter a write, pass the commitversion v11 as a floor; the USwaits or redirects until ≥ v11.Alternative, not shown here: multi-active regionsConcurrent writers in both regions need conflict resolution (siblings or last-write-wins). Async replicationalone never gives global strong consistency; that needs a quorum spanning regions and pays its latency.
EU leader quorum commits at home; the US replica trails by the replication lag and serves labeled, eventually consistent reads

Region loss and RPO

If EU fails abruptly, writes acknowledged in EU but not yet shipped are lost to the US copy. Assume EU takes 30% of the 40,000/s average writes — 12,000 per second — and lag is 0.3 s: up to 12,000 × 0.3 = 3,600 acknowledged writes are exposed at the instant of failure. That number is RPO exposure (recovery point objective), and it depends on lag, never on a promise of zero. Recovery time (RTO) is how long until service resumes. Both are shown here as illustrative; a real contract names them with measured lag.

Promoting the US replica requires fencing the old EU leader so it cannot accept writes if it revives (a region-level epoch or a control-plane decision recorded in a strongly consistent store), accepting that v11 may be missing in the promoted copy (it holds v10), and deciding what to do with the lost tail when EU returns. Records with data-residency rules must not be silently promoted in the US; their failover has to stay in a compliant region or fail closed.

Synchronous alternatives and vendors

Putting voting replicas in several regions gives stronger guarantees across regions at the price of cross-region latency on every commit, and a region-wide outage must leave a majority. Multi-active designs let several regions accept writes and reconcile conflicts, so they are a different model from a globally coordinated strongly consistent product. Bigtable's documentation describes replication across clusters as eventually consistent by default, stronger behavior only with single-cluster routing, and reverting to eventual consistency after a failover until replication catches up. Cassandra's LOCAL_QUORUM keeps quorum in the local datacentre for latency. DynamoDB global tables are multi-active; AWS describes two modes, multi-Region eventual consistency (MREC) and a separately configured multi-Region strong consistency (MRSC) — read the vendor's current documentation for exact behavior and supported operations, since details change. Earlier DynamoDB global tables behavior was that transactions were atomic only in the originating Region, so partial results could be visible elsewhere during replication.

TopologyCross-region readsFailover data lossWrite latencySplit-brain protection
Home leader + async replica (reference)Eventual unless sent to homeUp to the lag windowHome-region latencyFence old leader before promotion
Cross-region voting quorumLinearizable via leaderNone for committed writes if a majority survivesIncludes inter-region round tripMajority rule
Multi-active regional writersLocal, eventualConflicting writes reconciledLocal latencyConflict resolution, not prevention
Conceptual comparison; vendor products implement variants with their own caveats
Key takeaways
  1. 01Home-region commits are fast; remote reads are labeled eventual.
  2. 02RPO is lag-dependent exposure (here up to 3,600 writes), not zero.
  3. 03Promotion needs fencing and a residency check.
  4. 04Vendor modes differ; verify current documentation.
Chapter 10

Capacity and Operations

The fleet is sized by throughput and amplification, not by raw data volume. Everything here is a worked planning illustration with assumed budgets, not a measurement.

Footprint

2,000,000,000 records × 1,024 B = 2.048 TB logical (≈ 1.86 TiB). Three copies give 6.144 TB of raw record replicas — which is not provisioned disk. Adding an assumed 5% for indexes, Bloom filters and metadata gives 6.4512 TB; keeping volumes at most 60% full (an assumed target leaving room for compaction temporary files, tombstones, snapshots and repair) gives 6.4512 / 0.6 = 10.752 TB minimum, about 112 GB per node across 96 nodes. WAL space, backups and snapshots are listed separately, not hidden inside a 2× factor.

Throughput budget

QuantityWorkingResult
Peak write replica-applies200,000 writes/s × 3 replicas600,000/s
Per node (96 nodes)600,000 / 966,250/s = 31.25% of an assumed 20,000/s budget
Peak reads per node1,000,000 / 96≈ 10,417/s = 41.67% of an assumed 25,000/s budget
Combined at the short peak31.25% + 41.67%≈ 72.9% of a node
Node-equivalents at peak600,000/20,000 + 1,000,000/25,000 = 30 + 4070 (÷ 0.7 target = 100, versus 96 planned)
Disk write per node, peak204.8 MB/s × 3 × 10 ÷ 96 (10× assumed amplification)64 MB/s per node
A planning illustration: fine for a short burst, and a reason to monitor rather than trust

The workload is throughput-bound: 112 GB of provisioned disk per node is small next to the CPU and I/O needed. Little's Law (L = λ × W) sizes concurrency: 1,000,000 reads/s × an assumed 5 ms is 5,000 reads in flight, and 200,000 writes/s × an assumed 20 ms commit latency is 4,000 writes in flight — roughly 52 and 42 per node.

Network, CPU and catch-up time

Each committed write crosses the network more than once. At the assumed peak of 200,000 writes/s × 1 KiB, the leaders send every entry to two followers, so replication traffic is 2 × 204.8 = 409.6 MB/s across the cluster, or about 4.3 MB/s per node over 96 nodes; the acknowledgements are tiny by comparison. The asynchronous remote copy ships only the home region's share: at the assumed 12,000 writes/s that is about 12.3 MB/s, roughly 98 Mbit/s, before any compression. CPU goes to checksums (CRC32C is commonly hardware-accelerated, so the cost is usually modest, but measure it), serialization, TLS and compaction merges; compaction is normally the largest consumer, which is another reason to cap it.

Catch-up time decides how long a partition runs with fewer than three copies. Installing one 2.0 GB partition snapshot at an assumed throttled 50 MB/s takes 40 seconds plus the log tail. Replacing a failed node means re-creating its 32 partition replicas: 32 × 2.0 GB = 64 GB, which at an assumed 50 MB/s of aggregate throttled copy is about 21 minutes, and at 200 MB/s about 5 minutes. Raising the cap shortens the window of reduced redundancy at the price of foreground latency; the pace is a policy decision, and snapshot lag (how far a new replica's applied index trails the leader's) is the metric that shows it.

An SLO (service level objective) turns these dashboards into decisions. If Meridian promises 99.95% monthly availability for linearizable reads and writes, the error budget is 0.05% of a 30-day month, 43,200 minutes × 0.0005 = 21.6 minutes. A planned migration that pushes P99 past the objective, or an election that rejects writes for a few seconds, spends part of that budget, so risky background work is paced against the remaining budget rather than run whenever it is convenient.

Swipe to pan
Compaction amplification, disk headroom and P99 latencyCompaction: one logical write, many physical writesWrite amplification of 10× is an assumed illustration; measure the real factor for your workloadClient write1 KiB putWAL appendfsync, 1 copyMemtable flushnew SSTableCompactionrewrites it againRewritten …at each levelPeak arithmetic (assumed inputs)204.8 MB/s client × RF 3 × 10 = 6,144 MB/s ≈ 6.1 GB/s of cluster disk writes6,144 MB/s ÷ 96 nodes = 64 MB/s of sustained disk writes per nodeAverage load: 40.96 × 3 × 10 = 1,228.8 MB/s cluster-wide = 12.8 MB/s per nodeVolume fill: compaction needs temporary roomdata + indexes (target ≤ 60% full)temp spacedanger zone60% lineHow falling behind couples to P99 latencyCompaction debt growsMore SSTables per keyReads probe more filesDisk and I/O saturateP99 rises, writes stallControlsCap compaction I/O, keep disk below 60%,alert on debt before latency, add nodes.Trade-offCompacting harder cuts read cost but stealsI/O from foreground traffic; tune, do not max.
Compaction debt competes with foreground I/O; as disks fill, compaction slows and read amplification and P99 rise together

What to watch

Compaction is background work that needs headroom. When pending bytes grow faster than compaction drains them, the number of files a read checks rises and tail latency rises with it; once free space falls under the target, compaction temp space is at risk. Throttle background tasks, alert on pending compaction bytes and free disk, and shed or reject writes by policy before the disk is full — an explicit failure, not a corrupted one.

Swipe to pan
Operational dashboard for a distributed key-value storeOperations: what to watch before clients noticeIllustrative values and thresholds; set real ones from your SLOs and measured baselinesReplication lag300ms (US replica)alert > 2 s sustainedCommit latency P9918msalert > 40 ms (assumed SLO)Compaction debt↑pending bytesalert on 30 min growthDisk used58% (target ≤ 60)page at 60%Repair age6days (grace 10)alert > 7 daysUnder-replicated partitions3of 1,024page if > 0 for 10 minStuck migrations1running > 2 halert > 1 hTenant throttlesAtlas429s risingnotify tenant ownerEach tile pairs a leading indicator with an action: debt before latency, repair age before resurrection risk.
A dashboard that pairs replication lag, under-replicated partitions, compaction debt and repair age with foreground P99
  • Track P50/P95/P99 per operation and commit latency per partition.
  • Alert on under-replicated partitions, replication lag, compaction debt, free disk against the 60% target and repair age against tombstone retention.
  • Cap background repair, compaction and rebalance bandwidth; show the foreground P99 impact.
  • Throttle per tenant and expose stuck migrations.

Hot keys are not fixed by capacity. The hot key at 60,000 reads/s needs at least three serving replicas at an assumed 25,000 reads/s each (3 × 25,000 = 75,000, 80% utilised, little headroom). Options are explicitly stale follower reads, a TTL client cache that is optional and labeled, request coalescing, or splitting read-only data across keys. Writes to one key still serialize through one leader, and salting does not change that.

Key takeaways
  1. 01Size by throughput and amplification; a write is several I/Os.
  2. 02Disk provisioning is a worksheet: replicas, overhead, free-space target, plus WAL and backups.
  3. 03Little's Law turns latency into concurrency.
  4. 04Watch compaction debt and repair age, not only averages.
Chapter 11

Algorithms and Pseudocode

These sketches are conceptual: they omit batching, retries inside the replication layer, cancellation of in-flight requests and many membership details, and they are not an executable Raft implementation. Each is followed by a concrete timeline.

Put path

textConceptual put. The conditional check and the request-id dedupe run at apply time, in log order.
function put(tenant, key, value, ifVersion, requestId):
  ctx   = verifyTenant(credential)            // I7
  k     = ctx.tenant + "/" + key
  route = partitionMap.lookup(hash(k))
  leader = resolveLeader(route.epoch)         // WRONG_EPOCH -> refresh and retry
  entry = {term, index, key:k, value, requestId, ifVersion}
  appendToLeaderDurableLog(entry)             // flush before counting self
  replicateToVotingReplicas(entry)
  wait for majority under current membership  // timeout => UNKNOWN outcome
  result = applyInLogOrder(entry)             // dedupe requestId; CAS check here
  return result                               // OK(version) | 412 | prior result

Timeline: the client sends evt-024; the router uses epoch 12 for partition 417; node-14 appends index 88213; node-52 flushes and replies; commit; apply produces v8; 200. If the leader crashes after commit but before replying, the retry reaches the new leader, finds the request id in the applied table and returns v8. If the leader crashes *before* a majority stored it, the new leader may or may not hold the entry: if it does, the entry is committed under the new term and the retry is deduplicated; if not, the retry applies it once. Either way the outcome is applied exactly once. Canceling the wait on a client timeout must not cancel the replication.

Read path

textEach read mode chooses where the read executes and what it proves first.
function get(tenant, key, readMode, floor):
  route = partitionMap.lookup(hash(tenant+"/"+key))
  switch readMode:
    linearizable:   idx = leader.commitIndex; leader.confirmLeadershipWithMajority(); wait applied >= idx
    read-your-writes: replica = any in sync; wait applied >= floor.index else forward to leader
    follower-stale: replica.localRead() ; attach appliedIndex
    eventual:       nearest replica.localRead() ; label eventual
  return lookup(memtable, immutables, sstables newest->oldest)

SSTable lookup

textA false-positive filter costs a wasted block read, not a wrong answer.
function lookup(key):
  for source in [memtable, immutableMemtables, sstables newest..oldest]:
    if source is sstable and !bloom(source).mightContain(key): continue
    entry = source.findLatest(key)         // index block -> one data block
    if entry: return entry.isTombstone ? NOT_FOUND : entry.value
  return NOT_FOUND

For an absent key and five SSTables the expected wasted reads are about 0.041 with the 10-bit filter described earlier.

Safe split or rebalance

textMoves are reversible before the flip and fenced after it.
function movePartition(p, src, dst):
  mark p MOVING at epoch e
  dst.installSnapshot(src.snapshot(p)); dst.tailLog(src, throttled)
  verify checksums(src, dst)                 // abort + discard dst on mismatch
  src.fence(p)                               // pause writes, drain last entries
  dst.catchUpTo(src.lastIndex)
  map.flip(p -> dst, epoch = e + 1)          // single authority change
  src.stopServing(p); delete after grace

Repair and replay

textRepair respects the tombstone retention window.
function repairReplica(r, peers):
  if r.absentLongerThan(tombstoneRetention): rebuildFromSnapshot(r)  // never rejoin with old data
  else: for range in merkleDiff(r, peers):
          stream newest versions and tombstones for range into r
  throttle to repairBudget; record repairAge

Conditional-update race

Timeline: key atlas/user42/settings is at v7. Writers X and Y both call Put(ifVersion=7). The leader logs X at index 88213 and Y at 88214. Applying 88213 yields v8 and 200 for X. Applying 88214 sees version 8, not 7, and returns 412 with currentVersion 8 to Y, who re-reads and retries with ifVersion=8. No client-side lock was needed and no replica disagrees about the winner. A client-side read-then-write would have let both overwrite v7 with their own v8.