LSM-Trees & Compaction
The Time-Series Database That Froze on Flush
Test your architecture intuition: Pitch a 7-axis solution, survive two aggressive reviewer objections, and inspect the staff-level Teacher Gold Answer.
Part 0. Start here
The problem: 100,000 writes a second on one cloud disk
A storage node in a key-value store has to take 100,000 small writes a second. Each write is about 100 bytes, so that is about 10 MB/s of new data. The keys are random (user IDs, device IDs), and the data is far larger than the node's memory.
The node's disk is an AWS EBS gp3 volume (a general-purpose SSD block volume, attached to the instance over the network) at its baseline, which every gp3 volume includes: 3,000 IOPS (small reads or writes a second) and 125 MiB/s of throughput (about 131 MB/s). So the disk is good at one thing and bad at another:
| What we ask the disk for | Its limit | Our need |
|---|---|---|
| Small writes in random places | 3,000 a second | 100,000 a second |
| Big sequential writes | about 131 MB/s | 10 MB/s of new data |
The classic way to store sorted data is to update it in place: find the page that holds the key and rewrite that page. With random keys and data far larger than memory, what happens on this disk? How would you store the data instead?
The big picture
A log-structured merge-tree (LSM tree) is the design from the answer above. Here is the whole of it on one screen; the rest of the page opens each box.
Synthesizing vector architecture diagram...
What you'll be able to do after this page
- Name the three pieces of an LSM tree and the job of each (Part 1).
- Walk through a write, including overwrites and deletes (Part 2).
- Explain how the MemTable becomes a file without losing data in a crash (Part 3).
- Walk through a read, and say why Bloom filters make it cheap (Part 4).
- Run a compaction by hand, and explain why writes stall when compaction falls behind (Part 5).
- Explain how the engine knows which files exist and which level each is in (Part 6).
- Argue LSM tree vs B-tree for a given workload (Part 7).
- Trace a write and a read from your code through the operating system to the disk (Part 8).
- Describe what the engine does when it restarts after a crash (Part 9).
- Explain how corruption is detected and repaired (Part 10).
- Say where an LSM tree runs on AWS, size its disk and instance, and name the services that only look like one (Part 11).
You may have arrived from step 1.2 of the distributed key-value store loop or from the metadata store of the S3-like object storage loop: both run on the engine this page takes apart.
Part 1. The core mental model
Before any algorithm, we need the pieces and the rules they keep. Everything later on the page is one of these pieces doing its job.
The three pieces
We start with a log because it is the only way to make a write durable with one small sequential append. Then two more pieces turn that log into sorted, searchable data.
| Piece | What it is | Its job |
|---|---|---|
| Write-ahead log (WAL) | An append-only file on disk | Makes an acknowledged write survive a crash |
| MemTable | A sorted table in memory | Holds the newest writes, sorted, so reads find them and a flush can write them out in order |
| SSTables | Immutable sorted files on disk ("sorted string tables"), arranged in levels | Hold everything else; never changed after they are written |
Synthesizing vector architecture diagram...
Three rules the engine never breaks
| Rule | What breaks without it |
|---|---|
| A write is in the WAL before the client hears "OK" | A crash loses a write the client was told succeeded |
| Files on disk are never modified: they are only created and, once replaced, deleted | Readers and backups see half-written data, and writes become random again |
| The newest version wins: a place searched earlier (MemTable, then newer files, then deeper levels) never holds an older version of a key than a place searched later | Reads return stale values, and deleted data comes back |
The tiny store we follow
Real engines hold billions of keys, which is too many to watch. So one small example runs through the whole page: a store with a 4-entry MemTable that takes 24 writes. We call it "watching the tree grow". Every trace on this page was produced by running a private reference implementation of this store, not worked out by hand.
For now you only need the settings:
| Setting | Tiny store | At real scale (typical defaults) |
|---|---|---|
| MemTable capacity | 4 entries | about 64 MB |
| Data block size | 2 entries per block | about 4 KB |
| Level 0 compaction trigger | 2 files | 4 files |
| Level size limit | Level 1 up to 2 files, Level 2 up to 4 (each level 2 times the one above) | each level about 10 times the one above |
| Compaction output file size | 4 entries; one key's versions are never split across files | about 64 MB |
| WAL segments | one per MemTable: wal-1 holds writes 1 to 4, wal-2 writes 5 to 8, and so on | one per MemTable |
The 24 writes, for reference (the traces refer back to them). Each write gets a sequence number, seq, that goes up by one per write. A reader called snapshot S5 asks to see the data "as of seq 5": it is taken after write 5 and released after the compaction that follows write 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 |
What to remember from Part 1
- Three pieces: the WAL (crash safety), the MemTable (newest data, sorted, in memory) and SSTables (immutable sorted files in levels).
- Files are never edited: they are created, and deleted once something replaces them.
- The newest version of a key always wins, and the search order is built so the first match is the newest.
Part 2. Writes, step by step
A write must be durable before we say "OK", and visible to reads right after. And since files never change, an overwrite or a delete cannot touch the old value. This Part shows how one ordered list of steps gives all three.
What we need from the log
This page pairs with the Write-Ahead Log loop primitive, Write-Ahead Log, fsync & Group Commit, which covers durability in depth. All we assume from it:
- Append a record and make it durable before acknowledging. Making it durable means forcing it onto the disk itself with
fsync(Part 8); many writers can share onefsync, which is called group commit. - The append returns the record's sequence number. The same number becomes the version of the entry in the LSM tree. One number, two jobs.
- After a crash, replay the log in order from a given sequence number, stopping at the first damaged record (Part 10).
- The LSM tree tells the log one thing: "everything up to sequence N is now safely in a file." Log segments that hold only records up to N may then be deleted (Part 3).
Versions and tombstones
Every write adds a new version; nothing is overwritten. Each entry is stored as (key, sequence number, type) plus a value, where the type is PUT or DEL. Entries sort by 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 addsuser:1@5, which sorts in front ofuser:1@1 alice. - A delete is a new version called a tombstone.
DELETE user:2at seq 6 writesuser:2@6 DEL. It hides the older value until compaction removes both (Part 5). - A read at sequence S sees, for each key, the newest entry with a sequence number at most S. A snapshot is just 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 cost: old versions and tombstones take space until compaction removes them.
The MemTable is a skip list
The MemTable must stay sorted (so a flush can write a sorted file in one pass), take many writers at once, and let readers search while writes go on. A hash table is not sorted. The usual answer, in LevelDB and RocksDB, is a skip list: a sorted linked list with extra levels of pointers on top, called lanes, and each higher lane skips over some of the nodes below it. A search runs along the top lane and drops down a lane whenever the next node on this lane is not smaller than the key it wants, so a search or an insert takes about log n steps, and an insert only changes a few pointers.
The tiny store's MemTable after writes 1 to 4. Real skip lists pick each node's height at random; we fixed the heights to keep the picture small. The search for user:3 follows the thick arrows:
Synthesizing vector architecture diagram...
user:3, so the search drops to lane 1 (dotted arrows are the drops), takes one step to user:2@2, drops to lane 0, and the next node is the answer: 2 steps right instead of 4 on lane 0 alone. Lane 0 holds every entry in sorted order: order:9@3, user:1@1, user:2@2, user:3@4. Lane 1 holds only user:2@2 and user:3@4, and lane 2 only user:3@4, so each higher lane skips over nodes that the lane below must visit one by one. With many entries, these skips are what make a search take about log n steps instead of n.The write path
pseudocodeWRITE(op, key, value) -- op is PUT or DEL 1. seq = next sequence number 2. append (seq, op, key, value) to the WAL and make it durable 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 full: freeze it start a new MemTable and a new WAL segment schedule a flush (Part 3)
WRITE is the path of every client write, and its purpose is to make the write durable and visible before the client hears "OK". Its inputs come from the client: op is PUT or DEL, key is the key (for example user:1), and value is the new value (for example alicia), with no value for a delete. seq is the next sequence number: it goes up by one per write and becomes the version of this entry, so write 5 creates user:1@5. The function appends the tuple (seq, op, key, value) to the write-ahead log (WAL) and waits until it is durable, then inserts the entry into the active MemTable, makes seq visible to new readers, and acknowledges the client. When the MemTable is full (4 entries in the tiny store, about 64 MB at real scale), it also freezes that MemTable, starts a new MemTable and a new WAL segment, and schedules a flush. The result the client receives is the acknowledgement; the changes are one new log record and one new MemTable entry.
Synthesizing vector architecture diagram...
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 "OK" sees it. The order is always WAL first, then MemTable, then acknowledge.
Trace: writes 1 to 4 fill the first MemTable
| After write | MemTable (sorted) | Log |
|---|---|---|
| 1 | user:1@1 alice | wal-1: seq 1 |
| 2 | user:1@1 alice, user:2@2 bob | wal-1: seq 1 to 2 |
| 3 | order:9@3 book, user:1@1 alice, user:2@2 bob | wal-1: seq 1 to 3 |
| 4 | order:9@3 book, user:1@1 alice, user:2@2 bob, user:3@4 carol: full | wal-1: seq 1 to 4 |
Write 4 fills the MemTable, so it is frozen, a new MemTable and wal-2 take write 5, and a flush is scheduled.
How it breaks: if flushes cannot keep up, the number of frozen MemTables grows. Engines allow only a few MemTables at once (RocksDB's default is two), so when all are full, writes wait (Part 5).
What to remember from Part 2
- The order is WAL (durable), then MemTable, then "OK".
- An overwrite is a newer version and a delete is a tombstone; entries sort by key, then newest first.
- A read at sequence S sees the newest version at or below S; a snapshot is just a held sequence number.
Part 3. Flushing the MemTable to disk
Memory is limited and the WAL grows without end, so the frozen MemTable must become a file on disk, and the log segment that holds those writes must then be deleted. Done in the wrong order, a crash loses data.
What a flush writes: the SSTable
The frozen MemTable is already sorted, so the flush writes it out in one sequential pass. Scanning a whole file for one key would read megabytes, so an SSTable splits its sorted entries into small blocks and adds a small index and filter, so a read fetches only the one block it needs.
| Part | What it holds |
|---|---|
| Data blocks | Entries in sorted order, about 4 KB each, usually compressed one block at a time |
| Index block | One entry per data block: a key that separates it from the next block, and the block's position in the file |
| Filter block | A Bloom filter over the file's keys (Part 4) |
| Footer | A fixed-size tail, read first, that says where the index and filter are |
Synthesizing vector architecture diagram...
The safe order
pseudocodeFLUSH(frozen MemTable m, covered by WAL segment n) 1. write m's entries in sorted order into a new file f -- one sequential pass 2. fsync f fsync f's directory -- the bytes and the file's name are durable 3. append one edit to the manifest: "add f at Level 0" "WAL segment n not needed" fsync the manifest 4. readers start using f instead of m 5. only now delete WAL segment n
The manifest is a small log that lists which files make up the tree (Part 6). A file on disk that the manifest does not list does not exist for the engine.
FLUSH turns a frozen MemTable into a file on disk and only then lets the engine delete the log segment that holds those writes. Its input m is the frozen MemTable, already sorted, and n is the WAL segment that holds the same writes (in the trace, wal-2 for F2). f is the new SSTable file it creates at Level 0. The order of the steps is the point: first the file's bytes and its name in the directory are made durable with fsync, then one manifest edit that both adds f and marks segment n as not needed is appended and synced, then readers switch from m to f, and only after all of that is segment n deleted. A crash at any step therefore leaves either the log or a listed file holding every write. FLUSH returns nothing; it changes the tree by adding f to Level 0, and it frees the memory of m and the disk space of segment n.
Synthesizing vector architecture diagram...
FLUSH for F2 in the tiny store, and the order of the four messages is the safety argument. The engine writes F2's entries, filter, index and footer, then syncs the file and its directory. Only then does it append the manifest edit "add F2, wal-2 not needed" and sync it, and only after that edit is durable does it delete wal-2. At every moment, writes 5 to 8 are either in wal-2 or in a file that the manifest lists. The think-first question below shows what goes wrong when the steps run in another order.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?
Trace: F1 and F2 land in Level 0
Write 4 filled the first MemTable. The flush writes F1, two blocks of two entries each:
Synthesizing vector architecture diagram...
After write 4: F1 is recorded in the manifest and wal-1 is deleted. The panel "Level 0" now holds F1, whose key range runs from order:9 to user:3. Its four entries are stored in sorted order in two blocks of two entries each: block 0 holds order:9@3 book and user:1@1 alice, and block 1 holds user:2@2 bob and user:3@4 carol. The panel "MemTable" is empty and ready for write 5, and "Level 1" and "Level 2" are still empty.
Writes 5 to 8 (alicia, the user:2 tombstone, pen, dave) fill the second MemTable, and its flush writes F2. Level 0 now has two files whose key ranges overlap: both cover user:1 to user:3. That is normal for Level 0, because each file is simply one MemTable's worth of writes.
Synthesizing vector architecture diagram...
After write 8: two Level 0 files is the tiny store's compaction trigger, so a compaction is scheduled to run in the background (Part 5). Until it runs, reads must search both files. The panel "Level 0, newest first" lists F2 above F1 because F2 holds the newer writes, 5 to 8. The two key ranges, order:7 to user:4 for F2 and order:9 to user:3 for F1, overlap, so a read for a key such as user:1 must look in F2 first and then, if F2 does not hold it, in F1. The MemTable is empty and ready for write 9.
What to remember from Part 3
- A flush freezes the MemTable, starts a new one and a new WAL segment, and writes one sorted file sequentially: data blocks, index, Bloom filter, footer.
- The safe order is: sync the file and its directory, record it in the manifest, only then delete the WAL segment.
- Flushed files land in Level 0, where their key ranges may overlap.
Part 4. Reads, step by step
Writes are now cheap, but the data is spread over a MemTable and several files. Checking every file for every read would make reads slower as the tree grows. The "newest version wins" rule lets a read search in a fixed order and stop at the first match.
The search path
pseudocodeGET(key, read_seq = latest) 1. search the active MemTable, then the frozen MemTables, newest first 2. search every Level 0 file whose range covers key, newest first -- they overlap 3. for each deeper level: search only the one file whose range covers key -- no overlap 4. inside each file (FILE_GET below): the Bloom filter skips most files 5. the FIRST entry with seq <= read_seq wins: PUT -> its value DEL -> not found nothing anywhere -> not found
GET answers a point read for one key. Its inputs are key, the key the reader asks for, and read_seq, the sequence number the reader reads at: the latest one by default, or a snapshot's number, such as 5 for snapshot S5. It searches the sources from newest to oldest: the active MemTable, the frozen MemTables, every Level 0 file whose key range covers key (several can, because Level 0 files overlap), and then only one file per deeper level. Inside each file it uses FILE_GET below, whose Bloom filter lets it skip most files without a disk read. The first entry found with a sequence number at most read_seq decides the answer, because every source searched later holds only older data. GET returns that entry's value for a PUT, and "not found" for a DEL (a tombstone) or when no source holds the key; it changes nothing.
pseudocodeFILE_GET(file, key, read_seq) 1. if key is outside the file's [smallest, largest]: not here 2. if the Bloom filter says "certainly absent": not here -- no disk read 3. binary search the index for the block that may hold key 4. read that block (from the block cache or the disk) search inside it 5. return the newest entry for key with seq <= read_seq, if any
FILE_GET searches one SSTable for one key and avoids disk reads wherever it can. Its inputs are file, the SSTable being searched; key; and read_seq, the same read sequence number GET was called with. The file's smallest and largest keys, [smallest, largest], rule it out at once when key falls outside them. The Bloom filter rules it out without any disk read when it says the key is certainly absent; about 1% of absent keys still pass at 10 bits per key. Otherwise a binary search of the index finds the one data block, about 4 KB, that may hold key, and that block is read from the block cache (the engine's own in-memory cache of recently read blocks, Part 8) or from the disk and searched. FILE_GET returns the newest entry for key whose sequence number is at most read_seq, or nothing when the file has no such entry; it changes nothing except possibly filling the block cache.
Synthesizing vector architecture diagram...
GET as a series of questions, asked from the newest source to the oldest. The read first asks whether the key is in a MemTable. If not, it asks whether the key is in a Level 0 file, checking those files newest first. If not, it asks whether the key is in the one file of the next level whose range covers it, and repeats that question level by level. At the first "yes", the first visible entry decides the answer: a PUT gives its value, and a DEL gives not found. Only when the last level also says no is the answer "Not found". Every later source holds only older data, so the read never looks further after its first match.Bloom filters skip files that lack the key
Most files a read could check do not hold the key. A Bloom filter lets us skip them without a disk read. It is a bit array plus a few hash functions: adding a key sets a few bits, and 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 Bloom Filters & Counting Filters. The one approximate rule to remember:
(An ideal filter at 10 bits per key gives about 0.8%; real engines use slightly less accurate but faster designs, so people say "about 1%".) So a lookup for an absent key that has 10 candidate files wastes about 10 × 1% = 0.1 block reads, instead of 10 disk reads with no filter. The memory cost: 1 billion keys × 10 bits ÷ 8 = 1.25 GB.
Trace: four kinds of read
Write 9 (PUT user:5 erin) has gone into the new MemTable, and the compaction scheduled in Part 3 has not run yet. Snapshot S5 is alive.
Synthesizing vector architecture diagram...
After write 9, before the first compaction. This is the state the read trace below runs on. The panel "MemTable" holds one entry, user:5@9 erin. The panel "Level 0, newest first" still holds F2 and then F1, with the same blocks as after write 8, and Level 1 and Level 2 are empty. Snapshot S5 is still alive, so the older versions in F1, such as user:2@2 bob, can still be read at seq 5.
| Read | MemTable | F2 (newer) | F1 (older) | Answer |
|---|---|---|---|---|
user:5 (a hit in memory) | user:5@9 erin | not searched | not searched | erin |
user:1 (overwritten) | none | filter maybe, index → block 0: user:1@5 alicia | not searched | alicia |
user:2 (deleted) | none | filter maybe, index → block 1: user:2@6 DEL | not searched | not found |
user:3 (in the older file) | none | in range, but the filter says no: skipped with no disk read | filter maybe, index → block 1: user:3@4 carol | carol |
user:9 (absent) | none | outside the range: skipped | outside the range: skipped | not found |
user:2 at snapshot S5 | none | block 1 holds only @6, newer than 5 | block 1: user:2@2 bob | bob |
Notice the user:1 row: F1 also holds user:1@1 alice, but the read never gets there, because the newer file is searched first. Here we have only Level 0; with deeper levels, step 3 adds one file per level (Part 6 shows how that one file is found).
Range scans
A range scan returns every live key between lo and hi, in order. The data is spread across the MemTables and the files, each sorted on its own, so we merge them with a merge iterator: one cursor per source, and a min-heap (a priority queue that always gives the smallest entry) holding each cursor's current entry.
pseudocodeSCAN(lo, hi, read_seq) put one cursor per source at lo push each current entry on a min-heap last_key = none while the heap is not empty: e = pop the smallest advance its cursor push its next entry if e.key > hi: stop if e.seq > read_seq: -- too new for this reader continue if e.key == last_key: -- an older version continue last_key = e.key if e is a tombstone: -- deleted continue emit (e.key, e.value)
SCAN returns every live key from lo to hi, in key order, as the reader at read_seq sees them. lo and hi are the first and last keys of the range, and read_seq is the reader's sequence number, as in GET. Each source (every MemTable and every file that overlaps the range) gets a cursor placed at lo, and a min-heap holds each cursor's current entry, ordered by key and, for the same key, newest version first. e is the entry popped from the heap in each round, and last_key remembers the key of the last version considered, so older versions of the same key are skipped. An entry newer than read_seq is skipped because this reader must not see it, and a tombstone is skipped because the key is deleted. SCAN stops at the first key greater than hi, emits each surviving (key, value) pair, and changes nothing.
Trace, SCAN user:1 to user:4 on the state above: emit user:1 alicia (F2), skip user:1@1 alice (older); user:2@6 DEL hides user:2, skip user:2@2 bob (older); emit user:3 carol (F1); emit user:4 dave (F2). Result: alicia, carol, dave. The same scan at snapshot S5 skips everything newer than 5 and returns alicia, bob, carol.
Why scans cost more than point reads: every source that overlaps the range must be visited (a Bloom filter answers questions about one key, so it cannot rule any file out), and every old version and tombstone in the range passes through the heap before being thrown away.
What to remember from Part 4
- A read searches MemTables, then every overlapping Level 0 file newest first, then one file per deeper level; the first visible version wins, and a tombstone means "not found".
- Bloom filters let a read skip files without a disk read: 10 bits per key gives about 1% false positives.
- Scans merge all sources with a min-heap; filters do not help them, and tombstones slow them down.
Part 5. Compaction: merging SSTables
Without compaction, the number of files keeps growing: reads check more and more of them, and overwritten and deleted data never leaves the disk. We cannot simply delete old files, because they hold keys that were never overwritten. So we merge files into new files that keep only what some reader can still see.
The merge is a k-way merge of sorted files
Every input file is already sorted, so merging them is one sequential pass, like the merge step of merge sort: keep a cursor in each file and always take the smallest entry next (a k-way merge; with two files it is the familiar two-pointer merge). Two plain rules decide what goes. A reader here means the latest view plus every live snapshot.
- An old version goes when no reader would return it: every reader either sees a newer version of the key or is too old to see this one.
- A tombstone goes when nothing older can exist below it and no live snapshot is older than it. If an older value might sit in a deeper level this compaction does not read, dropping the tombstone would bring that value back. If a snapshot is older than the tombstone, it may still need the value the tombstone hides.
pseudocodeCOMPACT(inputs, output_level, live_snapshots) 1. merge all inputs in order: key ascending, seq descending 2. for each version v of each key: if no reader would return v: drop v else if v is a tombstone and no live snapshot is older than v and no deeper level may hold this key: drop v 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 fsync them and their directory 5. one manifest edit: { delete all inputs, add all outputs } -- atomic switch 6. delete the input files once no reader still uses them
COMPACT merges sorted files into new files and drops the versions no reader can see, so that reads check fewer files and old data leaves the disk. inputs are the files being merged (in the first compaction below, F2 and F1, all of Level 0, plus any overlapping files of the next level); output_level is the level the new files go to; and live_snapshots are the sequence numbers that readers still hold, such as snapshot S5. v is one version of one key during the merge. A version is dropped when every reader sees a newer version instead; a tombstone is dropped only when no live snapshot is older than it and no deeper level may still hold the key. The kept versions are cut into files of about the target size (4 entries in the tiny store, about 64 MB at real scale), never splitting one key's versions. COMPACT returns nothing; it changes the tree with one atomic manifest edit that removes all inputs and adds all outputs, and afterwards the input files are deleted. In the trace below, 8 entries go in and 7 come out, in F3 and F4.
Trace: the first compaction
The compaction scheduled after write 8 now runs. Its inputs are F2 and F1 (all of Level 0) plus any Level 1 files they overlap (none yet). S5 is alive.
| Merged entry | Decision | Why |
|---|---|---|
order:7@7 pen | keep | newest version |
order:9@3 book | keep | newest version |
user:1@5 alicia | keep | newest version |
user:1@1 alice | drop | every reader, S5 included, sees alicia instead |
user:2@6 DEL | keep | S5 is older than the tombstone and still needs the value it hides |
user:2@2 bob | keep | S5 reads it |
user:3@4 carol | keep | newest version |
user:4@8 dave | keep | newest version |
8 entries in, 7 out, cut into files of at most 4 entries: F3 gets 3 and F4 gets 4, so user:2's versions stay together. Level 1 is a single sorted run: its files do not overlap.
Synthesizing vector architecture diagram...
After write 9 and the first compaction: one manifest edit deleted F1 and F2 and added F3 and F4. Level 0 is now empty. The panel "Level 1, no overlap" holds F3, with keys order:7 to user:1, and F4, with keys user:2 to user:4; their ranges do not overlap, so a read needs to search at most one of them. user:1@1 alice is gone, because every reader sees alicia instead, while the user:2 tombstone and bob stay together in F4 because snapshot S5 still needs bob. The MemTable still holds user:5@9 erin.
Trace: writes 10 to 16 push data down to Level 2
Writes 9 to 12 flush as F5 (erin, the user:1 tombstone, lamp, bea), and writes 13 to 16 flush as F6 (finn, cara, the order:7 tombstone, gus). Two Level 0 files again, so they merge with the Level 1 files they overlap, F3 and F4. S5 is still alive.
- 15 entries in, 13 out.
order:7@7 penanduser:2@6 DELare dropped because no reader would return them: the latest view sees the newerorder:7@15 DELanduser:2@12 bea, and S5 sees nothing written after seq 5 (fororder:7it sees nothing at all; foruser:2it seesbob). The tombstonesuser:1@10andorder:7@15stay (S5 is older than both), andalicia,bob,carolandbookstay because S5 reads them. - The output is four files, F7 to F10, so Level 1 is over its limit of 2 files. The engine picks a Level 1 file and merges it with the Level 2 files it overlaps. Level 2 is empty, so nothing overlaps, and the engine does a trivial move: a manifest edit that relabels the file as Level 2 without rewriting a byte. It does this for F7, then F8.
Synthesizing vector architecture diagram...
After write 16. S5 is released now. The second compaction merged F5, F6, F3 and F4 into F7 to F10, and two trivial moves then relabelled F7 and F8 as Level 2 without rewriting them. The panel "Level 1" holds F9 (user:3 to user:5) and F10 (user:6 to user:7). The panel "Level 2" holds F7 (order:7 to order:9) and F8 (user:1 to user:2). The tombstones order:7@15 DEL and user:1@10 DEL are still stored next to the older values they hide, because S5 was alive when this compaction ran. The MemTable and Level 0 are empty.
Trace: writes 17 to 24 finish a delete
Writes 17 to 20 flush as F11 (vase, hana, dan, the user:5 tombstone), and writes 21 to 24 flush as F12 (ali, mug, ivy, fay). They merge with Level 1's F9 and F10. No snapshot is alive now.
- 14 entries in, 9 out.
carol,daveandfinnare dropped: newer versions hide them from every reader. user:5@20 DELanduser:5@9 erinmeet in this merge. No snapshot is alive, and no Level 2 file coversuser:5(F7 coversorder:7toorder:9, F8 coversuser:1touser:2), so nothing older can exist below. Both are dropped: that delete is finished, anderin's bytes leave the disk.- The output is F13, F14 and F15: one file too many for Level 1. The engine picks the file that overlaps least in the next level (RocksDB's default), so F14, which overlaps nothing in Level 2, takes a trivial move.
Synthesizing vector architecture diagram...
After write 24. Notice what stays: book, alicia and bob in Level 2 are no longer needed by anyone (S5 is gone), but they stay on disk until a later compaction reaches F7 and F8. Space comes back only when compaction touches a file. The third compaction merged F11, F12, F9 and F10 into F13, F14 and F15, and a trivial move then took F14 down to Level 2. The panel "Level 1" now holds F13 (order:1 to user:3) and F15 (user:9 only). The panel "Level 2" holds F7, F8 and F14. user:5 no longer appears anywhere: its tombstone and erin were dropped together.
Worked example: a tombstone that must stay
The tiny store never shows the other reason to keep a tombstone, so here is one more merge, also run through the reference implementation. A Level 1 file A merges with the two Level 2 files it overlaps; a snapshot at seq 30 is alive, and a Level 3 file whose key range covers only k:e holds an old k:e@5 e0.
| Merged entry | From | Decision | Why |
|---|---|---|---|
k:a@10 a1 | Level 2 | keep | only version |
k:b@40 b3 | A | keep | the latest view reads it |
k:b@20 b1 | Level 2 | keep | the snapshot at 30 reads it |
k:c@28 DEL | A | drop | no snapshot is older than 28, and nothing deeper covers k:c: the delete is finished |
k:c@15 c1 | Level 2 | drop | every reader sees the newer tombstone instead |
k:d@12 d1 | Level 2 | keep | only version |
k:e@29 DEL | A | keep | Level 3 still holds k:e@5 e0; dropping the tombstone would bring e0 back |
k:f@22 f1 | Level 2 | keep | only version |
8 entries in, 6 out. k:e@29 DEL can go only when a compaction finally merges it with the Level 3 file.
Leveled vs size-tiered, in one table
The tiny store uses leveled compaction (the default in LevelDB and RocksDB): below Level 0, each level is one sorted run of non-overlapping files, about 10 times bigger than the level above, and a file moves down by merging with only the files it overlaps in the next level. The main alternative, size-tiered compaction (Cassandra's default; RocksDB calls its version "universal"), waits for several files of similar size and merges them into one bigger file.
| Policy | Writes (write amplification) | Point reads | Space | Fits |
|---|---|---|---|---|
| Leveled | Higher: each byte is rewritten several times per level it passes through, often 10 to 30 in total | Best: one file per level | About 1.1 times the live data | Reads, updates, tight disks |
| Size-tiered | Lower: about once per tier | Worse: many overlapping files | Needs free space up to the size of the files being merged; in the worst case (a merge of everything), about as much again as the whole data set | Heavy writes, spare disk |
| Time-window or FIFO | Lowest | Good for recent data | Old data leaves as whole files | Time series and caches that expire |
Why writes stall
Compaction runs in background threads. If it falls behind, Level 0 grows (every read checks every Level 0 file) and the disk fills with data waiting to be merged. So the engine slows writers on purpose, then stops them. This is backpressure (slowing the producer so that the work waiting for a slower stage cannot grow without limit), not a bug.
Synthesizing vector architecture diagram...
The numbers are RocksDB's defaults. Engines apply the same kind of limits to the bytes compaction still owes and to the number of full MemTables waiting to flush. The loop works like this: ingest causes flushes, and each flush adds a Level 0 file. When the Level 0 file count reaches 4, a compaction is scheduled, and each compaction removes files from Level 0. If compaction keeps up, the count stays near 4. If it does not, the count keeps rising: at 20 files the engine delays writes, which reduces ingest, and at 36 files it makes writes wait, which stops ingest until compaction brings the count back down.
Every byte a client writes costs "write amplification" bytes of log, flush and compaction writes, so the disk's write bandwidth sets the ceiling:
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?
Synthesizing vector architecture diagram...
Why writes stop completely. This answers the first question of the drill The Time-Series Database That Froze on Flush. When ingest times write amplification is more than the disk can write, flushes add Level 0 files faster than compaction can merge them away. At RocksDB's defaults, with 64 MB MemTables and 120 MB/s of ingest, a flush lands about every 0.53 s (64 ÷ 120); if compaction drained nothing, Level 0 would grow from 4 files to the slowdown point of 20 in about 16 × 0.53 ≈ 8.5 s, and to the stop point of 36 about 8.5 s later. At the stop point (or when every allowed MemTable is full and waiting for a flush), the engine makes every writer wait, because letting Level 0 grow without limit would make every read check an unbounded number of files and let the disk fill. Writers resume only when compaction brings Level 0 back down, which is the "excellent latency, then a freeze of several seconds" pattern in the drill. Raising the triggers only delays the stall; the durable fixes are the three levers above.
What to remember from Part 5
- Compaction is a k-way merge of sorted files that keeps what some reader can still see; a tombstone goes only when no older snapshot needs it and nothing older can exist below.
- Leveled compaction keeps one file per level to read, at the price of rewriting each byte several times; size-tiered writes less and reads and stores more.
- Sustainable ingest ≈ disk write bandwidth ÷ write amplification; past it, the engine stalls writers on purpose.
Part 6. How files are tracked and levels are assigned
A flush adds a file; a compaction swaps several files for others. A reader must never see half of that change, a crash in the middle must not lose or duplicate data, and a read must find its one candidate file per level without opening every file. This Part is the small log and the in-memory table that do this.
The manifest is the directory of levels
The manifest is an append-only log of edits. Each edit lists files to add (with their level and key range) and files to delete, plus bookkeeping such as the last sequence number used and the oldest WAL segment still needed. The current shape of the tree is what you get by replaying every edit. One edit is one record, so a flush or a compaction takes effect in one step: before the edit is durable, the old files are the tree; after it, the new ones are.
The tiny store's manifest after 24 writes, as the reference implementation recorded it:
| # | Edit | Made by |
|---|---|---|
| 1 | add F1 at Level 0; logs before wal-2 not needed | flush after write 4 |
| 2 | add F2 at Level 0; logs before wal-3 not needed | flush after write 8 |
| 3 | delete F1, F2; add F3, F4 at Level 1 | first compaction |
| 4 | add F5 at Level 0; logs before wal-4 not needed | flush after write 12 |
| 5 | add F6 at Level 0; logs before wal-5 not needed | flush after write 16 |
| 6 | delete F3, F4, F5, F6; add F7, F8, F9, F10 at Level 1 | second compaction |
| 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 | flush after write 20 |
| 10 | add F12 at Level 0; logs before wal-7 not needed | flush after write 24 |
| 11 | delete F11, F12, F9, F10; add F13, F14, F15 at Level 1 | third compaction |
| 12 | move F14 to Level 2 | trivial move |
The same rule as the flush covers a compaction: new files durable, then one manifest edit, then delete what it replaced.
How a file gets its level
A file's level is written in the manifest edit that adds it, and it never changes inside the file:
- A flush always writes to Level 0. The new file holds the newest data, so it must be searched before anything older. (LevelDB has one shortcut: if a flushed file overlaps nothing in the first levels, it may be placed a level or two deeper straight away.)
- A compaction from Level N writes its outputs to Level N+1. The inputs are deleted in the same edit.
- A trivial move changes only the level in the manifest, when a file overlaps nothing in the next level.
Synthesizing vector architecture diagram...
The in-memory file metadata routes every read
Opening files to find out what they hold would cost disk reads on every lookup. So the engine keeps, in memory, one small record per live file: its number, its level, its smallest and largest key, and its size. Replaying the manifest builds this table at startup (Part 9), and each new edit updates it. For the tiny store after write 24:
| Level | File | Smallest key | Largest key |
|---|---|---|---|
| 1 | F13 | order:1 | user:3 |
| 1 | F15 | user:9 | user:9 |
| 2 | F7 | order:7 | order:9 |
| 2 | F8 | user:1 | user:2 |
| 2 | F14 | user:4 | user:8 |
The two kinds of level are routed differently:
- Level 0: ranges overlap, so a read must check every Level 0 file whose range covers the key, newest first.
- Level 1 and deeper: within a level, ranges are disjoint and kept sorted, so one binary search over the files' ranges finds the single file that could hold the key, or shows that none does.
Trace (reads after write 24; Level 0 and the MemTable are empty):
| Read | Level 1: binary search | Level 2: binary search | Answer |
|---|---|---|---|
user:2 | F13 covers it; its Bloom filter says no | F8 covers it; block 1: user:2@12 bea | bea |
user:5 | no file covers it (it falls between F13 and F15) | F14 covers it; its Bloom filter says no | not found, with no data block read |
order:7 | F13 covers it; its Bloom filter says no | F7 covers it; block 0: order:7@15 DEL | not found |
user:6 | no file covers it | F14 covers it; block 0: user:6@24 fay | fay |
Old files and readers. A reader keeps using the set of files that was current when it started, so a file deleted by a later edit stays on disk until the last reader using it finishes. That is why a scan left open for hours, or a snapshot never released, makes disk use grow while the data does not.
What to remember from Part 6
- The manifest is a log of add-file and delete-file edits; one edit makes a whole flush or compaction take effect at once.
- Flushes write to Level 0; a compaction from Level N writes to Level N+1; a trivial move relabels a file without rewriting it.
- In-memory file metadata (level and key range) routes reads: every overlapping Level 0 file, then one binary search per deeper level.
Part 7. B-tree vs LSM tree
An interviewer's next question is almost always "why not a B-tree?". A B-tree is a balanced tree of fixed-size pages (16 KB in MySQL's InnoDB, 8 KB in PostgreSQL) where each key lives in one leaf page, updated in place. Both designs log every write first; they differ in where the rest of the write cost goes.
| Dimension | B-tree (InnoDB, PostgreSQL) | Leveled LSM tree |
|---|---|---|
| Write pattern | Random page writes back in place | Large sequential writes |
| Write amplification, random keys, data far larger than memory | Up to about 33: a 16 KB page written twice for crash safety (InnoDB's doublewrite; PostgreSQL's full-page images in its WAL have a similar effect), plus a 1 KB log record, per 1 KB row (16 × 2 + 1) | Often 10 to 30, all sequential |
| Write amplification, strong key locality | About 3 to 5: several rows share each page write | Lower too: time-ordered keys move down by trivial moves, and repeated updates to hot keys collapse in the upper levels |
| Point read | One path down the tree; upper pages cached | One data block, plus about 0.1 wasted reads with Bloom filters |
| Range scan | Walk neighbouring leaf pages | Merge all levels; tombstones cost extra |
| Space | Pages often part-empty after splits | About 1.1 times the live data with leveled compaction; compresses well |
| Tail latency | Checkpoint bursts, page splits | Compaction competing for the disk; write stalls |
| Typical use | Read-heavy OLTP (online transaction processing: many small reads and writes from users), range queries, steady latency | Write-heavy events, messages, metrics, key-value stores |
Choose an LSM tree when writes dominate, keys are random, and the data is much larger than memory. Choose a B-tree when reads and range queries dominate and steady read latency matters most.
The time-series database in the drill The Time-Series Database That Froze on Flush stalls every hour because compaction falls behind. Why not switch to a B-tree engine like PostgreSQL or InnoDB, which has no compaction?
What to remember from Part 7
- A B-tree pays for writes with random page writes; an LSM tree pays with sequential compaction rewrites.
- With random keys and data far larger than memory, the LSM tree writes fewer bytes, all sequentially; locality lowers both designs' write cost, so the B-tree's case rests on reads, ranges and steady latency.
- Neither always wins: decide by write share, key pattern, data size vs memory and latency needs.
Part 8. End to end: from your code to the disk
So far "write a file" and "read a block" were single steps. In reality a request crosses several layers, each with its own cache, and knowing which layer does what explains why a read is sometimes a few microseconds and sometimes a real disk access, and why "written" is not the same as "durable".
The layers
Synthesizing vector architecture diagram...
write, pread, fsync, fdatasync), then the file system, then the page cache. Only what leaves the page cache reaches the panel "Storage device", the SSD or network volume.The files in the data directory
An engine keeps a database as a directory of files. LevelDB and RocksDB name them like this (numbers come from one counter shared by all files, printed with six digits):
| File | What it holds |
|---|---|
CURRENT | One line: the name of the manifest in use. It is replaced by writing a new temporary file and renaming it over the old one, so it is never half-written |
MANIFEST-000005 (for example) | The manifest: the log of add-file and delete-file edits (Part 6) |
000012.log (for example) | A WAL segment: the log records of one MemTable's writes |
000015.sst (for example) | An SSTable. LevelDB now names new tables .ldb and still reads .sst |
LOCK | Stops a second process from opening the same database |
LOG, and older copies of it | A human-readable information log (flushes, compactions, stalls). Not the WAL: deleting it loses no data |
OPTIONS-000007 (RocksDB only) | The options the database was last opened with |
A write, traced down to the device
Synthesizing vector architecture diagram...
write call returns in microseconds because it only copies the record into the page cache and marks the page dirty (changed in memory, not yet written to the device). The time goes into the fdatasync, which waits until the device has the dirty pages and has flushed its own cache. The MemTable insert and the OK come only after the kernel reports the record durable, which is the write order from Part 2 seen from below the engine. The list below explains each call.write()only copies the bytes into the page cache and returns. The data now survives the process crashing, because the kernel still holds it, but not a power loss or a kernel crash: the kernel writes dirty pages to the device later, on its own schedule.fsync()orfdatasync()makes the kernel write the file's dirty pages to the device and ask the device to put them on stable media, and it returns only when that is done.fdatasync()skips metadata that is not needed to read the data back, such as the modification time, so it is usually cheaper; RocksDB uses it where available.- Whether each write waits for the sync is a setting. RocksDB's default does not sync each write: it gives the "survives a process crash" guarantee of
write()alone, and an application asks for a sync per write (or relies on group commit) when it needs to survive power loss. Whatfsyncreally guarantees, and how group commit shares it among writers, belongs to the Write-Ahead Log, fsync & Group Commit loop primitive. - A flush is the same pattern at a larger size: many
write()calls fill the page cache with the new SSTable, then onefsyncof the file and one of its directory (so the file's name is durable too) before the manifest edit, as in Part 3.
A read, traced down to the device
A GET first searches the MemTables and uses the file metadata to pick candidate files, all in memory. Then, for each candidate file, it needs the filter, the index and one data block:
Synthesizing vector architecture diagram...
pread on F8; if the kernel's page cache holds the bytes, they are copied from memory with no device I/O, and only on a page cache miss does the kernel read the pages from the device. On either miss path, the engine then verifies the block's checksum, decompresses it and inserts it into the block cache, so the next read of the same block is a block cache hit.| Case | What it costs | Why |
|---|---|---|
| Block cache hit | A memory lookup | The engine already has the checked, decompressed block |
| Page cache hit | One pread() system call and a memory copy, plus checksum and decompression | The kernel has the file bytes in memory |
| Real disk read | One device read, far slower than memory | Neither cache holds the block |
pread() reads from a given offset in the file without moving a shared file position, so many threads can read one SSTable at once.
Who does what
| Job | Engine | Operating system kernel | Device |
|---|---|---|---|
| Order of writes, sequence numbers, versions | yes | ||
| MemTable, block cache, file metadata | yes | ||
Deciding when to call fsync (the durability promise) | yes | ||
| Caching raw file bytes (page cache) | yes | ||
Deciding when dirty pages reach the device, unless forced by fsync | yes | ||
| Mapping file offsets to device blocks (file system) | yes | ||
| Storing bytes on stable media, its own internal cache | yes | ||
| Checksums of WAL records and SSTable blocks (Part 10) | yes |
What to remember from Part 8
- A data directory holds
CURRENT, aMANIFEST,.logWAL segments,.ssttables, plusLOCK,LOGand (in RocksDB)OPTIONSfiles. write()reaches only the page cache;fsync()/fdatasync()is what makes bytes durable on the device.- A read is served by the block cache, the page cache or the device, in that order of speed.
Part 9. Restarts and the boot sequence
The process crashes, or the machine reboots. The MemTables are gone, some files may be half-written, and the engine must come back with every acknowledged write and nothing else. This Part is the startup procedure, and why it takes seconds even for a terabyte of data.
The boot sequence
pseudocodeOPEN() 1. take the LOCK file 2. read CURRENT to find the manifest in use 3. replay the manifest's edits in order -> live files per level with their key ranges, last sequence number, oldest WAL segment still needed 4. delete data files the manifest does not list -- leftovers of unfinished flushes and compactions delete WAL segments older than the oldest one still needed 5. replay the WAL segments still needed into a new MemTable, keeping each write's original sequence number and checking each record's checksum stop at the first damaged record (Part 10) 6. next seq = the highest sequence number seen + 1 7. usually: flush that MemTable to a new Level 0 file and record it (Part 3) 8. open for traffic compaction resumes in the background
OPEN is the startup procedure after a crash or a restart, and its purpose is to bring back every acknowledged write and nothing else. It takes no arguments: everything it needs is in the data directory. The LOCK file stops a second process from opening the same database, and CURRENT names the manifest in use. Replaying the manifest's edits gives the live files per level with their key ranges, the last sequence number, and the oldest WAL segment still needed. Files that the manifest does not list are leftovers of unfinished flushes and compactions and are deleted, together with WAL segments older than the oldest one still needed. The remaining WAL segments are replayed into a new MemTable with each write's original sequence number, stopping at the first damaged record, and the next sequence number is set to the highest one seen + 1 (23 after the crash after write 22 in the trace below). OPEN usually flushes that rebuilt MemTable to a new Level 0 file, then opens the store for traffic; its result is a running store with the same contents it had when the last write was acknowledged.
Synthesizing vector architecture diagram...
OPEN without the details, in time order. The engine reads CURRENT to find the manifest, replays the manifest's edits to learn the live files, and deletes the files that the manifest does not list. It then replays only the WAL segments that were not yet flushed into a new MemTable, sets the next sequence number, flushes that rebuilt MemTable, and opens for traffic. RocksDB flushes the rebuilt MemTable at startup by default (step 7), so the old WAL segments can be deleted straight away.Trace: a crash after write 22
The tiny store crashes right after write 22 was acknowledged. What the reference implementation finds on restart:
| Step | Result |
|---|---|
| Replay the manifest (edits 1 to 9 exist at this point) | Level 0: F11. Level 1: F9, F10. Level 2: F7, F8. Logs before wal-6 not needed |
| Delete unlisted files | None in this case: no flush or compaction was running |
Replay wal-6 | user:1@21 ali and order:1@22 mug go back into a new MemTable, with their original sequence numbers |
| Next sequence number | 23 |
Everything acknowledged is back, and the engine replayed two log records, not 22.
Why this is fast
- Only the unflushed tail of the log is replayed: at most a MemTable or two worth of writes (about 64 MB each at real scale), no matter how big the database is.
- SSTables are opened, not rebuilt. Their contents are already durable and immutable; the engine reads only a little metadata from each (footer, index, filter), and the key ranges come from the manifest.
- The manifest stays short, because engines start a fresh one from time to time holding just the current file list (LevelDB does it on every open).
CURRENTis then switched to it by the atomic rename from Part 8.
A crash in the middle of a compaction
A crash during a flush is the table in Part 3. A crash during a compaction (say F3 to F6 being merged into F7 to F10):
| Crash after | What recovery finds | Result |
|---|---|---|
| Writing some of F7 to F10 | Outputs not listed: leftovers, 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 | The manifest lists F3 to F6, which are gone | Missing files: the tree is corrupt |
What to remember from Part 9
- Boot: read
CURRENT, replay the manifest, delete unlisted files, replay the unflushed WAL into a new MemTable, usually flush it, then open. - It is fast because only the unflushed log tail is replayed and SSTables are opened, not rebuilt.
- "New files durable, then one manifest edit, then delete what it replaced" makes a crash at any step safe.
Part 10. Corruption: detecting and recovering
Disks, cables, memory and software all occasionally hand back bytes that are not what was written, and a crash can leave half a record at the end of a file. The engine must notice bad bytes rather than return them as data, and there must be a way to get the data back.
Checksums on everything
Every WAL record and every SSTable block is stored with a checksum of its contents, and the reader recomputes it and compares:
- WAL records: LevelDB's log format puts a CRC32C checksum of each record's type and data in the record's header; RocksDB's WAL uses the same record format.
- SSTable blocks: each block carries a CRC32C or xxHash-family checksum in a small trailer. LevelDB uses CRC32C; RocksDB's current default for new files is XXH3, from the xxHash family, and CRC32C remains an option.
A mismatch means "these bytes are not what was written". The read reports a corruption error instead of returning wrong data.
A damaged WAL
Two very different cases look alike:
- A torn record at the tail. The process or machine died in the middle of an append. When the engine syncs the log before saying "OK", that record was never acknowledged, so recovery simply stops there and drops it. Every engine tolerates this. With the default no-sync setting (Part 8), a power loss can also lose writes at the tail that were acknowledged; that trade-off belongs to the section Durability modes: what "acknowledged" means of the Write-Ahead Log, fsync & Group Commit loop primitive.
- Damage in the middle. Records after the bad one may have been acknowledged. What the engine does is a choice, and RocksDB makes it a setting,
wal_recovery_mode:
| Mode | On a bad record during replay | Default? |
|---|---|---|
kPointInTimeRecovery | Stop replay just before it and open with everything up to that point | Yes |
kTolerateCorruptedTailRecords | Accept damage only at the tail of the log; refuse to open on damage elsewhere | No |
kAbsoluteConsistency | Refuse to open on any damage | No |
kSkipAnyCorruptedRecords | Skip bad records and keep going, to recover as much data as possible when nothing else works | No |
So with the default, mid-log damage does not stop startup: the database opens at the last consistent point before the damage.
A damaged SSTable
- Found on a read: the block's checksum does not match, and the read returns a corruption error for that key. RocksDB verifies checksums on reads by default.
- Found by compaction: a compaction reads every block of its input files, and RocksDB always verifies checksums while compacting. If a block is bad, the compaction fails rather than writing the damage into new files, and the engine reports a background error and stops accepting writes until someone deals with it. So compaction also works as a background check of old data.
- Found at startup: RocksDB's
paranoid_checksoption (on by default) makes the database refuse to open if an SSTable cannot be opened, for example because of a wrong size or a corrupt footer. Turned off, the engine opens and serves what it can from the healthy files.
Getting the data back
| Situation | What you do | What it can lose |
|---|---|---|
| Single node, backup available | Restore the latest backup: the manifest plus the files it lists, which never change once written, so incremental backups are cheap. Then replay any log you still have | Writes since the backup, unless logs were kept |
| Single node, no backup | The engine's repair tool: RocksDB's RepairDB (also ldb repair). It scans the directory, turns readable WAL segments into tables while skipping damaged sections, reads every table it can, and writes a new manifest with every table placed in Level 0 | Any data in damaged log sections or unreadable tables; the result is not guaranteed to match any single point in time |
| Replicated store | Throw the damaged data away and re-copy that key range from healthy replicas. Anti-entropy repair compares replicas using Merkle trees (trees of hashes over key ranges), so only the ranges whose hashes differ are copied | Nothing, as long as a healthy replica exists |
Cassandra as an example. Its read repair fixes replicas that disagree when a read notices the difference. A corrupt local SSTable is a different problem, handled in three steps:
disk_failure_policy(defaultstop) decides what a node does on disk errors. On a single corrupt SSTable found while running, only the stricter settingsstop_paranoidanddietake the node out of service. Found during startup, the defaultstopalso stops the node.nodetool scrubrebuilds the table's SSTables, skipping data it cannot read.nodetool repairthen streams back from the other replicas whatever this node is missing. Use a full repair after corruption: incremental repair skips data it has already marked as repaired.
Synthesizing vector architecture diagram...
What to remember from Part 10
- Every WAL record and every SSTable block carries a checksum (CRC32C or an xxHash-family hash); a mismatch is reported, never returned as data.
- A torn WAL tail is dropped safely; damage mid-log is handled by the recovery mode, which by default opens at the last good point.
- Compaction verifies what it reads and fails rather than spread damage; recovery is a backup or repair tool on one node, or a re-copy from replicas in a replicated store.
Part 11. On AWS
Our node from Part 0 ran on one gp3 volume. In AWS production, an LSM tree runs in one of two ways: inside a managed service that says publicly it uses one, or on EC2 where you run the engine and size its disk yourself. A third group of services looks like an LSM tree and isn't, and mapping those wrong is a common interview mistake. Everything below is stated only from AWS's public documentation and papers; where AWS publishes nothing about a service's storage engine, this page assumes nothing.
Managed services that use an LSM tree
| Service | What AWS has published | What it means on this page |
|---|---|---|
| Amazon Managed Service for Apache Flink | "Managed Service for Apache Flink uses the RocksDBStateBackend", and choosing another backend in code has no effect. The state "is stored in RocksDB, an embedded key/value store that keeps its working state on disk", and each checkpoint also uploads it to Amazon S3. Checkpoints are incremental, so "state ttl is based on RocksDB compaction": expired state stops counting only after a compaction removes it. Each KPU (1 vCPU, 4 GB of memory) comes with 50 GB of running application storage | Every keyed Flink operator is the tree on this page: MemTables, SSTables, compaction. Expired state behaves like a tombstone in Part 5: gone only once compaction drops it. The 50 GB per KPU holds the live files and compaction's temporary space. The streaming side is the Event Time, Watermarks & Checkpoints loop primitive |
| Amazon EMR (Apache HBase) | In HBase's S3 storage mode, "region servers use MemStore to store data writes in-memory, and use write-ahead logs to store data writes in HDFS before the data is written to HBase StoreFiles in Amazon S3". A read that misses the caches reads the StoreFile from S3, "which has significantly higher latency" than HDFS, and "HBase compactions and region servers perform optimally when fewer StoreFiles need to be compacted" | The same three pieces under other names: the MemStore is the MemTable, StoreFiles (HFiles) are the SSTables, and the WAL sits on the cluster's HDFS (the Hadoop Distributed File System, on the cluster's own disks). EMR runs the cluster; flush and compaction settings are still yours |
Running it yourself on EC2
The other way is to run the engine yourself: RocksDB embedded in your own service, or Cassandra, ScyllaDB or HBase on EC2. Then the disk question from Part 0 is yours, with two limits to check, not one: the volume's and the instance's.
| Option | What to know | Our node, in numbers |
|---|---|---|
| Amazon EBS gp3 | Every volume includes 3,000 IOPS and 125 MiB/s. You can buy up to 80,000 IOPS (at most 500 per GiB) and 2,000 MiB/s (at most 0.25 MiB/s per provisioned IOPS). No burst: it holds what you provision indefinitely. Replicated within one AZ. EBS counts a sequential I/O of up to 256 KiB as one I/O | Flush and compaction need about 200 MB/s (100 MB/s of writes plus about as much in compaction reads), which is 190.7 MiB/s: 153% of the included 125 MiB/s. Provision about 300 MiB/s and it runs at 64%, leaving room for bursts of compaction. 300 MiB/s needs at least 1,200 IOPS (300 ÷ 0.25), already inside the included 3,000. At 256 KiB per I/O the 200 MB/s is about 763 IOPS; the log's syncs add about 1,000 (the section On AWS of Write-Ahead Log, fsync & Group Commit), about 1,763 of 3,000 |
| The instance's EBS bandwidth | A second cap under the volume's: each instance size has its own EBS throughput. Smaller sizes list a baseline and a higher maximum, and "can sustain the maximum performance for 30 minutes at least once every 24 hours, after which they revert to their baseline performance" | An r7g.xlarge has a baseline of 156.25 MB/s: 43.75 MB/s short of our 200. Compaction keeps up for half an hour on burst, then falls behind and the Part 5 stall begins, whatever the volume can do. An r7g.2xlarge (312.50 MB/s baseline) runs at 64% |
| Amazon EBS io2 Block Express | Average latency under 500 microseconds for 16 KiB I/O on Nitro instances; up to 256,000 IOPS and 4,000 MiB/s | An LSM tree is usually limited by throughput, not latency, so gp3 is the default. Choose io2 when cache-miss reads or the log's sync latency set your tail latency |
Instance-store NVMe (for example i4i, up to 30 TB; i7ie, up to 120 TB) | Local disks with no network volume cap, included in the instance price. The data survives a reboot, but not a stop, hibernation, termination, a change of instance type, or the failure of that disk | The fastest home for compaction, only if losing the node loses nothing: replicas in other AZs (Cassandra or ScyllaDB, one replica per AZ) or checkpoints in S3 (Flink). Rebuilding costs time: Cassandra streams at 24 MiB/s per sending node by default, so 1 TB from one sender takes about 11 hours |
| Disk space for compaction | Compaction writes its new files before it deletes the old ones (Part 5), so the disk needs free space for the largest merge the policy can run | Say our node holds 1 TB of live data. Leveled: about 1.1 TB in use. Size-tiered: up to about 2 TB in the worst case, a merge of everything. Leave room above that for the log and flushes in progress, and alarm on free space well before it runs out |
| Engine throttles | Self-run engines cap their own background I/O. cassandra.yaml ships compaction_throughput: 64MiB/s and suggests "16 to 32 times the rate you are inserting data" | For our 10 MB/s that is 160 to 320 MB/s, well above the default's 64 MiB/s (67.1 MB/s). If the throttle is set below what the arithmetic needs, compaction falls behind and the engine stalls writes, even when the disk could do more |
Look-alikes that are not an LSM tree
| Look-alike | Why a student might map it here | Why it isn't |
|---|---|---|
| Amazon DynamoDB | "A huge key-value store must be an LSM tree" | AWS's DynamoDB paper (USENIX ATC 2022): each storage replica holds "the write-ahead logs and the B-tree that stores the key-value data". A log plus a B-tree |
| Amazon Keyspaces | "It is Cassandra, and Cassandra is an LSM tree" | It speaks Cassandra's protocol and query language. AWS says Cassandra's "settings for compaction, compression, caching, garbage collection, and bloom filtering are not applicable to Amazon Keyspaces and are ignored if specified", and publishes no storage-engine design. Don't assume SSTables, compaction or tombstone behaviour |
| Amazon MemoryDB | "A durable database with a log" | In-memory data made durable by a "Multi-AZ transactional log": no sorted files and no compaction |
| Amazon OpenSearch Service | "It merges files in the background" | A close parallel, but not this mechanism. A shard is a Lucene index made of segments; smaller segments are merged into larger ones, and a deleted document is only marked until its segment is merged. That is the same "write new files, merge later, purge deletes during the merge" idea, applied to a search index rather than a sorted key-value tree in levels |
| Amazon S3 | "Keep the SSTables in S3" | Object storage: it can hold an engine's files (HBase StoreFiles in EMR's S3 mode, Flink checkpoints) but merges nothing. The tree runs on EMR or EC2 |
| Amazon Aurora, Amazon RDS | "Managed databases, so the same kind of engine" | B-tree engines (InnoDB, PostgreSQL), the other side of Part 7; their log is in the section The WAL in B-tree engines of Write-Ahead Log, fsync & Group Commit |
What to remember from Part 11
- Managed Service for Apache Flink (RocksDB) and HBase on EMR are the managed services AWS documents as LSM engines; DynamoDB (a B-tree) and Keyspaces (no published engine) are not.
- Self-run, size two limits: the volume's throughput (gp3 includes 125 MiB/s, up to 2,000) and the instance's EBS bandwidth, whose burst ends after 30 minutes.
- Keep free disk for the biggest merge, and use instance-store NVMe only when replicas or checkpoints can rebuild the node.
Part 12. What you've learned
Back to the 100,000 writes a second
Our node had to take 100,000 random-key writes a second on a disk that does only 3,000 small random writes a second but streams about 131 MB/s. The LSM tree solved it by never writing in place:
- Each write became a small sequential WAL append and an insert into a sorted MemTable (Part 2).
- Full MemTables became big sequential SSTable writes, flushed in an order that survives any crash (Part 3).
- Reads stayed cheap because they search newest-first and Bloom filters skip files that lack the key (Part 4), and because the manifest and in-memory file metadata send each read to one file per level (Part 6).
- Compaction merges the files in the background (Part 5). Its cost is the write amplification we met in Part 0: at 10, our 10 MB/s of new data becomes about 100 MB/s of disk writes, and compaction reads about as much again, about 200 MB/s against the baseline's 131 MB/s shared by reads and writes. So we provision more gp3 throughput (it goes up to 2,000 MiB/s) or lower the amplification; without either, compaction falls behind and the engine stalls writes (Part 5). If the data keeps growing, shard.
- Below the engine, the kernel's page cache and
fsyncdecide what is durable (Part 8); on restart, only the unflushed log tail is replayed (Part 9); and checksums catch bad bytes before they are served (Part 10). - On AWS, the same node needs about 300 MiB/s of provisioned gp3 throughput for its 200 MB/s, an instance whose baseline EBS bandwidth covers it (an
r7g.2xlarge, not anr7g.xlargewhose burst ends after 30 minutes), and free disk for its biggest merge. If you don't run the engine yourself, Managed Service for Apache Flink and HBase on EMR run one for you; DynamoDB (a B-tree) and Keyspaces (no published engine) are not ones to reason about this way (Part 11).
The cheat card
| Topic | Remember |
|---|---|
| Write order | seq → WAL (durable) → MemTable → visible → OK |
| Sort order | Key ascending, sequence number descending |
| Flush order | Sync file and directory → manifest edit → delete WAL segment |
| Compaction order | Sync outputs → one manifest edit → delete inputs |
| Point read | MemTables, every overlapping Level 0 file newest first, one file per deeper level (binary search on ranges); first visible version wins |
| Dropping an old version | No reader (latest or live snapshot) would return it |
| Dropping a tombstone | No older live snapshot and nothing below may hold the key |
| Bloom filter | 10 bits per key ≈ 1% false positives; no help for scans |
| Sustainable ingest | Disk write bandwidth ÷ write amplification |
| Leveled vs size-tiered | Leveled: cheaper reads and space, more rewrites. Size-tiered: fewer rewrites, more files per read, needs free space for big merges |
| LSM vs B-tree | LSM for write-heavy random keys on data far larger than memory; B-tree for read-heavy work, ranges and steady latency |
| Typical defaults | 64 MB MemTable, 4 KB blocks, level ratio 10, Level 0 compaction, slowdown and stop at 4, 20 and 36 files (RocksDB) |
| Data directory | CURRENT → MANIFEST-… → .sst files; .log files are the WAL; LOG is only a text log |
| Restart | Read CURRENT, replay manifest, delete unlisted files, replay unflushed WAL, open |
| Corruption | Checksums on WAL records and SSTable blocks; default WAL recovery opens at the last good point |
Failure checklist
- Is ingest × write amplification inside the disk's write bandwidth, with headroom?
- Are Level 0 file count, pending compaction bytes and stall time alarmed?
- Are Bloom filters configured, and do filters and indexes fit in the block cache?
- Is there free space for the compaction policy's biggest merge?
- Do scans run over ranges full of tombstones?
- Are long-lived scans or snapshots holding old files and versions?
- Does the application sync the WAL as often as its durability promise needs?
- Is there a tested way back from corruption: backups, repair tooling or healthy replicas?
Think-first drills
Drill 1. 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 2. A compaction into Level 3 of a 6-level tree meets k@50 DEL. No snapshot is live. Can it drop the tombstone?
Drill 3. After a power loss, the engine finds a WAL segment whose last record fails its checksum, and a manifest that does not list a file 000031.sst sitting in the directory. What does it do with each, and is any acknowledged write lost?
Interview questions
| Question | Model answer |
|---|---|
Walk me through a PUT. | Assign a sequence number, append to the WAL and make it durable, 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. |
| How does a read find the newest version? | MemTables, then every overlapping Level 0 file newest first, then one file per deeper level found by binary search on key ranges, skipping files by Bloom filter. Versions sort newest first, so the first one at or below the read's sequence number wins; a tombstone means not found. |
| Why do LSM writes stall? | Flush and compaction must write ingest × write amplification bytes. When the disk cannot, Level 0 and the backlog grow until the engine slows, then stops, writers on purpose. Sustainable ingest is about disk bandwidth ÷ write amplification. |
| What happens on restart after a crash? | Read CURRENT, replay the manifest to get the live files per level, delete unlisted leftovers, replay the unflushed WAL into a MemTable, usually flush it, open. Only the log tail is replayed, so it is fast. |
| How is corruption caught and fixed? | Checksums on every WAL record and SSTable block. A torn WAL tail is dropped; mid-log damage follows the recovery mode. Bad SSTable blocks fail reads and compactions; fix from backup or a repair tool on one node, or re-copy the range from replicas. |
| LSM vs B-tree? | LSM: sequential writes, less write amplification for random keys, better compression; costs read work and compaction interference. B-tree: one read path, cheap with locality; costs random page writes. Neither always wins. |
Where to go next
- The Write-Ahead Log loop primitive, Write-Ahead Log, fsync & Group Commit: what
fsyncreally guarantees, group commit and durability modes. - Bloom Filters & Counting Filters: the filter structure in depth.
- Drill: The Time-Series Database That Froze on Flush, answered in Parts 5 and 7.
- Loops: the distributed key-value store (step 1.2 and compaction) and S3-like object storage (metadata compaction).