0. The 5-Minute Version
0.1 The promise
A log-structured merge-tree (LSM tree) stores sorted key-value data so that writes are cheap and reads stay fast. It does it with three moves:
- Keep the newest data sorted in memory, in a table called the MemTable.
- Write it out as immutable sorted files. When the MemTable is full, we write it to disk in one sequential pass as a sorted file called an SSTable (sorted string table). A file is never changed after it is written.
- Merge the files in the background. A job called compaction merges SSTables, throws away overwritten and deleted data, and keeps the number of files a read must check small.
Nothing is updated in place. An overwrite is a new version and a delete is a new "deleted" marker; the newest version always wins.
Synthesizing vector architecture diagram...
What to notice: data only moves right. Files are created by flush and compaction and deleted after compaction replaces them; they are never edited.
0.2 What we assume from the log
This page pairs with the Write-Ahead Log loop primitive (Write-Ahead Log, fsync & Group Commit), which is not written yet. Here is the contract this page relies on, so it stands on its own:
- A write-ahead log (WAL) is an append-only file.
append(record)writes a record and makes it durable (withfsync, a call that forces the bytes onto the disk itself) before the write is acknowledged. Many writers can share onefsync; that is group commit. - Each record gets a sequence number (
seq) that goes up by one per write. The same number is the version of the entry in the LSM tree. One number, two jobs. replay(from_seq)returns the records in order after a crash and stops at the first damaged one.- The LSM tree tells the log one thing: "everything up to sequence N is safely in a flushed file." Log segments holding only records up to N may then be deleted. Section 1.6 gives the order that makes this safe.
0.3 The numbers to remember
| Number | Value | Section |
|---|---|---|
| Leveled write amplification | Per level: about typical, at most. For 1 TB at RocksDB defaults: about 22 typical, 40 at the bound | 3.3 |
| Size-tiered write amplification | About 1 per tier: 5 to 6 in total in our simulation | 3.4 |
| Bloom filter false positives | with bits per key: 0.82% at 10 bits (RocksDB's filter: 0.95%) | 2.2 |
| Sustainable write rate | , with the disk write bandwidth left for flush and compaction | 3.5 |
| Space amplification | Leveled about ; size-tiered can pass 2 | 3.3, 3.4 |
| Temporary space for one compaction | Leveled: a few files (RocksDB aims below 25 × 64 MB = 1.6 GB). Size-tiered: up to the size of the data being merged | 3.6 |
| Point read | About 1 data block plus about 0.01 wasted block reads per file checked | 2.5 |
0.4 When to choose what
| Question | Short answer | Section |
|---|---|---|
| LSM tree or B-tree? | LSM for write-heavy, random-key data larger than memory, or when storage and SSD wear matter. B-tree for read-heavy work, range queries, steady read latency. Neither always wins. | 5.1 |
| Which compaction policy? | Leveled for reads, updates and tight disks. Size-tiered for heavy writes with spare disk. Time-window for time series with a TTL. FIFO for caches. | 3.3, 3.4 |
| Why do writes stall? | Writes arrive faster than compaction can clean up, so the engine slows writers on purpose. Lower write amplification or add bandwidth; capping ingest at the compaction rate is not enough. | 3.5 |
| When is deleted data gone? | When compaction carries its tombstone to where nothing older can exist below it and no live snapshot needs the old value (in Cassandra, also after gc_grace_seconds). | 3.1, 3.6 |
0.5 Reading paths
| You have | Read |
|---|---|
| 5 minutes | Part 0 and the cheat card (7.4) |
| 20 minutes (interview prep) | Part 0, then 1.3, 1.5, 1.6, 2.3, 3.1, 3.3, 3.5, 5.1 and 7.3 |
| The whole mechanism | Everything in order; the Go deeper blocks are optional |
0.6 The tiny store
One small running example goes all the way through. Every trace on this page was produced by running a private reference implementation of this store, not written by hand.
| Item | Tiny store | At real scale (RocksDB 11.11 defaults) |
|---|---|---|
| Records | (seq, op, key, value), op is PUT or DELETE; keys like user:1, order:9 | Any byte strings |
seq | The log sequence number, also the entry's version | 56-bit sequence number |
| MemTable capacity | 4 entries | write_buffer_size = 64 MB |
| Block size | 2 entries per data block | block_size = 4 KB |
| Level 0 compaction trigger | 2 files | level0_file_num_compaction_trigger = 4 |
| Level size ratio | 2 (Level 1 up to 2 files, Level 2 up to 4) | max_bytes_for_level_multiplier = 10 |
| Compaction output file size | 4 entries (our assumption); the versions of one key are never split across files | target_file_size_base = 64 MB |
| Log segments | One per MemTable: wal-1 holds seq 1 to 4, wal-2 seq 5 to 8, and so on | One WAL file per MemTable |
The writes we follow. Snapshot S5 is a reader that asked to see the data "as of sequence 5"; it is taken after seq 5 and released after seq 16.
| seq | Operation | seq | Operation | seq | Operation |
|---|---|---|---|---|---|
| 1 | PUT user:1 alice | 9 | PUT user:5 erin | 17 | PUT order:9 vase |
| 2 | PUT user:2 bob | 10 | DELETE user:1 | 18 | PUT user:8 hana |
| 3 | PUT order:9 book | 11 | PUT order:9 lamp | 19 | PUT user:4 dan |
| 4 | PUT user:3 carol | 12 | PUT user:2 bea | 20 | DELETE user:5 |
| 5 | PUT user:1 alicia | 13 | PUT user:6 finn | 21 | PUT user:1 ali |
| 6 | DELETE user:2 | 14 | PUT user:3 cara | 22 | PUT order:1 mug |
| 7 | PUT order:7 pen | 15 | DELETE order:7 | 23 | PUT user:9 ivy |
| 8 | PUT user:4 dave | 16 | PUT user:7 gus | 24 | PUT user:6 fay |
0.7 Where this shows up
| Where | What it relies on from this page |
|---|---|
| Distributed key-value store loop, step 1.2 "The Data No Longer Fits in Memory" | The write path, flush, reads across levels, leveled compaction with ratio 10 |
| S3-like object storage loop, metadata store and "why compaction can stall writes" | Compaction debt, stalls, why not update in place |
| Discord's ScyllaDB case study | MemTables, SSTables, tombstones, time-window compaction |
| Ad-click event aggregation | Flink's RocksDB state backend (5.3) |
| Drill: The Time-Series Database That Froze on Flush | Both of its questions: section 3.5 and section 5.1 |
Part 1. The Write Side
1.1 Why not update in place?
The classic alternative is a B-tree: a balanced tree of fixed-size pages (16 KB in InnoDB, 8 KB in PostgreSQL), where each key lives in one leaf page. To change a row, the engine changes its page in memory and later writes the whole page back to the same place on disk.
That is less costly than it is often made to look, for two reasons. The B-tree engine also logs every change first, so a write is acknowledged after a small sequential log append, not a page write. And the buffer pool (its page cache) coalesces: if 8 rows on one page change before the page is written back, one page write covers all 8.
So a B-tree's write cost depends on how many changes land on a page of size before it is written back, with records of size . Pages also need protection against torn writes (a crash in the middle of a page write): InnoDB writes each page twice (the doublewrite buffer), and PostgreSQL logs a full copy of a page the first time it changes after a checkpoint. Call that extra factor . Per byte of user data:
| Case | Write amplification | |||
|---|---|---|---|---|
| Random keys, table far larger than memory | 16 KB | 1 KB | 1 | |
| Moderate locality | 16 KB | 1 KB | 8 | |
| Keys arrive in order (a page fills before write-back) | 16 KB | 1 KB | 16 |
Assumptions: , no compression, inner-node pages ignored (they are few and cached).
An LSM tree never writes a page in place: all its writes are sequential, and it pays later, in compaction, about 12 to 15 times in our simulation (section 3.3). So it writes fewer bytes when keys are random and the table is much larger than memory, and it can write more bytes than a B-tree when writes have strong locality. Reads are simpler in a B-tree. The full, equal-terms comparison is section 5.1.
1.2 The core idea and three invariants
The diagram in 0.1 is the whole structure. Three invariants (rules that are always true) hold it together:
| # | Invariant | What breaks without it |
|---|---|---|
| I1 | The newest data is in sorted in-memory tables (the active MemTable and any full ones waiting to flush), and also in the WAL | A crash loses acknowledged writes; flush cannot write a sorted file in one pass |
| I2 | Everything on disk is in immutable sorted files, organized into levels | Readers and backups see half-written data; writes become random again |
| I3 | The newest version wins: a place searched earlier (MemTable, then newer files, then lower levels) never holds an older version of a key than a place searched later | Reads return stale values; deleted data comes back |
I3 is why reads search in a fixed order (2.3) and why compaction must be careful about what it drops (3.1).
1.3 Internal keys, sequence numbers and tombstones
An LSM tree never stores a bare key → value. Each entry is an internal key plus a value:
| Part | Example | Why |
|---|---|---|
| User key | user:2 | What the application asked for |
| Sequence number | 6 | Which write this is; higher is newer |
| Type | PUT or DEL | A value, or a delete marker |
Entries sort by user key ascending, then sequence number descending, so all versions of a key sit together, newest first. We write an entry as key@seq, for example user:2@6 DEL.
- An overwrite is a new version.
PUT user:1 aliciaat seq 5 does not touchuser:1@1 alice; it addsuser:1@5, which sorts in front of it. - A delete is a new version, called a tombstone.
DELETE user:2at seq 6 writesuser:2@6 DEL. The old value may sit in an immutable file we cannot change, so the tombstone hides it until compaction removes both. - A read at sequence S sees, for each key, the newest entry with a sequence number at most S. A snapshot is exactly that: a sequence number a reader holds on to for a consistent view.
Smallest example. PUT a=1 at seq 1, PUT a=2 at seq 5, DELETE a at seq 8:
| Read at sequence | Newest visible entry | Result |
|---|---|---|
| 1 | a@1 PUT 1 | 1 |
| 5 | a@5 PUT 2 | 2 |
| 8 | a@8 DEL | not found |
| 9 | a@8 DEL | not found |
The tiny store's first eight writes, as internal keys:
| Internal key (sorted) | Value | Latest read | Snapshot S5 |
|---|---|---|---|
order:7@7 PUT | pen | pen | nothing (7 is after 5) |
order:9@3 PUT | book | book | book |
user:1@5 PUT | alicia | alicia | alicia |
user:1@1 PUT | alice | shadowed | shadowed |
user:2@6 DEL | tombstone | not found | ignored (6 is after 5) |
user:2@2 PUT | bob | hidden by the tombstone | bob |
user:3@4 PUT | carol | carol | carol |
user:4@8 PUT | dave | dave | nothing |
The cost: old versions and tombstones take space until compaction removes them.
1.4 The MemTable
The MemTable must stay sorted (so flush writes a sorted file in one pass, and scans work), accept many concurrent writers, and let readers search while writes go on. A hash table is not sorted. A balanced tree is sorted but rebalances near the root, which needs wide locks. The usual answer, in LevelDB and RocksDB, is a skip list: a sorted linked list with extra "express lanes". Every node is on lane 0; each node also joins the next lane up with probability (LevelDB and RocksDB use ). A search runs along the top lane and drops a lane whenever the next node would overshoot, so search and insert take expected steps, and an insert only changes a few pointers.
The first MemTable of the tiny store after four inserts, with lane heights fixed so the example repeats (user:1@1 = 1, user:2@2 = 2, order:9@3 = 1, user:3@4 = 3). The search for user:3 follows the thick arrows:
Synthesizing vector architecture diagram...
What to notice: lane 2 would overshoot straight away, so the search drops to lane 1, takes one step to user:2@2, and drops to lane 0, where the next node is the answer. That is 1 step right instead of 3 on lane 0 alone.
Memory and the flush trigger. The engine counts the MemTable's bytes. Past the limit (write_buffer_size, 64 MB), the MemTable becomes immutable, a new one takes writes, and a flush is scheduled. RocksDB keeps at most max_write_buffer_number MemTables (default 2) per column family, so MemTable memory defaults to up to 128 MB.
Go deeper: the skip list algorithm.
textSEARCH(list, target) -- target is an internal key (user key, seq) x = list.head for lane from top down to 0: while x.next[lane] != END and x.next[lane].key < target: x = x.next[lane] -- move right return x.next[0] -- first entry >= target INSERT(list, entry) h = 1; while random() < p: h = h + 1 walk as in SEARCH, remembering the last node before entry on each lane for lane from 0 to h - 1: -- bottom lane first, so readers never see a gap entry.next[lane] = remembered[lane].next[lane] remembered[lane].next[lane] = entry
RocksDB also offers a vector MemTable (fast bulk loads, sorted only at flush) and hash-indexed MemTables that speed up lookups within a key prefix at the cost of full-order scans.
1.5 The write path end to end
Synthesizing vector architecture diagram...
textWRITE(op, key, value) -- op is PUT or DEL 1. seq = next sequence number -- shared with the log 2. append (seq, op, key, value) to the current WAL segment and make it durable per the sync policy (group commit) 3. insert (key, seq, op) -> value into the active MemTable 4. make seq visible to new readers 5. acknowledge the client 6. if the MemTable is over its size limit: check the stall conditions (3.5): maybe delay or wait freeze it as immutable; start a new MemTable and a new WAL segment schedule a flush (1.6)
Step 2 comes before step 5, so a crash can only lose writes nobody was told succeeded. Step 4 comes before step 5, so a client that reads its own key right after the acknowledgment sees it. A new WAL segment per MemTable lets the engine delete a whole segment once its MemTable is on disk.
Trace: the first nine writes (4-entry MemTable):
| After | MemTable | Disk | Log segments |
|---|---|---|---|
| seq 4 | full: frozen and flushed | F1 at Level 0: order:9@3 book, user:1@1 alice, user:2@2 bob, user:3@4 carol | wal-1 released |
| seq 8 | full: frozen and flushed | F2 at Level 0: order:7@7 pen, user:1@5 alicia, user:2@6 DEL, user:4@8 dave. Level 0 now has 2 files, the trigger, so compaction merges them into Level 1 (3.2) | wal-2 released |
| seq 9 | user:5@9 erin | Level 1: F3 order:7@7 pen, order:9@3 book, user:1@5 alicia; F4 user:2@6 DEL, user:2@2 bob, user:3@4 carol, user:4@8 dave | wal-3 open |
In that first compaction user:1@1 alice was dropped (snapshot S5 reads the newer user:1@5, so no reader can see @1), while user:2@2 bob was kept because S5 still reads it.
How it breaks: if flushes cannot keep up, immutable MemTables pile up, and when all max_write_buffer_number of them are full, writes stop (3.5).
1.6 Flush
A flush turns an immutable MemTable into a Level 0 SSTable. The naive way (write the file, then delete the WAL) has a gap: a crash between the two, in the wrong order, loses data.
textFLUSH(imm) 1. write imm's entries in sorted order into a new file f: data blocks, then the filter, index and footer (2.1) -- one sequential pass 2. fsync f, then fsync its directory -- the bytes and the name are durable 3. append to the manifest: { add f at Level 0, "logs before wal-(n+1) not needed" } and fsync the manifest 4. readers start using f instead of imm 5. only now delete the released WAL segment
The manifest is a small log that lists which files make up the tree (4.1). A file on disk that the manifest does not list does not exist for the engine.
Synthesizing vector architecture diagram...
Synthesizing vector architecture diagram...
Flushing F2 takes four steps: write F2, fsync it and its directory, fsync a manifest edit that adds F2 and releases wal-2, delete wal-2. Why must the manifest edit come after the file sync and before the WAL is deleted?
A note on duplicates: our tiny store's flush writes every entry of the MemTable, as LevelDB does. RocksDB's flush also drops versions no snapshot can see, using the same rules as compaction (3.1).
Part 2. Files and the Read Side
2.1 The SSTable
An SSTable is sorted entries split into blocks, so a read fetches only the small block it needs. Scanning a whole file for one key would read megabytes; the layout below reads one block.
Synthesizing vector architecture diagram...
What to notice: a lookup touches the filter, the index and one data block. Keep the first two in memory and a lookup costs at most one disk read.
| Part | What it holds |
|---|---|
| Data blocks | Entries in internal-key order, about 4 KB each before compression; each block compressed on its own (LZ4 by default in current RocksDB when available; older versions used Snappy) |
| Index block | One entry per data block: a separator key (at least the block's last key, below the next block's first) and the block's position |
| Filter block | A Bloom filter over the file's keys (2.2) |
| Footer | Fixed-size tail pointing to the index and to the block that locates the filter |
| Block cache | Not in the file: memory holding recently used blocks |
textFILE_GET(file, key, read_seq) 1. if key is outside the file's [smallest, largest]: not here 2. if the filter says "certainly absent": not here, no disk read 3. binary search the index for the first block whose separator >= (key, read_seq) 4. read that block (block cache or disk) and search inside it 5. return the first entry for key with seq <= read_seq, if any (it may continue into the next block)
Worked size for a 64 MB file (24-byte key, 200-byte value, about 11 bytes of overhead, so 235 bytes per entry, before compression):
| Item | Arithmetic | Result |
|---|---|---|
| Entries per block | 17 | |
| Data blocks | 16,384 | |
| Entries in the file | about 285,000 | |
| Index | bytes | about 608 KB |
| Filter at 10 bits per key | about 349 KB |
About 1 MB of index and filter per 64 MB file: about 16 GB for 1 TB of data. If that does not fit in memory, lookups pay extra disk reads just to consult them, which is why it is a sizing question (6.1).
Trace: one lookup. GET order:7 in F3 ([order:7@7 pen, order:9@3 book], [user:1@5 alicia]): the key is in range, the filter says maybe, the index points to block 0, and block 0 holds order:7@7 pen.
Go deeper: inside a block. Sorted keys share prefixes, so each key stores only how many bytes it shares with the previous key plus the new suffix (prefix compression). To keep binary search possible, every 16th key (block_restart_interval = 16) is stored in full as a restart point, and the block ends with an array of restart offsets. A search binary-searches the restart points, then scans forward at most 16 keys.
2.2 Bloom filters in an LSM tree
A Bloom filter is a bit array and hash functions. Adding a key sets its bits; testing a key checks them. Any zero bit means "certainly absent"; all ones means "probably present". It never says absent for a key that is there. The structure itself is covered in Primitive #03: Bloom filters.
With bits per key and hashes:
| Bits per key | Best | False positives |
|---|---|---|
| 5 | 3 | 9.2% |
| 8 | 6 | 2.2% |
| 10 | 7 | 0.82% |
| 16 | 11 | 0.046% |
Our reference filter measured 0.83% at 10 bits per key. RocksDB's filter is documented at 0.95%: it keeps a key's bits in one cache line to save CPU, at a small cost in accuracy. That is the source of the rule of thumb "10 bits per key, about 1%". RocksDB does not build a filter unless one is configured.
Wasted reads. A lookup for an absent key checks up to one filter per candidate file. With 4 Level 0 files and 6 deeper levels, that is 10 files: wasted block reads, against up to 10 disk reads with no filter.
Range scans get no help. A scan of [user:1, user:4] is not a membership test for one key; it must visit every file whose range overlaps. Filters make point reads cheap, not scans (2.4).
2.3 The point-read algorithm
Checking every file for every read would make reads slower as the tree grows. Invariant I3 lets a read stop early.
textGET(key, read_seq = latest) 1. search the active MemTable, then immutable MemTables, newest first 2. search every Level 0 file whose range covers key, newest first -- they overlap 3. for each deeper level: the one file whose range covers key -- no overlap 4. inside each file: skip it if the filter says no (FILE_GET, 2.1) 5. the FIRST entry with seq <= read_seq wins: PUT -> its value DEL -> not found nothing anywhere -> not found
Synthesizing vector architecture diagram...
Trace. State after seq 14, with snapshot S5 alive:
| Where | Contents |
|---|---|
| MemTable | user:3@14 cara, user:6@13 finn |
| Level 0 | F5 [order:9@11 lamp, user:1@10 DEL] [user:2@12 bea, user:5@9 erin] |
| Level 1 | F3 [order:7@7 pen, order:9@3 book] [user:1@5 alicia]; F4 [user:2@6 DEL, user:2@2 bob] [user:3@4 carol, user:4@8 dave] |
| Read | MemTable | F5 (Level 0) | Level 1 | Answer |
|---|---|---|---|---|
user:3 (hit) | user:3@14 cara | not searched | not searched | cara |
user:2 (overwritten) | none | filter maybe, index → block 1: user:2@12 bea | not searched | bea |
user:1 (deleted) | none | filter maybe, index → block 0: user:1@10 DEL | not searched | not found |
order:7 (older file) | none | outside F5's range [order:9, user:5]: skipped | F3 block 0: order:7@7 pen | pen |
user:9 (absent) | none | outside F5's range: skipped | no Level 1 file covers it | not found |
user:2 at S5 | none | only @12, newer than 5 | F4: skips @6 DEL, finds @2 bob | bob |
After the later compactions (section 3.2's tiny-store run), the filters do the skipping: GET user:2 finds Level 1's F13 covers the key but its filter says no, and goes on to Level 2.
How it breaks: read cost grows with the number of Level 0 files and levels (2.5).
2.4 Range scans
A range scan returns every live key in [lo, hi] in order. The data is spread across the MemTables, every Level 0 file and each deeper level, all sorted. The engine merges them with a merge iterator: one child iterator per source, and a min-heap (a priority queue that always yields the smallest entry) holding each child's current entry.
Synthesizing vector architecture diagram...
textSCAN(lo, hi, read_seq) seek one iterator per source to lo; put each current entry on a min-heap last_key = none while the heap is not empty: e = pop the smallest; advance that iterator and push its next entry if e.key > hi: stop if e.seq > read_seq: continue -- too new for this reader if e.key == last_key: continue -- older version, shadowed last_key = e.key if e is a tombstone: continue -- deleted emit (e.key, e.value)
Trace, SCAN [user:1, user:4] on the state in 2.3:
user:1@10 DEL(F5): tombstone, hideuser:1. Thenuser:1@5 alicia(Level 1): shadowed.user:2@12 bea(F5): emit. Thenuser:2@6 DELanduser:2@2 bob: shadowed.user:3@14 cara(MemTable): emit. Thenuser:3@4 carol: shadowed.user:4@8 dave(Level 1): emit.
The same scan at snapshot S5 skips everything newer than 5 and returns user:1 alicia, user:2 bob, user:3 carol.
Why scans cost more: every source must be positioned and filters cannot rule any out, and every old version and tombstone in the range passes through the heap before being thrown away. A queue-like table that deletes from the front and scans from the start can step over thousands of tombstones before its first live row (3.7, 6.2).
2.5 Read amplification
Read amplification is the work a read does beyond the one block holding its answer. With Level 0 files, deeper levels, false-positive rate and index and filter blocks cached:
Synthesizing vector architecture diagram...
Upper line: no filters, one block read per file checked. Lower line: 10-bit filters (0.95% false positives), about 0.1 block reads at most.
| Case | Result |
|---|---|
| Healthy: , | 1 useful block + about 0.1 wasted |
| Behind on compaction: (the default slowdown point) | 1 + about 0.25 wasted, and 26 filter checks in CPU |
| Index and filter not cached | up to 2 more block reads per file consulted |
A range scan costs at least one seek per source, however short the range. Where the block cache helps: keep index and filter blocks cached first (every read touches them), then hot data blocks.
Part 3. Compaction: the Algorithm and the Policies
3.1 The merge algorithm
Without compaction, files pile up: reads check more and more of them, and overwritten and deleted data never leaves the disk. Simply deleting old files would lose keys that were never overwritten. Compaction instead merges sorted files into new sorted files that keep only what someone can still read.
textCOMPACT(inputs, output_level, live_snapshots) 1. merge all inputs in internal-key order (key ascending, seq descending) 2. for each key, walk its versions newest first: stripe(v) = the oldest live snapshot >= v.seq, or LATEST if there is none -- all versions in one stripe look the same to every reader if a newer version of this key is in the same stripe: drop v (shadowed) else if v is a tombstone and no live snapshot is older than v.seq and no file below output_level may hold this key: drop v (delete finished) else: keep v 3. cut the output into files of about the target size, never splitting one key's versions across files 4. write the outputs sequentially; fsync them and their directory 5. one manifest edit: { delete all inputs, add all outputs } -- atomic switch 6. delete the inputs once no reader still holds them (4.1)
The two drop rules are the hard part:
- Shadowed versions go once no snapshot sits between them and a newer version. With no live snapshot, only the newest version of each key survives.
- A tombstone goes only when both conditions hold. If an older version might sit in a deeper level this compaction does not read, dropping the tombstone would bring that value back. If a live snapshot is older than the tombstone, the snapshot may still need the older value, and the tombstone must stay to hide that value from everyone else.
3.2 A worked compaction
A Level 1 file A is picked and merged with the two Level 2 files that overlap its range [k:b, k:e]. One live snapshot is at sequence 30. Level 3 holds an old version of k:e.
Synthesizing vector architecture diagram...
With the snapshot at 30 there are two stripes: seq up to 30, and seq above 30.
| Merged entry | Decision | Why |
|---|---|---|
k:a@10 a1 | keep | Only version |
k:b@40 b3 | keep | Newest version (the overwrite) |
k:b@20 b1 | keep | The snapshot at 30 still reads b1 |
k:c@28 DEL | drop | No snapshot is older than 28, and no deeper file covers k:c: the delete is finished |
k:c@15 c1 | drop | Same stripe as the tombstone, so it is shadowed |
k:d@12 d1 | keep | Only version |
k:e@29 DEL | keep | Level 3's file C covers k:e and holds k:e@5 e0; dropping the tombstone would bring e0 back |
k:f@22 f1 | keep | Only version |
8 entries in, 6 out. Reads afterwards: k:b latest gives b3, at snapshot 30 gives b1; k:c and k:e give not found either way. k:e@29 DEL can go only when a compaction merges it with C at Level 3.
The same rules in the tiny store. After seq 16 the two Level 0 files F5 and F6 merge with Level 1 files F3 and F4 while S5 is alive. The tombstones user:1@10 and order:7@15 stay (S5 is older than them), alicia, bob, carol and book stay for S5, and the shadowed order:7@7 pen and user:2@6 DEL go: 15 entries in, 13 out, in files F7 to F10. Level 1 then has 4 files, over its limit of 2, so F7 and F8 move to Level 2. Nothing overlaps them there, so the engine does a trivial move: a manifest edit that relabels the file, without rewriting it.
S5 is released after seq 16. After seq 24, the next Level 0 merge meets user:5@20 DEL and user:5@9 erin with no snapshot alive and no deeper file covering user:5: both are dropped, and that delete is finished. Meanwhile F7 and F8 in Level 2 still carry book, alicia and bob, which nobody can read any more. They stay until a compaction touches those files again; RocksDB's periodic_compaction_seconds exists to force that eventually.
3.3 Leveled compaction
Leveled compaction (the default in LevelDB and RocksDB) keeps every level below Level 0 as one sorted run: its files never overlap, and each level's target size is times the one above.
Synthesizing vector architecture diagram...
What to notice: one file from above meets only its overlapping neighbours below; the rest of the level is not touched.
textPICK_COMPACTION() 1. score Level 0 as (file count / trigger); score each deeper level as (size / target) 2. take the level with the highest score above 1 3. Level 0: take all its files and the overlapping files of the base level deeper level: pick one file (RocksDB default: the smallest overlap with the next level relative to its size) and the overlapping files of the next level 4. if nothing overlaps below, just move the file (a manifest edit)
Level 0 is special: its files are flushed MemTables whose ranges overlap, so they are merged together into the base level.
Level targets at real scale. RocksDB 11.11 defaults: base size 256 MB (max_bytes_for_level_base), ratio 10, and since version 8.4 level_compaction_dynamic_level_bytes = true, which sizes the levels from the bottom so the last level holds about of the data. For 1 TB: about 1 TB, 102 GB, 10 GB and 1 GB. The next level up would be about 102 MB, below 256 MB, so the 1 GB level is the base level that Level 0 compacts into: 4 levels below Level 0.
Deriving write amplification for 1 TB, , 64 MB MemTables. Count how many times one byte is written:
- WAL: 1.
- Flush to Level 0: 1.
- Level 0 to the base level: 4 files × 64 MB = 256 MB merge with the overlapping base level, which is all of its 1 GB when keys are random. Writing 256 MB down costs MB: a factor of .
- Each deeper move: a file of size from level covers the fraction of the keys, so it overlaps about bytes of level . The merge writes : at most per level. It is lower in practice because the next level is not always full and the picker prefers files with little overlap.
Assumptions: uniform random keys, levels at their targets, no compression, the WAL counted. Keys written in order overlap less and do better.
Checked against the reference implementation (1 million writes, , Level 0 trigger 4, base level the size of one Level 0 batch, so step 3 costs about 2):
| Bytes written into | New keys every write | Keys rewritten about 4 times |
|---|---|---|
| Level 0 (flush) | 1.00 | 1.00 |
| Level 1 | 1.99 | 1.95 |
| Level 2 | 5.14 | 5.06 |
| Level 3 | 5.08 | 2.66 (last level, not full) |
| Level 4 | 0.55 (just started) | not reached |
| Total including the WAL | 14.8 | 11.7 |
A full level cost about 5 per byte, about , well under the bound of 11. In the tiny store the same effect is visible at a smaller scale: 24 writes were flushed once, and the three compactions then wrote 7 + 13 + 9 = 29 more entries.
Read amplification: at most the Level 0 files plus one file per level (2.5). Space amplification: stale copies in the upper levels add at most about of the data, so about plus one compaction's temporary files. Our update-heavy run measured 1.18 because its last level was not full.
Go deeper: stacked write amplification. An SSD has its own write amplification: to reuse space, it must erase whole flash blocks and copy live pages elsewhere first. The bytes that reach the flash are the product of both: . Large sequential writes and free space on the drive keep close to 1; a nearly full drive with small random writes can raise it several times. If we assume , an LSM at 22 writes about 33 bytes of flash per user byte, which is what drive endurance ratings must cover (6.4).
3.4 Other policies
Different workloads want different trade-offs, and the loops mention all of these.
- Size-tiered (Cassandra's default, STCS; RocksDB's universal compaction is the same family): wait for several files of similar size (Cassandra: at least
min_threshold= 4, within about 50% of each other) and merge them into one bigger file. Each byte is rewritten about once per tier: with tiers growing 4 times, 1 TB built from 64 MB flushes has about tiers. - FIFO: never merge; delete the oldest files once the total passes a size limit or they pass a time to live (TTL).
- Time-window (Cassandra's TWCS): group files by the time window of their data, run size-tiered compaction only inside the current window, and drop a whole window's file once all of its data has expired.
| Policy | Write amp | Point read | Space amp | Temporary space | Fits |
|---|---|---|---|---|---|
| Leveled | High: about per level (11.7 to 14.8 simulated) | Best: one file per level | Best: about 1.1 | A few files | Reads, updates, tight disks |
| Size-tiered / universal | Low: about 1 per tier (5.3 to 5.7 simulated) | Worse: many files | Worst: 2.8 at the end of our update-heavy run | Up to the data being merged | Heavy writes, spare disk |
| Time-window | Low | Good for recent data | Low when data expires | One window | Time series with TTL, written in time order |
| FIFO | Lowest: about 1 | Worst: every file | Bounded by the size limit | None | Caches, short-lived data |
The RUM conjecture (Athanassoulis et al., 2016) names the pattern: a storage method cannot make Read, Update and Memory (space) overheads all minimal at once. Leveled buys cheap reads and space with writes; size-tiered buys cheap writes with reads and space; FIFO gives up old data.
How they break: size-tiered needs free disk for its big merges (3.6); FIFO loses data by design; time-window handles late updates and deletes badly, because they land in new windows and cannot be merged away.
3.5 Scheduling, stalls and the feedback loop
Compaction runs in background threads. If it falls behind, Level 0 fills (reads slow down, 2.5) and disk fills (3.6). So the engine slows writers on purpose, then stops them, to keep the tree bounded.
Synthesizing vector architecture diagram...
| Condition (RocksDB 11.11) | Slowdown (writes delayed to delayed_write_rate, 16 MB/s when no rate limiter is set) | Stop |
|---|---|---|
| Level 0 files | level0_slowdown_writes_trigger = 20 | level0_stop_writes_trigger = 36 |
| Bytes compaction still owes | soft_pending_compaction_bytes_limit = 64 GB | hard_pending_compaction_bytes_limit = 256 GB |
| MemTables waiting to flush | on the last allowed one, if max_write_buffer_number > 3 | all max_write_buffer_number full |
The math. Each client byte costs WA bytes of flush and compaction writes. With bytes per second of disk write bandwidth for that work:
Synthesizing vector architecture diagram...
Ingest is 120 MB/s. The disks can write 40 MB/s of compaction output. Write amplification is 10. What ingest rate is sustainable? A colleague suggests capping ingest at 40 MB/s "to match compaction". Does that work?
Worked timeline. At 120 MB/s with 64 MB MemTables, a flush lands about every s. If compaction cannot drain Level 0, it grows from 4 files to the slowdown point of 20 in about s, and would reach the stop point of 36 about 8.5 s after that without the slowdown. Then writers wait until compaction brings Level 0 back under 36. This is the pattern in the drill The Time-Series Database That Froze on Flush: excellent latency most of the time, then a freeze of several seconds. Its first question, why writes stall completely, is answered here: flushes add Level 0 files faster than compaction can merge them, and the engine stops writers on purpose so reads and disk use stay bounded. Its second question is answered in 5.1.
3.6 Space, tombstones and disk-full
Temporary space. A compaction writes its outputs before deleting its inputs, so both exist at once.
- Leveled: a compaction is a few files; RocksDB tries to keep one under
max_compaction_bytes(default 25 ×target_file_size_base= 1.6 GB). A few GB per concurrent compaction is enough. - Size-tiered: merging the largest tier writes a file as large as its inputs minus what it drops. In the worst case (all the data in one merge, nothing dropped) that is free space equal to the data being merged, so the disk must stay at most about half full. Our simulation peaked at 2.97 times the live data during a merge. ScyllaDB's incremental compaction (ICS) splits big files into runs of about 1 GB fragments to remove this peak.
When deleted data really disappears: only when compaction brings the tombstone together with the old value and the tombstone reaches a point where nothing older can exist below it (3.1). Until then it costs space and scan time.
Zombies in replicated stores. In Cassandra, a replica that was down during a delete never got the tombstone. If the other replicas purge their tombstones and the lagging replica later shares its old value through repair, the deleted row comes back: a zombie. Cassandra keeps tombstones for gc_grace_seconds (default 864,000 s, 10 days) before they can be purged, and every node must be repaired within that window so every replica learns about each delete.
Disk-full. A compaction that runs out of space cannot finish, so it cannot delete its inputs, so no space is freed, while writes keep coming. Recover by adding space (or removing backups and snapshots), not by starting another compaction. Prevent it with headroom sized to the policy (6.1).
3.7 Deletes at scale
Deleting a billion keys one by one writes a billion tombstones. Each must be written to the WAL, flushed, scanned past by readers, and carried down the levels until compaction can drop it, so a big cleanup can slow reads for days.
The key deleted near the top of the tree takes longest to go: in 3.2, k:e@29 DEL sits in Level 2 because an older k:e is in Level 3, and it stays until a compaction merges it with Level 3.
The ways out:
| Way out | How it works | Where |
|---|---|---|
| Range delete | One marker covers a whole key range instead of one tombstone per key | RocksDB DeleteRange; Cassandra range and partition deletes |
| TTL | Data carries an expiry time, so no explicit delete is written | Cassandra per-row and per-cell TTL (expired cells then behave like tombstones until purged after gc_grace_seconds); RocksDB FIFO with a TTL |
| Drop whole files | Put data that dies together in the same files and delete the files | TWCS dropping fully expired windows; FIFO; RocksDB DeleteFilesInRange |
Designing the keys so data that expires together sits together is usually the cheapest delete of all.
Part 4. Keeping the Tree Consistent: Manifest, Versions and Recovery
4.1 Immutable files and version sets
A flush adds one file; a compaction deletes several and adds several. A reader must never see half of it: both the inputs and the outputs (duplicate data) or neither (lost data).
The manifest is an append-only log of version edits. Each edit lists files to add (with level and key range) and files to delete, plus bookkeeping: the last sequence number used and the oldest WAL segment still needed. The current version of the tree is what you get by replaying all edits. One edit is one record, so a flush or a compaction switches in one step. A small file named CURRENT points to the active manifest.
Synthesizing vector architecture diagram...
The tiny store's manifest, as the reference implementation recorded it:
| # | Edit |
|---|---|
| 1 | add F1 at Level 0; logs before wal-2 not needed |
| 2 | add F2 at Level 0; logs before wal-3 not needed |
| 3 | delete F1, F2; add F3, F4 at Level 1 |
| 4 | add F5 at Level 0; logs before wal-4 not needed |
| 5 | add F6 at Level 0; logs before wal-5 not needed |
| 6 | delete F3, F4, F5, F6; add F7, F8, F9, F10 at Level 1 |
| 7 | move F7 to Level 2 (trivial move) |
| 8 | move F8 to Level 2 (trivial move) |
| 9 | add F11 at Level 0; logs before wal-6 not needed |
| 10 | add F12 at Level 0; logs before wal-7 not needed |
| 11 | delete F11, F12, F9, F10; add F13, F14, F15 at Level 1 |
| 12 | move F14 to Level 2 (trivial move) |
Reference counting. A reader holds on to the version that was current when it started. Files deleted by a later edit stay on disk while any reader still holds a version that lists them, and are deleted when the last one lets go.
Go deeper: forgotten iterators and snapshots. An iterator (an open scan) pins the files of its version, so a scan left open for hours keeps every file that compactions have replaced since then: disk use grows while the data does not. A snapshot pins no files, but it makes compaction keep the versions it can see (3.2), which also holds space. Both show up as "disk use grows, live data does not"; the fix is closing long-lived iterators and releasing snapshots.
4.2 Full crash recovery
textRECOVER() 1. read CURRENT, then replay the manifest's edits in order -> live files per level, last sequence number, oldest WAL segment still needed 2. delete data files the manifest does not list -- orphans of unfinished work 3. replay the WAL from the oldest segment still needed, in order, stopping at the first damaged record (the log's rule) -> rebuild a MemTable with each record's original seq 4. next seq = max(manifest's last seq, last replayed seq) + 1 5. flush the rebuilt MemTable (usually), record it, delete the old segments 6. open for traffic
Synthesizing vector architecture diagram...
Crash during a flush: the table in 1.6. Crash during a compaction (F3 to F6 merged into F7 to F10):
| Crash after | What recovery finds | Result |
|---|---|---|
| Writing some of F7 to F10 | Outputs not listed: orphans, deleted; inputs still listed | No loss; the compaction runs again |
| Syncing all outputs | Same | No loss |
| The manifest edit | Outputs listed, inputs no longer listed: inputs deleted | No loss |
| Deleting the inputs | Outputs listed | No loss |
| Wrong order: inputs deleted before the edit | Manifest lists F3 to F6, which are gone | Missing files: the tree is corrupt |
One rule covers every change: new files durable, then one manifest edit, then delete what it replaced.
4.3 Why immutability pays off
- Cheap consistent backups. A backup is the manifest plus the files it lists, and a file already copied never needs copying again, so incremental backups upload only new files. RocksDB's checkpoint makes a consistent copy by hard-linking the live files.
- Free snapshots. A snapshot is just a sequence number (1.3).
- Moving data by file. A new replica can be seeded by copying files rather than replaying every write.
- Simple caching. A cached block can never be stale.
The distributed key-value store loop and the S3-like object storage loop both lean on these properties.
Part 5. Choosing: LSM vs B-tree, Variants and Engines
5.1 LSM vs B-tree, honestly compared
Both log every write first. They differ in where the rest of the write cost goes. A B-tree pays in page writes: dirty pages written back in place at checkpoints or when the buffer pool needs room, plus torn-page protection (the Write-Ahead Log page will cover checkpoints and full-page writes in depth). An LSM tree pays in compaction.
| Dimension | B-tree (InnoDB, PostgreSQL) | Leveled LSM (RocksDB) |
|---|---|---|
| Write amp, random keys, data far larger than memory | Up to about 33 (1 KB rows, 16 KB pages) | About 12 to 15 simulated; about 22 at 1 TB |
| Write amp, strong locality or ordered keys | About 3 to 5 | About the same as random |
| Write pattern | Random page writes | Large sequential writes |
| Point read | One leaf page, inner pages cached | One data block, plus filter checks and about 0.1 wasted reads |
| Range scan | Walk neighbouring leaf pages | Merge all levels and Level 0 files; tombstones cost extra |
| Space | Pages often part-empty after splits | About 1.1× with leveled; compresses well, no free space kept inside pages |
| Tail latency | Checkpoint bursts, page splits, cache misses | Compaction competing for disk; write stalls |
| SSD wear | More small random writes | Fewer bytes for random workloads, all sequential |
| Concurrency | Latches on pages; hot pages contend | Writers touch only the MemTable |
Decision guide:
| Workload | Choose | Because |
|---|---|---|
| Write-heavy random keys (events, metrics, messages), far larger than memory | LSM | Fewer and sequential bytes per write |
| Read-heavy, point and range queries, steady latency | B-tree | One lookup path, no compaction interference |
| Tight storage budget, compressible data | Leveled LSM | Block compression, about 1.1× space |
| Queue-like "insert, then delete soon" | B-tree, or LSM with care | LSM scans pay for tombstones |
| Time series with a TTL | LSM with time-window or FIFO | Whole files expire at once |
Applied to two workloads. (1) The drill's telemetry store: 250,000 random-key points per second, far more data than memory, written in time order and expiring. LSM with time-window compaction. (2) An inventory service: 90% point and short range reads, updates with strong key locality, steady p99 required. B-tree: its write amplification is about 3 to 5 here, so the LSM's main advantage is gone while its scan cost and compaction interference would remain.
The drill's time-series database stalls every hour because compaction falls behind. Why not switch to a B-tree engine like PostgreSQL or InnoDB, which has no compaction?
5.2 Large values and time series
Key-value separation. With large values, compaction mostly moves value bytes that never change. The WiscKey design (Lu et al., FAST 2016) keeps only keys and small pointers in the LSM tree and appends values to a separate value log.
textPUT(key, value) -- with key-value separation 1. append value to the value log; get its position 2. write (key -> position) through the normal LSM write path GET(key) 1. read the position from the LSM tree (2.3) 2. read the value from the value log -- one extra read GARBAGE_COLLECT(value log file) 3. for each value: keep it only if the tree still points to it; rewrite the live ones
Worked amplification (24-byte key, 16-byte pointer, LSM write amplification 22 as in 3.3; assumed value-log garbage collection rewriting each value about once):
| Value size | Without separation | With separation | Arithmetic |
|---|---|---|---|
| 1 KB | 22 | about 3.8 | |
| 64 KB | 22 | about 3.0 |
The costs: an extra read per lookup, scans that read values from scattered places, and value-log garbage collection that must check each value against the tree. RocksDB implements this as BlobDB (enable_blob_files, with min_blob_size below which values stay inline); TiKV offers Titan. Worth it when values are large and point reads dominate.
Time series fit time-window compaction (whole windows expire at once) and caches fit FIFO (3.4).
5.3 The engines table (corrected)
| Engine | Structure | Default compaction | Notable knob |
|---|---|---|---|
| RocksDB | LSM with WAL | Leveled; universal and FIFO available | level_compaction_dynamic_level_bytes (true since 8.4) |
| LevelDB | LSM with WAL | Leveled | write_buffer_size |
| Pebble (CockroachDB's engine) | LSM, RocksDB-compatible design, written in Go | Leveled | Level 0 sublevels |
| TiKV | Data in RocksDB; the Raft log in its own Raft Engine by default since v6.1 | Leveled (RocksDB) | Titan for key-value separation |
| Apache Cassandra | LSM: commit log, MemTables, SSTables | Size-tiered (STCS) in 5.0; LCS, TWCS and UCS available | gc_grace_seconds (10 days) |
| ScyllaDB | LSM, Cassandra-compatible | Chosen per table: STCS, LCS, ICS or TWCS | ICS to avoid size-tiered's big temporary peaks |
| HBase | LSM per region: WAL, MemStore, HFiles | Size-based minor compactions, periodic major compactions | hbase.hregion.majorcompaction |
| Google Bigtable | LSM per tablet (commit log, memtable, SSTables), per the 2006 paper | Minor, merging and major compactions | Managed; internals beyond the paper not public |
| Flink RocksDB state backend | RocksDB with its WAL disabled: Flink's checkpoints provide durability | Leveled | Incremental checkpoints upload only new SSTables |
| InnoDB (contrast) | B+ tree, redo log, doublewrite buffer | No compaction | Redo log capacity |
| PostgreSQL (contrast) | Heap and B-tree indexes, WAL with full-page writes | No compaction; VACUUM reclaims dead rows | checkpoint_timeout, max_wal_size |
| DynamoDB (contrast) | WAL plus B-tree storage nodes, per AWS's published description: not an LSM tree | Not applicable | Managed |
| Kafka (contrast) | Append-only log segments: log-structured, but not an LSM tree (no sorted runs merged by key) | Not applicable | Segment size, retention |
| etcd (contrast) | B+ tree (bbolt) plus a Raft WAL | Not applicable | --auto-compaction-retention (history, not LSM files) |
5.4 On AWS
- Self-run RocksDB, Cassandra or ScyllaDB on EC2. Instance-store NVMe is fastest but is lost when the instance stops, so replication is required. EBS survives instance stops; size gp3 or io2 throughput for the flush and compaction budget of 3.5, not just for client writes. Leave free space for the policy: a little for leveled, up to half the disk for size-tiered.
- Flink state on Amazon Managed Service for Apache Flink uses the RocksDB state backend, with checkpoints handled by the service.
- Amazon Keyspaces (for Apache Cassandra) is serverless and speaks the Cassandra protocol. AWS does not publish its storage internals, so the compaction tuning on this page does not apply to it, and we make no claim about what runs underneath.
- Amazon DynamoDB is the contrast case: a managed WAL plus B-tree design, nothing here to tune.
Part 6. Operating It
6.1 Knobs mapped to the algorithm
| Knob (RocksDB 11.11 default) | Step it changes | Raising it | Risk |
|---|---|---|---|
write_buffer_size (64 MB) | MemTable size (1.4), flush size (1.6) | Fewer, bigger flushes | Memory; longer WAL replay after a crash |
max_write_buffer_number (2) | MemTables allowed before writes stop | Absorbs flush hiccups | Memory: size × count per column family |
level0_file_num_compaction_trigger (4) | When Level 0 merges down (3.3) | Fewer, bigger Level 0 merges | More files per read (2.5) |
level0_slowdown_writes_trigger / level0_stop_writes_trigger (20 / 36) | Stall points (3.5) | Later stalls | Slower reads, bigger backlog first |
max_bytes_for_level_base (256 MB), max_bytes_for_level_multiplier (10) | Level sizes (3.3) | Bigger ratio: fewer levels, more write amp per level | Write vs read and space trade |
max_background_jobs (2) | Flush and compaction threads | More compaction bandwidth | CPU and disk contention with reads |
| Rate limiter (none) | Caps background I/O | Smoother read latency | Too low: backlog grows, stalls |
| Bloom bits per key (no filter unless configured) | Filter accuracy (2.2) | Fewer wasted reads | Memory: bits × keys / 8 |
| Block cache size | Cached index, filter and data blocks (2.5) | Fewer disk reads | Memory |
soft_ / hard_pending_compaction_bytes_limit (64 GB / 256 GB) | Backlog stalls (3.5) | Later stalls | More space in use |
Sizing formulas:
- MemTable memory : MB per column family by default.
- Filter memory : one billion keys at 10 bits is 1.25 GB.
- Disk write bandwidth for flush and compaction : 20 MB/s at WA 22 needs 440 MB/s (and about as much reading).
- Free space: leveled, a few GB per concurrent compaction plus about 10% for upper levels; size-tiered, up to the largest tier (plan for 50% free).
Tuning by ingest rate (per node; NVMe, leveled, WA about 22):
| Ingest per node | Flush and compaction writes | First changes |
|---|---|---|
| Up to 5 MB/s | Up to 110 MB/s | Defaults; add a 10-bit Bloom filter and size the block cache |
| 5 to 20 MB/s | 110 to 440 MB/s | 4 to 8 background jobs, 128 to 256 MB MemTables, watch pending compaction bytes |
| Above 20 MB/s | Above 440 MB/s | Lower WA (universal or time-window, key-value separation for big values) or shard |
6.2 Diagnostics
Synthesizing vector architecture diagram...
| Symptom | Signature | Mechanism | First action |
|---|---|---|---|
| Write stall | Write latency jumps to seconds; RocksDB logs "Stalling writes"; rocksdb.stall.micros rises | Level 0 or backlog past the thresholds (3.5) | Compare ingest × WA with disk bandwidth; follow the tree above |
| Tombstone scan slowdown | Scans slow on queue-like tables; Cassandra warns past tombstone_warn_threshold (1,000 tombstones in one read) and fails the query at tombstone_failure_threshold (100,000) | Scans step over every tombstone (2.4) | Scan from a moving start key; range deletes or TTL with time-window compaction (3.7) |
| Disk full during compaction | Compaction fails for lack of space; the database stops writes | Outputs need space before inputs are freed (3.6) | Add space first; then fix the headroom |
| Bloom filter not helping | Many disk reads per lookup; rocksdb.bloom.filter.useful low compared with lookups | No filter, too few bits, or scan-heavy work (2.2) | 10 bits per key, prefix filters for prefix seeks, cache filter blocks |
| Flush backlog | rocksdb.num-immutable-mem-table above 1; writes stop | Flush slower than MemTables fill (1.6) | More flush threads, faster disks, more MemTables |
| Read latency creep | Point-read latency climbs; rocksdb.num-files-at-level0 grows | Every Level 0 file checked per read (2.3, 2.5) | Let compaction catch up; look for throttled compaction |
6.3 Incident stories
The compaction storm (the drill):
| Time | Event |
|---|---|
| Minute 0 | Ingest rises to a new peak. Latency is fine: writes touch only the WAL and the MemTable. |
| Minutes 1 to 55 | The backlog grows quietly; Level 0 creeps up with each flush. |
| Minute 55 | Level 0 passes 20 files: writes slow to 16 MB/s. |
| Seconds later | Level 0 reaches 36: writes stop for about 12 seconds. |
| Fix | Time-window compaction (WA from about 22 to about 3), more compaction threads. |
Lesson: a stall is a budget problem (3.5), not a bug.
The tombstone meltdown. A job queue in Cassandra deletes each job after it runs, and the poller scans from the start of the partition. After a busy day each poll steps over hundreds of thousands of tombstones, and queries fail at the tombstone limit. Lesson: deletes stay until compaction removes them; don't scan over them (3.7).
The zombie delete. A Cassandra node came back after two weeks without a repair. The other replicas had purged tombstones older than 10 days, so its old rows spread back through repair. Lesson: repair within gc_grace_seconds (3.6).
Disk full during compaction. A size-tiered table at 70% disk use began its first merge of the largest tier and ran out of space halfway. Lesson: size-tiered needs up to half the disk free (3.6).
6.4 The Well-Architected lens
| Pillar | What this mechanism says |
|---|---|
| Reliability | Compaction backlog and write stalls are reliability risks: alarm on Level 0 file count, pending compaction bytes and stall time before users see them REL 6. Immutable files make consistent incremental backups cheap (4.3) REL 9. |
| Performance efficiency | The LSM vs B-tree choice, the compaction policy, Bloom filters and the block cache are the data-access decisions that set read and write cost (5.1, 3.4, 2.2) PERF 3. |
| Cost optimization | Size disks for compaction headroom (up to half free for size-tiered) and for bandwidth equal to ingest × WA; stacked write amplification drives drive endurance and cost (3.3) COST 6. |
| Operational excellence | Export the engine's stall, flush and compaction metrics and keep the diagnostics of 6.2 as runbooks OPS 8. |
| Security | Encrypt the immutable files and WAL at rest SEC 8. Deleted data remains on disk until compaction removes it (3.6, 3.7), which matters when data must be erased by a deadline. |
| Sustainability | Write amplification wears flash, so lower WA means fewer drive replacements SUS 5; TTLs and retention bound the stored volume (3.7) SUS 4. |
Part 7. Practice
7.1 Think-first drills
Drill 1. A leveled tree has and 4 levels below Level 0, with a 1 GB base level fed by 256 MB of Level 0 at a time. What is the write amplification, typical and at the bound?
Drill 2. Disks give 300 MB/s to flush and compaction. Write amplification is 15. Ingest is 30 MB/s. Will it stall?
Drill 3. A metrics store writes points in time order, keeps them 30 days and never updates them. Which compaction policy?
Drill 4. The versions of user:2 are @2 PUT bob, @6 DEL and @12 PUT bea. What does a latest read return? A read at snapshot 5? At snapshot 8?
Drill 5. A compaction into Level 3 of a 6-level tree meets k@50 DEL. No snapshot is live. Can it drop the tombstone?
Drill 6. A service ingests 50,000 random-key events per second into a table ten times larger than memory; reads are rare and mostly by exact key. LSM or B-tree?
7.2 Hand simulations
Simulation A. Fresh tiny store (MemTable 4 entries, blocks of 2). Apply: 1 PUT cart:1 pen, 2 PUT cart:2 ink, 3 DELETE cart:1, 4 PUT cart:3 pad, 5 PUT cart:2 nib, 6 PUT cart:4 cap. Give the Level 0 file, the MemTable, GET cart:1, GET cart:2, GET cart:3, GET cart:5, and SCAN [cart:1, cart:4].
When does the first flush happen, and which entries does it contain?
Simulation B. Level 1 file: k:b@35 b3, k:c@38 DEL, k:d@33 d2. Level 2 file: k:a@10 a1, k:b@31 b2, k:b@20 b1, k:c@12 c1, k:d@25 d1. Level 3 is empty. Output files hold up to 4 entries. Give the output (1) with a live snapshot at 30 and (2) with none.
For each key, first split its versions into "at most 30" and "above 30".
7.3 Interview questions with model answers
| Question | Model answer |
|---|---|
Walk me through a PUT. | Assign a sequence number, append to the WAL and sync per policy (group commit), insert into the MemTable, make it visible, acknowledge. When the MemTable is full, freeze it, start a new one and a new WAL segment, and flush in the background: sync the file, record it in the manifest, then release the log (1.5, 1.6). |
| How does a read find the newest version? | MemTables, then every overlapping Level 0 file newest first, then one file per deeper level, skipping files by range and Bloom filter. Entries sort by key, then sequence descending, so the first version at or below the read's sequence number wins; a tombstone means not found (2.3). |
| How are deletes handled? | A delete writes a tombstone that hides older versions. Compaction drops it only when no live snapshot is older and no deeper file may hold the key; in Cassandra, also after gc_grace_seconds. Mass deletes use range deletes, TTLs or dropping whole files (3.1, 3.6, 3.7). |
| Why do LSM writes stall? | Flush and compaction must write ingest × WA bytes. When the disk cannot, Level 0 and the backlog grow until the engine slows, then stops, writers on purpose. Sustainable ingest is about B / WA (3.5). |
| LSM vs B-tree? | LSM: sequential writes, less write amplification for random keys, better compression; costs read amplification and compaction interference. B-tree: one read path and steady reads, cheap with locality; costs random page writes. Neither always wins (5.1). |
| Leveled vs size-tiered? | Leveled: about T/2 writes per level, one file per level per read, about 1.1× space. Size-tiered: about 1 write per tier, many files per read, can exceed 2× space and needs up to half the disk free (3.3, 3.4). |
7.4 The cheat card
| Topic | Remember |
|---|---|
| Sort order | Key ascending, sequence descending |
| Write path | seq → WAL (sync) → MemTable → visible → ack |
| Flush order | file sync → manifest edit → delete WAL |
| Compaction order | outputs sync → one manifest edit → delete inputs |
| Point read | MemTables, all overlapping Level 0 files newest first, one file per deeper level; first visible version wins |
| Tombstone drop | No older live snapshot and nothing below may hold the key |
| Bloom | : 10 bits ≈ 0.82% (RocksDB 0.95%); no help for scans |
| Leveled WA | About per level (bound ); about 22 for 1 TB |
| Sustainable ingest | |
| Space | Leveled about 1.11×; size-tiered can pass 2×, needs up to half the disk free |
| RocksDB 11.11 | write_buffer_size 64 MB, max_write_buffer_number 2, Level 0 triggers 4 / 20 / 36, base level 256 MB, ratio 10, dynamic level sizing on (since 8.4), target file 64 MB, backlog limits 64 GB / 256 GB, 4 KB blocks, restart interval 16 |
| Cassandra 5.0 | STCS default, min_threshold 4, gc_grace_seconds 864,000 (10 days) |
Failure checklist: ingest × WA within the disk budget? Level 0 count and backlog alarmed? Filters configured and cached? Free space for the policy? Scans over tombstones? Repairs inside gc_grace_seconds? Long-lived iterators or snapshots holding space?
7.5 Where to go next
- The Write-Ahead Log loop primitive (Write-Ahead Log, fsync & Group Commit), coming next: the log this page builds on.
- Primitive #03: Bloom filters: the filter structure in depth.
- Primitive #04: caching: the block cache is a cache with the same trade-offs.
- Drill: The Time-Series Database That Froze on Flush, answered in 3.5 and 5.1.
- Loops: the distributed key-value store (step 1.2 and compaction) and S3-like object storage (metadata compaction).