Skip to content
SeeItFlow
System Design · 16 chapters · ~43 min read

Design a Distributed Key-Value Store: One Node to a Cluster

A distributed key-value store keeps records addressable by key across many machines while an acknowledged write survives failures and reads follow a stated consistency contract. This page builds one reference design for the fictional Meridian Profiles: a write-ahead log and an LSM engine on one node, fixed virtual partitions with epoch-checked routing, three-way replication with majority commit, compare-and-set, tombstones and TTL, and a home region with asynchronous remote replicas. Each component appears after the failure that demands it. An interactive 46-scene Visual Design Journey is embedded below at the visual-journey anchor, and chapters cite its scenes by number.

Mangesh MandlikLast updated: How lessons are made
Chapter 01

Problem and Scope: Settings That Must Not Vanish

A key-value store maps an opaque key to an opaque value and answers one question very fast: what is stored under this key? The hard part is not the lookup. It is deciding which machine owns the key, making the write survive crashes, keeping copies in agreement, and saying precisely what a reader may see while things are failing. This page builds one design, step by step, from a map in a single process to a partitioned, replicated, region-aware database, and each component appears only after the failure that demands it.

The running example is fictional. Meridian Profiles is a B2B platform that stores user preferences, account settings and tenant configuration as durable records for three tenants: Atlas (large and busy), Birch and Cedar. Every size, rate and latency on this page is a teaching assumption, not a measurement, and no figure describes a real product. The records we follow are atlas/user42/settings, birch/user17/settings and one hot configuration key, atlas/feature=checkout_policy. One tracked mutation, evt-024, is a Put of Atlas user 42's settings; the Visual Design Journey embedded further down follows it from request to commit, through a crash, and into later conflicts.

What “key-value” does and does not mean here

A key is a short byte string that identifies a record; a value is an opaque blob (bytes or JSON) that the store does not interpret. Because the store never looks inside values, it can spread records across machines by key alone. That is the whole bargain: you give up joins, secondary indexes and multi-key transactions, and in exchange a single-key lookup touches one partition and scales by adding machines.

It is also a *database*, not a cache. A cache may drop an entry whenever it likes because the source of truth lives elsewhere. Meridian's settings records are the source of truth: a successful Put that later disappears is data loss, not a miss. That one sentence rules out silent eviction, forces a durability boundary, and is why the Redis-style "fast in-memory map" is a starting point we will have to leave, not a destination. (The Redis fundamentals lesson covers that engine on its own terms.)

QuestionIn scope for V1Out of scope for V1
OperationsPut (optionally conditional on a version), Get with a chosen read mode, Delete, BatchGetJoins, SQL, cross-key transactions
Access patternPoint lookup by tenant-scoped key; limited prefix Scan only where the data is range-partitionedSecondary indexes, full-text search, arbitrary analytics
Optional, advancedTTL on records; a Watch change streamAnything that needs global multi-key ordering
ValuesOpaque bytes or JSON under a size limit (assumed: key ≤ 1 KiB, value ≤ 256 KiB)Large blobs; those belong in object storage with a pointer here
Scope for the settings store. The size limits are teaching assumptions.

Point lookups versus range-heavy workloads

Meridian reads atlas/user42/settings by its exact key. A workload that instead says "give me every event for user 42 between two timestamps" is range access, and it wants keys stored in sorted order on one partition. Ordered storage and hash distribution pull in opposite directions; chapter 7 returns to that choice. For now, note that point lookup is the operation everything below optimizes, and that a range scan is a feature the partitioning model must explicitly permit rather than something every key-value store gets for free.

Swipe to pan
Product scope of the key-value storeProduct scope: what the store promises and what it does notFictional company Meridian Profiles; limits are assumed teaching valuesTenant Atlaslarge, hotTenant BirchTenant CedarAPI surface (V1)Put(key, value, expectedVersion?, idempotencyKey)Get(key, readMode)Delete(key, expectedVersion?)BatchGet(keys) optionalScan(prefix | range) range- partitioned onlyWatch(change stream) advancedRecordkey tenant/… ≤ 1 KiB (assumed)value opaque bytes or JSON ≤ 256 KiB (assumed)version monotonic per keye.g. atlas/user42/settings birch/user17/settingsPromisesAck means: committed on a majority anddurable on those replicas.Single-key reads in linearizable mode seethe latest acknowledged write.Conditional write (CAS) is atomic andreturns 412 when the version mismatches.No quorum, no leader: 503, never fake success.A timeout is an unknown outcome: retry withthe same idempotency key.Not in V1Joins, cross-key transactions,secondary indexes, full-text search.Each is a different product with its own cost.Read modes (named everywhere)linearizable · read-your-writesfollower-stale (labeled) · eventual (remote region)The caller chooses how fresh a read must be.
Product scope: what the API offers, and the features deliberately left out. Each excluded feature would change how data must be placed or ordered.

Visual Design Journey scenes 1 and 2 show this starting point: one caller, one settings write, and then the list of requirements that the rest of the story has to satisfy.

Why large values and secondary indexes stay out

Both exclusions are decisions about this workload, not limits of the idea. Large values. Meridian's records average about 1 KiB and are capped at 256 KiB because every byte of a value is paid for several times: it is written to the log on three replicas, rewritten by compaction, and shipped again in snapshots and region replication, and one very large value can delay the writes queued behind it in the replicated log. The usual alternative is to keep the blob in object storage and store a small record here holding its location, size and checksum. The store's durability then covers the pointer, and the application owns the order (upload first, then write the pointer) and the cleanup of orphaned objects.

Secondary indexes. Finding every user with locale = de-DE needs a second ordering over the same data. Keeping it consistent with the primary record means a write touches more than one partition, or a read fans out to all of them. Meridian reads by exact key, so it keeps the write path simple and feeds a separate index or search system from the change stream, accepting that this index is eventually consistent and can lag. If attribute lookups were on the hot user-facing path, this is the first exclusion to revisit (Trade-offs & Decisions, Decision 17).

Interactive

Visual Design Journey

Follow one tracked write, evt-024, from an in-memory map that loses data on a crash to a durable, partitioned, replicated, region-aware key-value store — write-ahead log, LSM engine, partition epochs, quorum commit, CAS, tombstones and regional failover — for the fictional Meridian Profiles and its tenants Atlas, Birch and Cedar.

Chapter 02

Requirements and the Consistency Contract

Requirements for a data store are mostly promises about failure, so they have to be written as promises. "Fast and reliable" decides nothing; "an acknowledged write survives the loss of any one machine, and a read in the strong mode never returns anything older than the last acknowledged write" can be tested.

Functional behavior

Put(key, value, expectedVersion?, idempotencyKey) stores a value and returns a committedVersion that the server assigns; the same number is exposed as an ETag. With expectedVersion the write is conditional: if the key's current version differs, the server answers 412 Precondition Failed and changes nothing. Get(key, readMode) returns the value and its version. Delete(key, expectedVersion?) is a versioned write that records a deletion marker. Every record therefore has a version that rises with each committed change, and that version is what lets two writers avoid overwriting each other by accident.

Non-functional targets

The targets below are the ones the design must meet; the numbers are assumptions chosen so that the arithmetic in the next chapter is checkable.

PropertyTarget (assumed)What it forces
DurabilityAn acknowledged write survives a process crash and the loss of any single nodeA write-ahead log with fsync, then replication to a majority before the acknowledgement
ConsistencySingle-key linearizable writes and linearizable reads in the home regionOne leader per partition and a replicated commit rule; no silent conflicting writers
AvailabilityKeep serving while one replica of any partition is downThree replicas in separate failure domains; majority commit
LatencyPoint reads and committed writes bounded and observable at P99 (illustrative: single-digit ms reads, tens of ms commits)Local engine design, no cross-region hop on the home path
IsolationOne tenant's burst must not starve anotherPer-tenant quotas, bulkheads, visible throttling
Failure semanticsNo false successReject with 503 when no safe leader or quorum exists; a timeout means *unknown outcome*

Choosing the consistency contract on purpose

Linearizability means that every operation on a single key appears to take effect at one instant between its start and its finish, and that those instants respect real-time order: once a write has been acknowledged, every later read sees it or something newer. It is a statement about single objects. Serializability is about multi-object transactions and is a different property; the V1 store offers the first for one key and none of the second. Meridian chooses linearizable single-key semantics in the home region because settings are the kind of data where a user saves a preference and immediately reloads the page, and an old value on that reload reads as a bug.

The cost is stated up front. When a partition cannot assemble a majority, the strong mode refuses writes and strong reads with 503 rather than guessing. This is what the CAP framing actually says: not "pick any two of three" in general, but *during a network partition*, an operation either stays consistent and may be rejected, or stays available and may be inconsistent. A leaderless design makes the opposite choice and is shown as a clearly labeled alternative in chapter 10, never blended into the reference.

The response vocabulary is fixed here so that later chapters can lean on it. 200/201 carries committedVersion. 404 means the key does not exist or was deleted (the product may choose to expose deleted and expired separately). 409 is a conflict such as reuse of an idempotency key with a different payload. 412 is a failed version precondition. 421 or a redirect, or a retryable WRONG_EPOCH, tells a client its routing map is stale. 429 is per-tenant throttling. 503/UNAVAILABLE means no quorum or no safe leader. And a client *timeout* is neither success nor failure: it is an unknown outcome, retried only with the same idempotency key.

Visual Design Journey scene 2 shows the same requirement list as it is built up one line at a time.

Chapter 03

Scale Math: Data, Throughput and Where the Load Really Goes

Capacity arithmetic here has one job: decide whether the next component is justified. For Meridian's assumed workload the answer is that one machine cannot hold or serve it, and that the binding constraint is throughput and hot keys, not raw disk.

The inputs, all assumed: 2,000,000,000 records averaging 1 KiB (1,024 bytes, key plus value plus metadata, before indexes, replicas or write amplification). Average load is 200,000 reads/s and 40,000 writes/s; short peaks reach 1,000,000 reads/s and 200,000 writes/s.

QuantityWorkingResult
Logical data2,000,000,000 × 1,024 B2,048,000,000,000 B ≈ 2.048 TB decimal ≈ 1.86 TiB
Three raw copies2.048 TB × 36.144 TB decimal ≈ 5.59 TiB — record copies only, not provisioned disk
Average client ingest40,000 × 1,024 B40,960,000 B/s ≈ 40.96 MB/s ≈ 39.06 MiB/s
Peak client ingest200,000 × 1,024 B204.8 MB/s decimal ≈ 195.3 MiB/s
Reads in flight at peak1,000,000/s × 5 ms (assumed latency)5,000 concurrent reads (Little's Law, L = λ × W)
Writes in flight at peak200,000/s × 20 ms (assumed commit latency)4,000 concurrent writes
The footprint and concurrency arithmetic. Client-side bytes only: replicas multiply write traffic, as the fleet worksheet below shows.
Swipe to pan
Capacity worksheet for two billion keysCapacity worksheet: 2 billion keys, assumed planning figuresHypothetical inputs, not measurements. WAL, backups and snapshots are extra and listed separatelyStorageArithmeticResultRecords × 1 KiB average2,000,000,000 × 1,024 B2.048 TB logical (≈ 1.86 TiB)× RF 3 record copies2.048 TB × 36.144 TB — not provisioned diskPartitions2.048 TB ÷ 1,0242.0 GB logical eachPartition replicas1,024 × 33,072 → 32 per node on 96 nodesPer-node replicated data6.144 TB ÷ 9664 GBDisk to provision (assumed)6.144 × 1.05 ÷ 0.6010.752 TB ≈ 112 GB per nodeDisk factors: +5% index/Bloom/metadata, volumes kept ≤ 60% full for compaction, tombstones, snapshots and repair.Peak load per node (assumed budgets: 20,000 write-applies/s or 25,000 point reads/s)Writes: 200,000 × 3 ÷ 96 = 6,250/s31.25% of the write budgetReads: 1,000,000 ÷ 96 ≈ 10,417/s41.67% of the read budgetCombined (fractions add)≈ 72.9%Throughput-bound, not capacity-bound112 GB of disk per node is small. The 96nodes are there for CPU and I/O. 70node-equivalents at peak ÷ 0.7 target = 100ideal vs 96 planned: fine for a short burst.Easy to forgetOne logical write is not one disk I/O.Little’s Law: 1,000,000 reads/s × 5 ms≈ 5,000 reads in flight; 200,000 writes/s ×20 ms ≈ 4,000 in flight (assumed latencies).
Capacity worksheet: data, partitions, replicas and per-node budgets side by side. Throughput, not disk, is what sizes the fleet.

A planning worksheet, clearly assumed

Split the key space into 1,024 logical partitions: 2.048 TB / 1,024 = 2.0 GB each, about 1,953,125 records. With three replicas that is 3,072 partition replicas; spread over 96 nodes it is 32 replicas per node and 6.144 TB / 96 = 64 GB of replicated data per node. Assume a node can apply 20,000 replica writes per second *or* serve 25,000 point reads per second, and that mixed load adds as fractions.

At the peak, 200,000 writes/s become 600,000 replica applies (each write lands on three replicas), or 6,250 per node: 31.25% of the write budget. One million reads over 96 nodes is about 10,417 per node: 41.67% of the read budget. Together that is about 72.9% of a node, for a short burst. Counted as node-equivalents, 600,000 / 20,000 + 1,000,000 / 25,000 = 30 + 40 = 70; dividing by a 70% utilization target gives 100 nodes ideal against 96 planned. That is a planning illustration for a brief peak, not a promise, and nothing here is an average-utilization claim.

Disk is the small number

Replicated data of 6.144 TB plus an assumed 5% for indexes, Bloom filters and metadata is 6.4512 TB. If volumes are kept at most 60% full (assumed, to leave room for compaction scratch space, tombstones, snapshots and repair), the minimum provisioned capacity is 6.4512 / 0.6 = 10.752 TB, about 112 GB per node across 96 nodes. That is tiny next to the CPU and I/O the fleet needs, so this workload is throughput-bound, not capacity-bound. WAL space, backups and snapshots are additional line items to list explicitly; folding them into one magic "2×" would hide the reasoning.

Requests per second also understate physical work. If, for illustration, one client write causes roughly ten bytes of disk write for each byte accepted once log, flush and compaction rewrites are counted (an assumed factor of 10×; real values depend on the compaction strategy and workload and must be measured), then the peak is 204.8 MB/s × 3 replicas × 10 = 6,144 MB/s, about 6.1 GB/s cluster-wide or 64 MB/s per node. The average gives 1,228.8 MB/s, or 12.8 MB/s per node. One logical write is not one disk I/O.

Skew beats averages

Dividing traffic by node count assumes uniform spread. Real traffic is not uniform. In a simplified 100-partition illustration (distinct from the 1,024-partition fleet above), average reads of 200,000/s give 2,000 per partition. If atlas/feature=checkout_policy alone takes 60,000 reads/s it is 30× the average partition and 30% of average fleet reads. With 25,000 point reads per second per replica, 60,000 / 25,000 = 2.4, so at least three serving replicas are needed, and three replicas leave only 80% utilization. Chapter 13 handles that key; here it is a design pressure, because no amount of adding nodes lowers the load on one key's leader.

Visual Design Journey scene 3 puts these numbers on screen.

Chapter 04

V0: One Process and a Map

The first version is the one anyone would write: a dictionary in the application process. It is instant, it is trivially correct for one thread, and it loses every record the moment the process ends.

textV0 in a dozen lines. It has a version counter already, because conditional writes will need one.
store = {}                      # key -> (version, value)

def put(key, value):
    version = store.get(key, (0, None))[0] + 1
    store[key] = (version, value)
    return version                # 'acknowledged'

def get(key):
    return store.get(key)         # O(1), no disk

Everything about it is attractive for ten thousand records: a lookup is a hash probe, a write is an assignment, and there is nothing to configure. Now trace evt-024. Atlas user 42 saves new settings; put stores them in memory and returns version 8 to the caller. Ten seconds later the process is restarted by a deployment, or the machine reboots, or the kernel kills it for memory. The next get returns nothing, and the user's saved preferences are simply gone, even though the API said success.

Swipe to pan
A single in-memory map loses everything on a crashThe simplest store: one in-memory mapFast and simple, but its durability boundary is the process lifetimeClientPut evt-024Server process (RAM only)map[key] = valueatlas/user42/settings = {…v7}birch/user17/settings = {…}atlas/user42/settings = {…v8} ← evt-024200 OKacked from RAMTimelineserving; all writes live only in RAMcrashrestart: map is empty, Get returns 404Loss boundary = everything since the process startedThe client was told "success" for data that existed in one place only. Durable acknowledgement needs bytes on stable storage first.
V0 and a crash: the acknowledgement was truthful about the process's memory and false about the user's expectation. RAM is not a durability boundary.

This is the first invariant breaking, so name it precisely. The system acknowledged a write at a point where only volatile memory held it. The durability boundary is the point at which a write will survive the failure mode you promised to survive. For V0 that boundary is "until this process exits", which is below the requirement. Nothing in this design is wrong in logic; it fails because the promise and the mechanism do not match.

A natural first patch is to periodically dump the whole map to a file. That narrows the loss window to the dump interval but does not remove it, and writing a multi-gigabyte file while accepting writes either blocks them or produces an inconsistent snapshot. We need a mechanism that makes each write durable individually and cheaply. Visual Design Journey scenes 4 and 5 show the map answering instantly and then forgetting evt-024 on a crash.

Chapter 05

V1: Write-Ahead Log, Fsync and Replay

A write-ahead log (WAL) is an append-only file where every change is recorded before it is applied anywhere else. If the process dies, restart reads the log from the start and rebuilds memory. The acknowledgement moves from "I updated a variable" to "the log has it on stable storage".

Appending is the cheapest write a disk offers: sequential, no seeking, no rewriting of existing pages. Each record carries a sequence number, an operation, the key and value, and a checksum so recovery can tell a complete record from a torn one. The order of operations is the whole trick: append the record, make it durable, apply it to the in-memory map, then reply. Reversing the first two steps recreates V0's lie.

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.
Write-ahead ordering: append, fsync, apply, then acknowledge. The acknowledgement point sits after the durable flush, not after the append call.

Append is not durable; fsync is

Calling write() hands bytes to the operating system's page cache, which is memory. A power loss at that moment loses them. fsync (or an equivalent flush such as fdatasync or direct I/O with flush semantics) asks the OS to push the data to the storage device and wait until the device confirms. Only after that returns can the record be called durable on stable storage, and even then the claim holds *unless the storage stack honours flushes*: a drive or controller with a volatile write cache that ignores flush requests can still lie. Production fleets verify this with power-loss testing rather than trusting a datasheet.

fsync is also expensive, so doing one per write would cap throughput at the device's flush rate. Group commit fixes this: many writers' records are appended and a single fsync covers the whole batch. Using the worksheet figures, a node applying 6,250 writes/s with an (assumed) 1 ms commit window collects about 6.25 records per fsync. The price is that each write waits up to the window before its acknowledgement; the gain is that the flush cost is shared.

PolicyAcknowledged afterSurvives process crashSurvives power lossTrade-off
No log (V0)Memory updateNoNoFastest, loses data
Append, no flushwrite() to page cacheYesNo — OS cache is volatileLooks safe; fails exactly when you need it
fsync per writeDurable flushYesYes, if flushes are honouredSafe, flush rate limits throughput
Group commitOne fsync per batchYesYes, if flushes are honouredSafe with throughput; adds up to one commit window of latency
Where the acknowledgement sits determines what a crash can take back.

Recovery and checkpoints

On restart the engine opens the log, verifies each record's checksum, and applies records in order. A crash can leave the final record half-written; the checksum fails, recovery stops at the last good record and truncates the tail, and because that record was never acknowledged (the ack follows the flush), nothing the client was told is lost. Replaying a month of writes would be slow, so the engine periodically writes a checkpoint (a consistent snapshot of the map tagged with the last sequence it includes) and discards log segments older than the checkpoint.

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: load the last checkpoint, replay later log records in order, stop at the first checksum failure. Everything acknowledged is recovered; the torn tail was never acknowledged.

Replay must be idempotent or position-aware, since a crash during recovery means replaying again; recording each record's sequence number and skipping those already applied is the usual approach. With this in place evt-024 survives the restart that erased it in V0. Visual Design Journey scenes 6 and 7 show the append-then-flush ordering and the replay after a crash.

Chapter 06

V2: A Disk Engine That Keeps Up — Memtable, SSTables and Compaction

With a WAL, durability is solved but the working set still lives in memory. At 2 TB of logical data it cannot. The single-node engine now needs a layout on disk that keeps writes sequential and makes reads findable without scanning everything. The common answer is the LSM tree (log-structured merge tree).

The idea has four parts. Writes go to the WAL and then into a memtable, a sorted in-memory structure. When the memtable reaches a size threshold it is written out as an SSTable (sorted string table), an immutable file whose keys are in order, and the covered WAL segment can be dropped. Reads look in the memtable first and then in SSTables from newest to oldest. Because files are never edited in place, an update or delete is simply a newer entry that shadows older ones. Compaction merges several SSTables into fewer larger ones, keeping only the newest version of each key and reclaiming the space of shadowed entries.

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.
The write path into memtable and SSTables, and compaction merging overlapping files. Old versions linger until compaction rewrites them.

A key read through several files

Follow atlas/user42/settings over a week. Version 6 was flushed into SSTable 1 on Monday. A write on Tuesday made version 7, which flushed into SSTable 3. On Wednesday version 8 (evt-024) arrived and is still in the memtable. A Get finds version 8 in the memtable immediately. After the memtable flushes, a Get must check SSTable 4 (newest), find version 8 and stop. But a read for birch/user17/settings that was last written long ago might have to look at every file before finding it, and a read for a key that does not exist has to prove its absence in *every* file.

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.
A point read: memtable, then each SSTable newest-first, with a Bloom filter and index block saving most disk reads. The first hit wins because newer data shadows older.

That proof of absence is what Bloom filters are for. A Bloom filter is a compact bit array and a set of hash functions: adding a key sets k bits, and a lookup says "definitely not here" if any of its bits is zero or "maybe here" otherwise. A correctly built filter has no false negatives, so a "no" is a safe skip. With 10 bits per key and k = 7 hash functions the false-positive rate is (1 − e^(−7/10))^7 ≈ 0.82%. For one partition replica of 1,953,125 keys that is 19,531,250 bits, about 2.44 MB, and across 32 replicas per node about 78 MB per node. An absent key probing five SSTables expects about 5 × 0.0082 = 0.041 needless disk reads. A sparse index block inside each SSTable then narrows a hit to a single data block.

What compaction costs

Compaction is where the design pays for cheap writes. Every byte may be rewritten several times as it moves down through levels or merges with others; this is write amplification, and with read amplification (files consulted per read) and space amplification (stale versions kept) it forms a three-way trade-off. Different strategies pick different corners: size-tiered compaction writes less but leaves more overlapping files and more space overhead; leveled compaction keeps fewer files per read at the price of more rewriting; time-window strategies suit data that expires together. Compaction also competes with foreground traffic for disk and CPU, so it has to be rate-limited and observed, an operational matter chapter 13 revisits.

EngineWrite costPoint-read costSpace behaviorFits when
LSM treeSequential appends; rewrites during compactionSeveral places to look; Bloom filters and indexes cut itStale versions and tombstones persist until compactionWrite-heavy or mixed workloads, large datasets
B-tree (update in place)Random page writes plus a logFew page reads, predictablePages partly filled, little stale dataRead-heavy workloads wanting predictable reads
An intuitive comparison, not a benchmark; the winner depends on workload and implementation.

Visual Design Journey scenes 8 and 9 show writes accumulating into SSTables and the read path getting longer as files pile up. For the formats themselves, the architecture deep dive gives a WAL record layout, an SSTable sketch and the read algorithm in pseudocode.

Chapter 07

The Capacity Wall and the First Partition Split

One engine on one machine eventually runs out: disk, memory for filters, or simply the throughput of its storage. Meridian's peak of 600,000 replica writes and a million reads per second against 20,000 and 25,000 per node budgets means one node is nowhere close. The data must be partitioned: divided into pieces that different nodes own.

The tempting shortcut: hash modulo node count

Hash each key and take hash mod N. It is simple and spreads uniformly. Then add a fourth node to a cluster of three. A key keeps its owner only when h mod 3 == h mod 4, which is true for 3 of every 12 residues: 25% stay and 75% move. In bytes, 0.75 × 2.048 TB = 1.536 TB of logical data would have to be copied (4.608 TB counting three replicas), during which routing is wrong for much of the key space. Growth that should be routine becomes an outage-grade reshuffle.

Swipe to pan
Modulo hashing remaps most keys when a node is addedWhy hash mod N is a trap when the node count changesOwner of each hash residue (h mod 12) before and after adding a 4th nodeh mod 3h mod 4h≡0N1N1staysh≡1N2N2staysh≡2N3N3staysh≡3N1N4movesh≡4N2N1movesh≡5N3N2movesh≡6N1N3movesh≡7N2N4movesh≡8N3N1movesh≡9N1N2movesh≡10N2N3movesh≡11N3N4movesHash mod N: 75% movesA key stays only if h mod 3 = h mod 4:3 of every 12 residues.0.75 × 2.048 TB = 1.536 TB moved(4.608 TB counting RF 3 copies).Consistent / virtual partitions: 25%Only the share the new node takes overmoves: 0.25 × 2.048 TB = 0.512 TB,and all of it lands on the new node.Not zero movement, just the minimum.Why it hurts in productionMoved data must be copied while serving traffic, and during the copy a key’s owner is ambiguous.Reads miss on the new owner until the data arrives. Fix placement so ownership changes by small, explicit steps: see virtual partitions and safe moves.
Changing N from 3 to 4 re-homes most keys. The remapping cost is paid by data that did nothing but sit still.

Virtual partitions: decouple keys from machines

Instead of mapping keys to nodes directly, map keys to a fixed, larger number of logical partitions, then map partitions to nodes in a table. The fixed key-to-partition rule never changes (fixing the partition count up front is what makes that possible); only the partition-to-node table does. Meridian's worksheet uses 1,024 partitions. The journey scene uses a smaller picture, 12 virtual partitions: with three nodes each owns four. Add a fourth node and move three partitions (one from each of the old nodes), so each node owns three. That is 25% moved, all of it onto the new node, and 0.512 TB of the logical 2.048 TB. Not zero movement, and not magic, but proportional to the capacity added rather than to everything. Consistent hashing achieves a similar bound with a ring and virtual nodes; the fixed-partition table is the variant that makes a partition a first-class unit you can replicate, move and monitor.

Swipe to pan
Virtual partitions and the ownership mapFixed virtual partitions plus an ownership mapkey → hash → partition p0…p11 → owner node. Illustration uses 12 partitions; the fleet plan uses 1,024Before: 3 nodes, 4 partitions eachN1p0p3p6p9N2p1p4p7p10N3p2p5p8p11After: add N4, 3 partitions move (25%)N1p0p3p6N2p1p4p7N3p2p5p8N4p9p10p11Ownership map (the routing table)epoch 7: p0→N1 p1→N2 p2→N3 …epoch 8: p9→N4 p10→N4 p11→N4A key never moves between partitions;only whole partitions change owner.Why this is the practical shapeMoves are whole units that can be copied,verified and rolled back one at a time.Many partitions per node also spread loadand let a failed node’s work fan out.Cost to rememberMore partitions mean more replicas, heartbeats and metadata. 1,024 partitions × RF 3 = 3,072 replicas, 32 per node on 96 nodes.
Twelve virtual partitions on three nodes, then four. Three partitions move; keys do not change partition.

Hash versus range placement

Hash placement spreads load evenly but destroys key order, so a prefix scan would have to ask every partition. Range partitioning keeps adjacent keys together, which makes Scan(prefix) cheap and tablet-style splits natural, but sequential or popular key ranges can concentrate on one partition, and the split points must be managed. Meridian's reference uses hash partitioning for point lookups with a tenant-scoped composite key, and offers Scan only on collections it deliberately places on range-partitioned data. Neither choice is universally right: it depends on whether the dominant access is a point lookup or a range.

PlacementSpread of loadPrefix scanRebalancing unitMain risk
hash mod NEvenFan-out to all nodesNearly everything on resizeMass remapping
Virtual partitions (hash)Even, tunable by countFan-outOne partition at a timeNeeds a partition table; hot key still hot
Range / tabletsDepends on key distributionOne or few partitionsSplit or merge a rangeHot ranges, sequential-key hotspots

Visual Design Journey scenes 10 to 13 show the single node filling, the modulo reshuffle, and the same cluster growing with virtual partitions.

Chapter 08

Partition Metadata, Routing and Safe Cutover

Once data lives on many nodes, every request has to find the right one, and the answer changes whenever the cluster does. The partition map records, for each partition, its current owners and an epoch: a number that rises each time ownership changes.

A map entry reads something like partition 417 → leader node-12, replicas {node-12, node-31, node-77}, epoch 9. A client library or a stateless router hashes the key (after prefixing the tenant), finds the partition, looks up the owner and sends the request along with the epoch it believes is current. The map lives in a small strongly consistent control store, and routers cache it. Caching is what makes routing fast and also what makes it dangerous.

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.
Moving a partition: the epoch rises at cutover, and any request carrying the old epoch is rejected or redirected rather than served by a former owner.

Stale routers

Suppose partition 417 moves from node-12 to node-90 and the epoch goes from 9 to 10. A router that has not refreshed still sends evt-024 to node-12 tagged epoch 9. If node-12 simply accepted it, two nodes could believe they own the partition and writes would diverge. Instead node-12 knows it has handed ownership over, sees the stale epoch and answers 421 or a retryable WRONG_EPOCH with the new map version. The router refreshes and retries. Invariant: a key resolves to one partition and routing epoch, and a stale route produces a redirect, retry or bounded rejection, never a second authoritative owner.

A safe cutover sequence

Moving a partition is a sequence of small steps because each step has to be crash-safe. First the target node is added as a non-voting learner and copies a snapshot while the source keeps serving. Second, it catches up on log entries written since the snapshot. Third, when the lag is small, the control store atomically changes the map to the new owner with epoch 10. Fourth, the source stops accepting writes for that partition, forwards stragglers, and eventually deletes its copy after a safety delay. If anything fails before step three, the source remains authoritative and the move is abandoned; if the control store is unreachable, no cutover happens. Movement is throttled, because copying a 2.0 GB partition competes with foreground work, and the migration's progress is a dashboard item so a stuck move is noticed.

Visual Design Journey scenes 14 to 16 walk through the partition map, a stale router being redirected and the rebalance with epoch cutover.

Chapter 09

Single-Node Failure: Replicate, and Define Commit

Partitioning raised capacity and lowered durability: lose the one node that owns partition 417 and its 2.0 GB, and everything that was acknowledged for those keys, is unavailable or gone. The fix is replication, keeping the partition on several nodes. But replication alone is only copying; what makes it safe is a rule for when a write counts as committed.

Meridian uses a replication factor of three (RF=3) with the replicas in separate failure domains (different racks or availability zones), so one rack or power feed does not take them all. For partition 417 the three replicas are named A, B and C. A is the leader: it orders every write for the partition by appending it to the replicated log; B and C are followers that receive the log from the leader. Each partition elects its own leader, so leadership is spread across nodes and the whole cluster is not funnelled through one machine.

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.
Leader A appends evt-024 and sends it to B and C. The entry commits once a majority holds it durably; only then does the client see success.

What “committed” means

A quorum is a majority of the voting replicas: with three replicas, two. Leader A writes evt-024 to its own WAL (log index 118, term 4), sends it to B and C, and counts itself plus any follower that confirms a durable write. When B confirms, two of three hold it; the leader advances its committed index to 118, applies the entry to its state, and replies to the client with version 8. C may be slow, and that is fine: the commit did not wait for it. No follower commits an entry before the leader has seen a majority; the arrows in the diagram go outward and the commit decision comes back to the leader.

Why a majority? Any two majorities of three overlap in at least one replica. If a new leader must win votes from a majority, at least one voter has every committed entry, and the election rule only favours candidates whose logs are at least as up to date. That is how a new leader is guaranteed to hold everything a previous leader acknowledged. Raft formalises this; the design here is Raft-like as a conceptual model and does not claim to be any specific product's internals.

Replicas aliveWrites commit?Strong reads possible?Comment
3 of 3YesYesNormal operation
2 of 3Yes (majority = 2)YesOne replica failure tolerated; a new election if the leader was the one lost
1 of 3NoNo — no quorumRejects with 503; never accepts a write it cannot commit
0 of 3NoNoPartition unavailable until replicas return
RF=3 tolerates one failure for commits. The common shorthand that any two can be lost and writes still succeed is false; RF=5 (majority 3) tolerates two.

Replication also has a price: three replicas triple write work (the 600,000 replica applies at peak in chapter 3) and add a network round trip, within the home region, to every commit. Visual Design Journey scenes 17 to 19 show a single copy being lost, the replica group appearing, and evt-024 committing on a majority.

Chapter 10

Read and Write Consistency: Which Answer Do You Get?

With replicas comes a question that did not exist on one node: when a client reads, which copy answers, and how old can its answer be? The store exposes that choice as a named read mode, because the right answer differs by call site.

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.
Follower C is behind: a read served by C returns version 7 while the leader already committed version 8. Different read modes pick different replicas and accept different staleness.
Read modeServed byWhat you may seeCost
linearizableLeader after confirming it is still leader (read-index or lease), or a quorum-confirmed readLatest acknowledged write, never olderExtra confirmation step; unavailable without a quorum
follower-staleAny replica, optionally bounded by max stalenessPossibly old data; labeled in the responseCheap, spreads read load, may lag behind
read-your-writesA replica that has applied at least the caller's session version floorYour own acknowledged writes, but not necessarily others'Needs a session token; may wait or redirect
eventualRemote region or leaderless replicaOld data that converges laterLowest latency; weakest guarantee

Take evt-024. Leader A commits version 8; the client gets success. Follower C has not yet applied index 118. A follower-stale read of atlas/user42/settings routed to C returns version 7, which is a legal answer for that mode and a bug if the product promised something stronger. A linearizable read is served by A, which first confirms it is still the leader (either by exchanging heartbeats with a majority to establish a read index, or under a time-bounded lease), and returns 8. For a caller that just saved a setting, read-your-writes gives the better cost: the client keeps the committedVersion as a floor, and any replica that has not reached it either waits briefly or hands the request on.

Stale reads are a feature when you ask for them

Serving every read from the leader would pin 1,000,000 reads/s onto 1,024 leaders. Follower reads let read-heavy, staleness-tolerant calls (dashboard views, background jobs) use the other replicas. The contract is that the *API* says which mode was used, so a client never receives stale data while believing it is fresh.

When the quorum is gone

A partition that loses its majority has to choose. The reference store is CP in the CAP sense for strong operations: the minority side refuses writes, and refuses linearizable reads, answering 503. The reason is concrete. If the minority side kept accepting writes, and the majority side also elected a leader and accepted writes, two histories would exist for the same key and no automatic rule could pick one without discarding acknowledged writes.

Swipe to pan
Network partition: majority commits, minority rejectsPartition: only the majority side may commitRF 3; the link between A and the others is cut. CAP applies during this partition, not all the timeMinority side (1 of 3)A · old leaderterm 3, aloneClient 1Put → ApartitionMajority side (2 of 3)B · new leaderelected, term 4CfollowerClient 2Put → BClient 1 gets503 UNAVAILABLEA cannot reach a majority, so itcannot commit. It never says OKfor a write it cannot make durable.Client 2 gets200 OK, committed by B + CB and C elect within roughly anelection timeout (the Raft papersuggests values like 150–300 ms).Timing is not freeUntil the election finishes there isno leader: writes fail or wait.Reads in linearizable mode on Amust fail too: A cannot prove leadership.Two failures out of three leaves no majority anywhere: no commits and no strong reads. RF 5 needs 3 to commit and survives 2.
A network partition splits the group. The side with two replicas keeps a leader and commits; the side with one replica cannot, and rejects instead of guessing.

A leaderless design makes the opposite choice, and the journey shows it as a labeled alternative in scenes 27 to 30. In that model a coordinator sends each write to N replicas and waits for W acknowledgements, and a read asks R replicas and takes the newest. With N=3, R=2 and W=2, R + W = 4 > 3, so every read quorum overlaps every write quorum in at least one replica. That overlap is an intuition for how a read can find a recent write, not a linearizability guarantee: sloppy quorums that use stand-in nodes, concurrent writes, clock skew, partially failed writes and membership changes all break it. Such stores answer conflicts with version vectors or siblings, or by last-writer-wins on timestamps, and they repair divergence with hinted handoff, read repair and anti-entropy. Cassandra, for instance, treats hinted handoff as best-effort and relies on anti-entropy repair for the eventual-consistency guarantee. It is a valid design for a different contract; the reference does not borrow its guarantees.

Visual Design Journey scenes 20 to 23 show a stale follower read, the linearizable read, the minority partition rejecting a write, and leader fencing; scenes 27 to 30 are the labeled leaderless branch, and scene 30 returns to the reference.

Chapter 11

Concurrent Updates and Compare-and-Set

Two admins open Atlas user 42's settings at the same time. Both read version 7. One changes the theme, the other changes the notification policy, and both save. Without a guard, the second save silently overwrites the first, and nobody ever sees an error. This is the lost update.

The guard is compare-and-set (CAS), exposed as Put(key, value, expectedVersion). The write applies only if the key's current version equals expectedVersion; otherwise it is rejected with 412 and the caller re-reads, merges and retries. What makes this safe is *where* the comparison runs. If the client reads version 7, checks it, and then issues an unconditional write, another client can sneak in between the check and the write. A check-then-put in application code is not CAS. The comparison and the update must be one atomic step inside the replicated state machine, executed by the partition leader in log order.

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.
Both clients submit ifVersion=7. The leader orders them: the first commits as version 8, the second finds the version is now 8 and gets 412.

Walk it through. Client 1 sends Put(atlas/user42/settings, ifVersion=7) and client 2 sends the same a moment later. Leader A appends them to the log as entries 118 and 119. Both entries contain the precondition, and the state machine applies entries in order on every replica. When entry 118 applies, the version is 7, so it succeeds and the version becomes 8. When entry 119 applies, the version is 8, the precondition fails, and the state machine records the outcome as a rejection. Every replica reaches the same conclusion because they apply the same entries in the same order, and the leader returns 412 to client 2, with the current version so it can retry without another read. Exactly one writer wins.

The same idea for deletes and for retries

Delete(key, expectedVersion) uses the same mechanism. Retries are a separate hazard: a client that times out does not know whether its write committed. It retries with the same idempotency key, a client-supplied identifier stored with the committed entry for a documented retention window. The state machine recognises the repeated key and returns the original result instead of applying twice; reusing a key with a different payload is a 409. See the idempotency lesson for the general pattern, and race conditions for how this failure presents in production.

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 hides whether the commit happened. Retrying with the same idempotency key returns the original result; a fresh key would risk applying twice.

The limits matter too: CAS protects one key. Updating two keys together atomically needs transactions, which V1 does not offer. Visual Design Journey scenes 24 and 25 show the two-writers race and the ambiguous timeout.

Chapter 12

Leader and Node Failure: Terms, Fencing and Recovery

Replication tolerates a dead follower trivially. A dead leader is harder because everyone has to agree on a successor, and a leader that is only *believed* dead must be stopped from continuing.

Each partition group counts terms: a term is a number that increases every time a leader election starts. Followers expect heartbeats from the leader. If a follower hears nothing for a randomized election timeout, it increments the term, becomes a candidate and requests votes. The Raft paper suggests randomized timeouts in the range of 150–300 ms (implementations differ), the randomness being there so that candidates rarely tie. A candidate wins when a majority votes for it, and a voter grants its vote only if the candidate's log is at least as up to date as its own. That rule is what carries committed entries across leaders.

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.
Leader A is slow, not dead. B wins term 5. When A returns, it sees a higher term, steps down, and its stale writes are refused.

The old leader that does not know

Say leader A in term 4 stalls for a garbage-collection or I/O pause. B and C elect B as leader in term 5. A wakes up still believing it leads. If A accepted evt-025, it could send it to C, but C is in term 5 and rejects messages from term 4, and A cannot gather a majority anyway. Because every request, vote and log append carries the term, a stale leader discovers the truth the first time it talks to anyone and steps down. This is fencing: using a monotonically increasing token (term, or epoch for routing) so that actions by an obsolete actor are refused. For reads, a leader that served a linearizable read under a lease must stop doing so when its lease expires, which is why leases depend on bounded clock drift.

Recovery from disk

A crashed replica restarts, loads its latest snapshot, replays its durable log, and asks the leader to fill in what it missed. If its log has entries the new leader never committed (written by A in term 4 but not replicated), the leader's log wins and those uncommitted entries are truncated. They were never acknowledged, so no client was promised them. If the replica has been gone so long that the leader has already discarded the log it needs, it receives a fresh snapshot instead. Recovery traffic is throttled so a returning node does not starve foreground work.

EventWhat the store doesClient sees
Follower diesLeader keeps committing with the remaining majorityNothing, apart from reduced headroom
Leader diesElection in a new term, then commits resumeA short window of retryable errors or higher latency
Leader isolated from majorityIt cannot commit and steps down; majority elects anotherRejections from the old leader, success via the new one
Two replicas lost (RF=3)No quorum503 for strong operations until quorum returns

Visual Design Journey scenes 22, 23 and 26 show the minority side, leader fencing, and a replica recovering from its log; the failure side of this chapter is the subject of the failure and scale scenarios page.

Chapter 13

Scaling Without Collapse: Compaction, Hot Keys and Tenant Budgets

A store that is correct at rest can still collapse under its own housekeeping or under one popular key. Growth adds three kinds of pressure the earlier chapters did not need to name: background work that competes with foreground traffic, skew that defeats even spreading, and one tenant's burst landing on everyone.

Compaction debt

Chapter 6 showed that compaction pays for cheap writes. If writes arrive faster than compaction can merge them, SSTables accumulate; every read must check more files, read latency rises, disk fills with stale versions, and eventually the engine has to slow or stall writes to let compaction catch up. The accumulation is compaction debt. Speeding compaction up helps the debt but steals disk and CPU from foreground reads, so the cure is a *bounded* rate: enough to keep debt flat, with a visible P99 latency impact. The metrics worth watching are pending compaction bytes, files per read, and free disk against the 60% target from chapter 3.

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: when ingest outruns merging, files per read climb and P99 follows. The remedy is a rate limit and capacity, not unbounded merging.

The hot key

Return to atlas/feature=checkout_policy, a configuration value every Atlas checkout reads. In the simplified 100-partition illustration, average partition load is 2,000 reads/s and this single key draws 60,000/s: 30× the average partition. A replica serves 25,000 point reads/s under the assumed budget, so 60,000 needs at least 3 serving replicas, and three leave 80% utilization and little headroom. Moving the partition to a bigger node, or adding more partitions, does nothing for this key, because it still lives in exactly one partition.

Swipe to pan
Hot partition histogram: one key cannot be spread by hashingOne hot key defeats uniform hashingSimplified 100-partition illustration (the fleet plan uses 1,024); 20 of 100 bars drawn60k0reads/savg 2,000/s60,000/s (30×)partition index (hash of key picks one; a single key never splits)Why hashing can't helpThe hot key is one key, so itlives on one partition group.Fleet is 6% busy at peak, yetthis group is the bottleneck.Mitigations by operationReads (spread out)Need ≥ 3 replicas at 25,000/s:60,000 ÷ 25,000 = 2.43 × 25,000 = 75,000 → 80% busyFollower reads (labeled stale),near cache with TTL (optional),request coalescing.Writes (not spread)One key = one leader = oneserial order of writes.Salting or fan-out does not fixa single key's write rate.Key splitting only works forread-mostly data you can shard.Operate itDetect with per-key / per-partition heat metrics, notnode averages.Rate-limit the tenant, shedload, and tell the owner tocache or redesign the key.
Per-partition load: nearly uniform except one partition carrying a single key's traffic. Averages hide it.

The options all trade something. Letting followers serve the key spreads reads over three replicas but returns follower-stale data, which is acceptable for a policy that changes rarely if the product says so. A client or near-cache with a short TTL removes most reads from the store altogether, at the price of staleness up to the TTL; the cache is optional and explicitly layered on top, not a change to the store's semantics. Request coalescing merges simultaneous identical reads so one backend read serves many callers. Splitting the key into copies is valid only for read-mostly data. And writes to that key remain serialized through one leader: salting or fanning out a key does not help a single hot key's writes, because a conditional update must still be ordered in one log.

Per-tenant budgets

Atlas's backfill job starts pushing 150,000 writes/s. Without limits it eats the shared commit pipeline and Birch's writes see queueing. The store needs per-tenant quotas enforced at the front (requests, bytes, concurrent operations), a bulkhead so that overflow in one tenant's queue cannot occupy every worker, and a visible 429 telling Atlas to slow down, instead of latency creep for everyone. See bulkheads, rate limiting and load shedding. Rebalancing and repair are throttled for the same reason, and during overload the store follows an explicit policy: reject new work with 429 or 503 before accepting writes it cannot finish.

Visual Design Journey scenes 34 to 36 show compaction pressure, the hot key, and the hot-tenant isolation.

Chapter 14

Delete, TTL and Repair: Logical Absence Is Not Physical Removal

Deleting a record sounds like the easiest operation: remove it. In a replicated, immutable-file store, removing the bytes from one place does not make the record gone, and may let it come back.

Atlas deletes atlas/user42/settings at version 9. If the leader simply erased its copy, a replica that was offline and still holds version 8 could, when it returns, look like the only copy and teach everyone that version 8 exists. Immutable SSTables add a second reason: an older file still contains the old value, and the engine cannot edit it. So a delete is written as a new, versioned, replicated entry: a tombstone that says "as of version 9, this key is deleted".

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.
Without a retained tombstone a lagging replica's old copy resurrects the key. With the tombstone, the replica's old value loses to the newer delete.

Reads treat a tombstone as 404 while it is the newest version; older values beneath it are shadowed. Tombstones are themselves data and are kept for a tombstone retention period (Cassandra calls it gc_grace_seconds; its documented default is 864,000 seconds, or 10 days) and then dropped during compaction. The rule that sets the length: retention must exceed the longest time a replica can be out of the cluster or unrepaired before it is repaired. If a replica returns after 11 days against a 10-day retention, the tombstone may already be gone and the replica's old value can come back as a zombie. This page uses 10 days as a teaching value.

Repair keeps replicas honest

Lagging replicas converge by replaying the log in the reference design; in addition a background anti-entropy process compares replica contents (typically by comparing hash trees of key ranges) and fixes differences, bounded in rate so it does not compete with foreground traffic. Read repair, where a read notices stale copies and updates them, belongs to leaderless designs; in the leader-based reference the log is the repair path and the leader's committed order is authoritative. Whichever mechanism is used, repairs must carry tombstones along with values, or repair itself would spread the zombie.

TTL is a delete scheduled by a clock

A record can carry an expiry time. This is not cache eviction: eviction drops an entry for memory pressure and is allowed to forget, whereas expiry is a promise that the record is absent after a given time and present before it. Because it depends on time, clock skew matters: a replica whose clock runs fast may hide a record early. The reference decides expiry from a timestamp agreed through the log (the expiry is part of the entry and checked against a bounded-skew clock on read), and at physical removal time writes a tombstone through the normal path so replicas agree.

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.
TTL: logically absent at the deadline, physically present until compaction and backup retention catch up. The two timelines are different.

The consequence is stated in the contract rather than avoided. A deleted or expired value is logically absent immediately, while old copies persist in older SSTables until compaction rewrites them, in snapshots until they age out and in backups until retention ends (an example policy of 35 days means up to 35 days after a delete before the bytes leave backups). Erasure is bounded by those processes, never instant, and any privacy-driven deletion promise has to be worded to match. Visual Design Journey scenes 31 to 33 show the logical delete, the resurrection hazard and TTL expiry, and scene 37 covers backup and restore.

Chapter 15

Cross-Region Deployment: Home Region, Lag and Failover

Customers in the US and EU want local latency, and a whole region can fail. Stretching a quorum across oceans makes every commit pay a wide-area round trip, so the reference keeps the commit path inside a home region and ships the log onward asynchronously.

Meridian's records are homed in EU: the partition leader and its follower quorum live there, and commits finish in-region. A US replica receives the committed log asynchronously. Reads in the US served locally are eventual: they can be behind. If a US caller needs the latest value, the request is routed to the EU home region and pays the round trip. Nothing about asynchronous replication gives global strong consistency; it gives locality for reads and a warm copy for recovery.

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 commits in-region and ships to US asynchronously. Local US reads are fast and may be stale; a strong read crosses to EU.

Lag becomes data-loss exposure

Suppose the US replica trails by 300 ms (assumed). EU has acknowledged version 11 of a record; US shows version 10. If the EU region disappears at this moment, whatever has not yet arrived is lost to a failover. With EU receiving 30% of average writes (an assumed 12,000 writes/s), up to 12,000 writes/s × 0.3 s = 3,600 acknowledged-but-unreplicated writes are at risk at that instant. That figure is the RPO (recovery point objective: how much acknowledged data can be lost) in concrete terms. It depends on lag, and it is never zero. The RTO (recovery time objective: how long to restore service) is the other number; any value you state is a labeled example.

Swipe to pan
Region failure: RPO is lag-dependent exposure, and promotion needs fencingLosing the home region: RPO, promotion and fencingHypothetical inputs: 12,000 EU writes/s (30% of average 40,000) and 300 ms lagEU — lostacknowledged up tov11unreachable, state unknownUS replicahas applied up tov10≈300 ms behind at the cutExposure at the instant of loss12,000 writes/s × 0.3 s= up to 3,600 acknowledgedwrites not yet in the US.That is RPO: lag-bound, not 0.Operator decisionA. Wait for EU recoveryNo acknowledged write is lost; EU data staysin its region. Cost: EU records are read-only orunavailable for as long as the region is down.Best when the outage is short or RPO must be 0.B. Promote the US replicaAvailability returns, but up to ~3,600 writesare missing until EU data is reconciled.Needs the old leader fenced (higher epoch) soEU cannot accept writes on return, and residencyrules must permit EU records in the US.Safety rules for a promotion• Fence first: new term/epoch on the US group, so a returning EU leader cannot accept writes (no split brain).• Record the last-known EU version so lost writes (v11 and later) can be audited and replayed if EU recovers.• Do not silently serve EU-resident records from the US: residency is a policy decision, not a failover default.
Region loss: US takes over from version 10 with up to 300 ms of acknowledged writes missing. Promotion trades those writes for availability, and it is a decision, not an accident.

Failover, done safely

A safe regional failover needs, in order: a decision that EU is really gone (a human or a quorum of observers outside both regions, to avoid split brain); a fencing step so a returning EU cannot keep accepting writes in the old epoch; promotion of US to home with a new epoch; an explicit statement that remote-written data may be missing the last RPO window and that readers of linearizable see the new region's history; and after EU returns, reconciliation of the unreplicated tail rather than silent merge. Residency matters too: records bound to EU must not be silently promoted into a US region, so failover for them follows the residency policy, which may mean staying unavailable.

Visual Design Journey scenes 38 to 42 follow evt-024 through the home region, the lag, the outage, the safe failover and a labeled multi-active alternative.

Chapter 16

The Final Architecture and What It Guarantees

Every part of the finished design exists because an earlier version failed. Clients reach a router that consults a partition map; each partition is a replicated group with a leader and two followers, each replica running a WAL and an LSM engine; background compaction, repair and rebalancing run under rate limits; a control plane owns the partition map and epochs; quotas protect tenants from one another; and a home region commits in-region and replicates to remote regions asynchronously.

Swipe to pan
Final end-to-end architecture with control and data planesFinal architecture: data plane and control planeReference design, conceptual (Raft-like) replication; node counts are the assumed worksheetClientsSDKAPI gatewayauthn · tenant · quotaRouterpartition map · epochDATA PLANE1,024 partitions × RF 3= 3,072 replicas on 96 nodesPartition group p-0417 (one of 1,024) — one replica per failure domainLeader Azone 1replicated logLSM engineFollower Bzone 2replicated logLSM engineFollower Czone 3replicated logLSM enginemajority (2 of 3) commit before ack; followers answer labeled stale reads onlyCONTROL PLANEpartition metadata (consensus)placement · rebalancerrepair · tombstone GCmetrics · alerts · quotasUS async replicaeventual · ≈300 ms lag · labeledObject storagesnapshots + WAL archive, restore-testedControl plane changes metadata with epochs; the data plane keeps serving from cached maps.Router re-reads the map on a stale-epoch or 421 redirect; metadata loss must not stop reads and writes.
The final design. Each box is traceable to a failure earlier in the page.
ComponentIntroduced afterFailure it answers
WAL with fsync and group commitV0 crashAcknowledged writes lost with process memory
Memtable, SSTables, Bloom filters, compactionV1Working set larger than memory; reads must stay findable
Virtual partitionsCapacity wallSingle node limits; modulo hashing moves 75%
Partition map with epochsRoutingStale routes creating two owners
Three replicas, leader, majority commitNode lossOne copy is one failure
Terms and fencingLeader failureA stale leader accepting writes
CAS and idempotency keysConcurrent edits, timeoutsLost updates, double application
Tombstones, repair, TTL rulesDeleteZombie data
Quotas, throttled background workSkew and burstsOne tenant or one job starving the rest
Home region plus async replicasRegion lossWide-area commit latency; regional outage
Each component traces to a witnessed failure; a smaller deployment would omit many of them.

One Put, one Get, one CAS, two outages

A Put for evt-024: the router hashes the tenant-scoped key to partition 417, reads the cached map at epoch 9, and sends the call to leader A; A checks quota and the idempotency key, appends to its WAL (index 118, term 4), replicates to B and C, commits when two of three have flushed, advances its committed index, applies to the engine and returns version 8. A Get in linearizable mode is confirmed by the leader and returns version 8; in follower-stale mode it may return version 7 from C. A conditional write with ifVersion=8 succeeds once and the other racing writer gets 412. A single-node outage costs one replica's headroom or a brief election; a region outage promotes US with the stated RPO. Visual Design Journey scenes 44 to 46 replay the put path, the get and failure path, and the final mental model.

Swipe to pan
Failure decision matrix for RF=3Failure decision matrix (RF=3, majority = 2)Which operations stay safe? Stale follower reads are labeled and may return old dataScenarioQuorum?Linearizable readWriteStale readAll 3 healthyyes (3/3)safesafestale ok1 follower downyes (2/3)safesafestale okLeader down, before electionyes, no leaderunavailableunavailablestale onlyNew leader elected (2 up)yes (2/3)safesafestale ok2 of 3 replicas downNO (1/3)unavailablerejectedstale onlyLeader isolated aloneNO on its sidemust step downrejectedstale onlyRF=3 survives ONE failure for writes, not any two.RF=5 (majority 3) survives two, at higher write cost. Stale reads trade freshness for availability.
What the store does when each part fails: available, rejecting, stale or read-only. Every cell is a deliberate policy, not an accident.

What it guarantees, and what it does not

GuaranteeHolds whenDoes not mean
Acknowledged writes survive a single node lossA majority of the replica group flushed the entrySurvival of two simultaneous losses at RF=3
Single-key linearizable reads and writes in the home regionA quorum and an unfenced leader are availableMulti-key transactions or serializability
No false successAny condition without quorum, leader or spaceAvailability: strong operations are rejected with 503
CAS is atomic per keyThe comparison runs in the replicated state machineApplication check-then-put is safe
Deleted data is logically absentTombstone retention exceeds time between repairsInstant physical erasure
Remote regions convergeReplication resumesGlobal strong consistency, or zero RPO
Key takeaways
  1. 01A key-value store is a database when an acknowledged write is durable; that requires a WAL with fsync and then a replicated majority, not memory or an unflushed append.
  2. 02Partitioning by a fixed set of virtual partitions makes growth move about 25% of data (three to four nodes), where hash modulo moves 75%.
  3. 03Three replicas give single-failure tolerance only through a majority commit rule; losing two of three stops commits and strong reads.
  4. 04Terms and epochs fence stale leaders and stale routers; CAS inside the replicated log is the only safe conditional write.
  5. 05Tombstones outlive the longest repair gap; logical absence is immediate and physical removal is bounded by compaction and backup policy.
  6. 06Cross-region async replication buys locality and a recoverable copy, and its lag is the data-loss window.