Skip to content
Software Atlas

LSM Trees vs B-Trees

Why random writes are expensive, and how log-structured merge trees avoid them: the write-ahead log, memtables, SSTables, Bloom filters and compaction. Then the write/read/space amplification trade-off against B-trees, a toy LSM tree in Go and JavaScript, the real systems built on each, and what goes wrong in production.

⏱ ~50 min read14 sections9 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA B-tree finds the spot where a record lives and edits it in place. An LSM tree never edits anything on disk: it collects writes in memory, writes them out as new sorted files, and merges those files later in the background.

That one design choice decides almost everything else. Editing in place means scattered little writes all over the disk. Appending new files means big, neat writes at the end, which disks love, so LSM trees can swallow far more writes per second. The price is paid at read time and in background work: a value might be in any of several files, and someone has to keep merging them.

Two ways to store the same sorted data
B-tree · update in place[ g | p ]a b c dh k m oq s u zput(k, 2) → read page, change it,write the whole 8 KB page backthe disk: that page lives somewhere in the middleLSM tree · append, merge latermemtable (RAM)k=2 … (sorted)flushsst-3 (newest)sst-2sst-1 (oldest)compactionthe disk: new files go at the end, one long write
Left: a B-tree finds the one page that holds the key and rewrites that page where it is. Right: an LSM tree adds the change to a sorted table in memory, writes it out later as a brand-new file, and merges files in the background. Nothing on disk is ever edited.

Where you'll meet them

Built on B-treesBuilt on LSM trees
PostgreSQL, MySQL (InnoDB), SQLite, bbolt (Go)Cassandra, RocksDB, LevelDB, ScyllaDB, HBase, CockroachDB (Pebble), BadgerDB (Go)
“Read a lot, update in place, predictable speed”“Ingest a firehose of writes: metrics, logs, events, messages”

By the end of this topic you'll know how the write path, the read path, Bloom filters and compaction work; you'll have built a tiny LSM tree in Go and JavaScript; and you'll be able to say when to pick which, and what goes wrong with each in production.

Foundations§02

Why random writes hurt

In one sentenceDisks are fast at long, continuous writes and slow at small, scattered ones. Updating records in place produces the slow kind.

Disks read and write whole pages

A disk never writes “one row”. It reads and writes fixed-size blocks called pages, usually 4–16 KB (Postgres uses 8 KB). To change 100 bytes, the database reads the 8 KB page that holds them, changes the 100 bytes in memory, and later writes the whole 8 KB page back.

Worked example: you update one user's last_seen timestamp, a 100-byte row. The database writes an 8 KB page for it: 80× more bytes than you changed, plus a small record in its log (more on that log in §03). Do that for 10 000 different users per second and the disk is rewriting ~80 MB/s of pages to store ~1 MB/s of actual changes. (Real databases soften this by waiting and writing a page once after several changes, but the shape stays.)

A one-minute B-tree recap

A B-tree keeps keys sorted in a short, wide Tree of pages. The top page says “keys a–f are down this way, g–o that way…”, and after 3–4 hops you reach the leaf page that holds your key. That makes reads fast and predictable. The full story, with a diagram, is in Database indexes.

The catch is in how a B-tree writes. It updates the leaf page in place, at whatever spot on the disk that page happens to live. Your next update is for a different key, so it lands on a different page, somewhere else. And when a page is full, a Page split turns one page into two and updates the parent too: even more scattered writes.

Where do 8 small updates land on disk?
Each update changes one 100-byte row. Each square is one 8 KB page on the disk. Watch the write head (▼).
bytes you changed
0 B
pages written
0 × 8 KB
head jumps (seeks)
0
HDD time, roughly
≈ 0 ms

The 8 rows live in 8 different pages, scattered across the disk. Each update rewrites its whole 8 KB page in place: 64 KB written to store 800 bytes of changes, and on a spinning disk every jump is a seek of ~5–10 ms (≈ 8 ms used here).

Why “scattered” is slow

Sequential and random I/O cost very different amounts, and the gap depends on the hardware:

  • Spinning disk (HDD). A metal arm must physically move to the right track before each scattered write: a seek of about 5–10 ms. That limits you to roughly 100–200 random page writes per second (~1–2 MB/s), while the same disk can stream ~150–250 MB/s sequentially. A ~100× gap (approximate, varies by drive).
  • Solid-state drive (SSD). No arm, so random writes are far faster (see SSD vs HDD). But flash has a quirk: it writes small pages and can only erase big erase blocks of several MB. Overwriting scattered small pages forces the drive to shuffle data internally, which costs speed and wears the drive out sooner. Large sequential writes avoid most of that. The gap is smaller than on HDD (often a few times, depending on the drive), but it's still there.
Foundations§03

The write path: log, memtable, flush

In one sentenceEvery write does two cheap things: append it to a log on disk, and insert it into a sorted table in memory. Then the database says “done”.
function put(key, value) {
  wal.append({ key, value });          // 1. one sequential append on disk (+ fsync)
  memtable.set(key, value);            // 2. sorted insert in RAM
  if (memtable.size >= LIMIT) flush(); // 3. now and then: write it all out as a new file
}

That's the whole write path. No existing data is read, no file is edited. Let's take the three lines one at a time.

1. The write-ahead log: so a crash loses nothing

The table in memory would vanish in a power cut. So before touching it, the write is appended to a Write-ahead log: a plain file that only ever grows at the end. Appending is sequential, the fast kind of write from §02. After the append the database calls fsync, which forces the bytes onto the physical disk instead of leaving them in the operating system's memory. Only then does it answer “OK”.

After a crash, the database reads the WAL from the start and redoes every write in it. (Durability and WALs in general are covered in Transactions; here we only need “append first, so we can replay”.)

2. The memtable: a sorted table in RAM

Next, the write goes into the Memtable, which keeps keys in sorted order at all times. Inserting into memory takes well under a microsecond, so it's never the slow part. Most engines build the memtable as a Skip list, a sorted structure that's easy to make safe for many writers at once; a balanced tree or a sorted map works too.

Why sorted? So that step 3 can write it to disk already in order, front to back, with no extra work.

3. Flush: the memtable becomes a file

When the memtable reaches its size limit (RocksDB's default is 64 MB), the engine freezes it (it becomes Immutable), starts a fresh empty memtable for new writes, and a background thread writes the frozen one to disk as a new SSTable: a sorted, never-to-be-changed file. Once that file is safely on disk, the WAL records it covered are no longer needed and get deleted.

Freshly flushed files form level 0 (L0). §06 explains what happens to them next.

The LSM write path
Pick an action, then click a key. The memtable holds 4 keys, then it is flushed to disk.
RAM · memtable (sorted)0/4
empty
Disksurvives crashes
WAL (append-only log)
empty
SSTables (immutable, newest first)
no files yet: put 4 different keys
Try it: click put(cat), put(dog)… and watch each write go to the WAL and the memtable. The 4th distinct key triggers a flush.

Updates and deletes are just more writes

  • Update = put the same key again. The new version goes into the memtable; the old version stays in whatever older file it's in. Reads will simply prefer the newest one (§04).
  • Delete = write a Tombstone, a marker that says “this key is deleted”. The old value can't be erased because files are never edited, so the tombstone hides it instead. Try delete(dog) above: the ☠ entry travels through the WAL and memtable into an SSTable like any other write.
Practitioner§04

SSTables and the read path

In one sentenceTo read a key, look where the newest data is first: the memtable, then each SSTable from newest to oldest. The first entry you find is the answer, even if it's a tombstone.

(One detail from §03: while a frozen memtable is still being flushed, reads check it too, right after the active memtable and before any file.)

Inside an SSTable

An SSTable isn't one big list you scan from the top. Its sorted records are packed into small data blocks of about 4 KB, and the end of the file holds a Block index listing the first key of each block. To look up a key, the engine does a Binary search on that index to pick the one block that could hold it, then reads just that block.

Inside one SSTable file
data block 0 · byte 0
ant · bee · cat
data block 1 · byte 4096
cow · dog · eel
data block 2 · byte 8192
fox · gnu · owl
bloom filter
0110 1011…
block index
ant→0 · cow→4096 · fox→8192
footer
offsets
get(dog): footer → index: “cow ≤ dog < fox” → read block 1 (one disk read) → find dog inside it
Sorted records are packed into ~4 KB data blocks. At the end of the file sit a Bloom filter (§05), a block index (“block 1 starts with cow at byte 4096”), and a fixed-size footer that says where those two are. To find dog, read the footer and index (usually already cached in memory), binary-search the index → block 1, read that one block.

The index and the Bloom filter (§05) are small (well under 1% of the file), so engines keep them in a Block cache in RAM. Then checking one file costs at most one disk read. Each file also records its smallest and largest key, so a key outside that range skips the file for free.

Walking the read path

Reading from an LSM tree
get("dog"), then get("ant"), step by step.
Code
1function get(key) {
2 const m = memtable.get(key); // 1. newest data first
3 if (m) return m.deleted ? null : m.value;
4 for (const t of sstables) { // 2. newest file → oldest
5 if (key < t.minKey || key > t.maxKey) continue; // can't be here
6 const block = t.index.search(key); // binary search, in memory
7 const e = block.find(key); // read ONE block from disk
8 if (e) return e.deleted ? null : e.value;
9 }
10 return null; // in no file at all
11}
memtable (RAM)
fox = yip
gnu = moo
SSTables on disk, newest first
sst-3 bee…fox
bee · eel · fox
sst-2 cat…eel
cat=purr · dog ☠ · eel
sst-1 ant…dog
ant=tiny · cat=meow · dog=woof
what get() did
nothing yet
Three SSTables on disk (newest first) and a memtable in RAM. Each file remembers its smallest and largest key. Notice: dog was written long ago (sst-1: woof) and later deleted — the tombstone ☠ is in sst-2. Let's run get("dog").
1 / 8

Why newest → oldest? Because the same key can live in several places: an old value in an old file, a newer value or a tombstone in a newer file. The newest one is the truth. Inside the engine every write also gets a Sequence number (1, 2, 3…). When two entries for the same key meet in one place (say, two overlapping L0 files, or during a merge), the one with the higher sequence number is newer and wins.

Try it yourself

Here's the write-path simulator again, now with get and a crash button. Fill a couple of files, put cat again, delete dog, then use get and watch the numbered badges show the order in which places were checked. Then crash: the memtable vanishes, and restart rebuilds it from the WAL.

LSM tree: write, read, crash
Pick an action, then click a key. The memtable holds 4 keys, then it is flushed to disk.
RAM · memtable (sorted)0/4
empty
Disksurvives crashes
WAL (append-only log)
empty
SSTables (immutable, newest first)
no files yet: put 4 different keys
Try it: put a few keys until a flush happens, put cat again, delete dog, then switch to “get” and look up dog and cat. Then crash and restart.

The cost: read amplification

Every file that might hold the key costs a disk read. That's read amplification: how many reads one lookup really takes.

Worked example: 20 SSTables whose key ranges all overlap. Looking up a key that doesn't exist means checking all 20, so up to 20 disk reads, ≈ 2 ms on an SSD at ~0.1 ms each. A B-tree answers the same question with about one read. Missing keys are the worst case, and they're common: “does this username exist yet?” is usually a no.

A range scan merges every file
scan("bee", "eel") across three overlapping SSTables.
sst-3 (newest)
cat = purr
eel = zap
sst-2
bee = buzz
dog ☠
sst-1 (oldest)
ant = tiny
cat = meow
dog = woof
fox = yip
scan output, in key order
🌸 Bloom filters: can't help — there's no single key to test
scan("bee", "eel"): every key from bee to eel, in order. Any of the three files might hold keys in that range, so all three must be opened. A Bloom filter only answers “is this exact key here?”, so it's no use for a range.
1 / 6

Two fixes, and both are essential parts of every real LSM engine: Bloom filters let a read skip files that definitely don't hold the key (§05), and compaction keeps the number of files small (§06).

Practitioner§05

Bloom filters: skipping files for free

In one sentenceA Bloom filter is a small bit array stored with each SSTable. Asked about a key, it answers “definitely not here” or “maybe here”, without touching the disk.

If the answer is “definitely not”, the read skips that file. Only “maybe” costs a disk read. That turns §04's worst case, one read per file, into about one read in total.

How it works

  1. Start with m bits, all 0.
  2. Pick k hash functions. Each turns a key into a bit position, always the same position for the same key.
  3. Add a key: set its k bits to 1.
  4. Check a key: compute its k positions. Any 0 → definitely not added. All 1 → maybe.

Worked example with m = 24 and k = 2: cat hashes to bits 23 and 16, dog to bits 1 and 0. Add both, and bits 0, 1, 16 and 23 are set. Check ant (bits 22 and 19): both 0, so ant is definitely not there. Check hen (bits 0 and 23): both happen to be set already, by dog and cat. The filter says maybe, yet hen was never added. That's a False positive. Try it:

Bloom filter playground
Add words to the set, then check words that were never added. Change the size and the number of hash functions.
10
11
02
03
04
05
06
07
08
09
010
011
012
013
014
015
116
017
018
019
020
021
022
123
add to the set
check (never added)or check an added one
The set holds: cat, dog. Click a word on the right to test it.
keys n
2
bits per key
12.0
false positives (formula)
2.4%
measured (2000 tests)
2.8%

Things to notice while you play:

  • A Bloom filter never says “no” to a key that's there: adding only ever sets bits to 1, so an added key's bits are always all 1.
  • More keys in the same bits → more 1s → more false positives. Give it more bits per key and the rate drops fast.
  • Too few hash functions and each key leaves too faint a mark; too many and the array fills with 1s. The best k is about 0.7 × (bits per key).

In code

bloom.mjs
// FNV-1a: a simple, fast hash. Math.imul multiplies as 32-bit integers.
function fnv1a(s, seed = 0x811c9dc5) {
  let h = seed;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619) >>> 0; // >>> 0 keeps it an unsigned 32-bit number
  }
  return h;
}

class BloomFilter {
  constructor(m, k) {
    this.m = m;                    // number of bits
    this.k = k;                    // number of hash functions
    this.bits = new Uint8Array(m); // all 0 to start
  }
  #positions(key) {                // k positions from 2 hashes: h1 + i·h2
    const h1 = fnv1a(key), h2 = fnv1a(key, 0x9747b28c) | 1;
    return Array.from({ length: this.k }, (_, i) => ((h1 + Math.imul(i, h2)) >>> 0) % this.m);
  }
  add(key) {
    for (const p of this.#positions(key)) this.bits[p] = 1;
  }
  mayContain(key) {                // false = definitely not; true = maybe
    return this.#positions(key).every((p) => this.bits[p] === 1);
  }
}

const f = new BloomFilter(24, 2);
f.add("cat");
f.add("dog");
console.log(f.mayContain("cat"), f.mayContain("ant"), f.mayContain("hen"));
// true false true   ← "hen" is a false positive

The key idea in #positions/positions is a standard trick: instead of k separate hash functions, compute two hashes and combine them as h1 + i·h2 for i = 0…k−1. It works as well as k independent hashes in practice.

How many bits?

With the best k, the false-positive rate is roughly 0.62 ^ (bits per key):

Bits per keyBest kFalse positives (approx.)Memory for 1 billion keys
53~9%0.6 GB
86~2%1 GB
107~1%1.25 GB
1611~0.05%2 GB

10 bits per key is the classic default (LevelDB and RocksDB examples use it). With it, a lookup across 20 files expects 1 real read plus 20 × 1% = 0.2 wasted reads, instead of up to 20.

Here's the LSM simulator with Bloom filters switched on. Each file gets a deliberately tiny 16-bit filter (the strip under each file), so you'll see some false positives (“bloom lied”) along with the skips:

LSM tree: write, read, crash
Pick an action, then click a key. The memtable holds 4 keys, then it is flushed to disk.
RAM · memtable (sorted)0/4
empty
Disksurvives crashes
WAL (append-only log)
empty
SSTables (immutable, newest first)
no files yet: put 4 different keys
Try it: put a few keys until a flush happens, put cat again, delete dog, then switch to “get” and look up dog and cat. Then crash and restart.
Practitioner§06

Compaction: merging files in the background

In one sentenceCompaction reads several SSTables, merges them into fewer, bigger sorted files that keep only the newest version of each key, then deletes the old files.

Without it, an LSM tree slowly falls apart. Every flush adds a file, so reads check more and more files (§04). Every overwritten value and every deleted one still takes up disk space. Compaction fixes both, in the background, while reads and writes carry on.

Merging two sorted files

Because every SSTable is already sorted, merging needs no sorting at all: walk all inputs front to back at the same time, always writing out the smallest key next. That's a K-way merge. It reads every input once and writes the output once, all sequentially, the kind of disk work §02 said is cheap.

Compaction = merging sorted files
Two SSTables become one. Watch overwritten values and the tombstone vanish.
Code
1function merge(older, newer, isBottomLevel) {
2 const out = []; let a = 0, b = 0;
3 const emit = (e) => { if (!(isBottomLevel && e.deleted)) out.push(e); };
4 while (a < older.length || b < newer.length) {
5 const x = older[a], y = newer[b];
6 if (!y || (x && x.key < y.key)) { emit(x); a++; } // key only in older
7 else if (!x || y.key < x.key) { emit(y); b++; } // key only in newer
8 else { emit(y); a++; b++; } // same key: newer wins, older is dropped
9 }
10 return out; // one sorted file, written front to back
11}
older file (sst-1)
▶ ant = tiny
cat = meow
dog = woof
newer file (sst-2)
▶ cat = purr
dog ☠
eel = zap
output file
nothing written yet
Two sorted files: older (sst-1) and newer (sst-2). A cursor (▶) sits at the front of each. We'll merge them into one sorted file, reading each input once from front to back. Let's say this is the bottom level: no older files exist below.
1 / 6

Which files to merge? Two classic strategies

Merging everything every time would be far too much work. Real engines choose a few files at a time, and the rule they use to choose is the compaction strategy.

Size-tiered (the long-time default in Cassandra; also ScyllaDB): wait until there are about 4 files of similar size, then merge them into one file about 4× bigger. Those pile up until there are 4 of them, which merge into one 16× file, and so on.

  • ✓ Each byte gets rewritten only once per size tier, so little write work: low write amplification (bytes the disk writes ÷ bytes you asked it to write; §07 digs in).
  • ✗ At any moment there may be many files of different sizes whose key ranges overlap, so reads may check many files.
  • ✗ Merging the biggest tier needs as much free disk space again as those files take, so plan for ~2× disk space at worst.

Leveled (LevelDB, and RocksDB's default): files are arranged in levels. L0 holds fresh flushes. Every level below has a size limit about 10× bigger than the one above (in RocksDB with static level sizing: L1 ≈ 256 MB, L2 ≈ 2.5 GB, L3 ≈ 25 GB…; recent versions size levels from the bottom up instead, see §11), and within one level the files' key ranges never overlap.

Leveled layout: each bar is one SSTable, placed by the key range it covers
key akey z
L0 fresh flushes · overlap
L1 max ~256 MB
L2 max ~2.5 GB (10×)
L3 max ~25 GB (10×)
L0 files are raw memtable flushes, so each one covers almost the whole key range and they overlap: a lookup may have to check every L0 file. From L1 down, files in a level never overlap, so a lookup checks at most one file per level. Each level is ~10× bigger than the one above (RocksDB defaults; approximate).

When a level grows past its limit, the engine picks one of its files and merges it into the overlapping files of the next level down, replacing them.

  • ✓ A lookup checks the L0 files plus at most one file per level: fast, predictable reads.
  • ✓ Almost all data sits in the last level and old versions get cleaned up quickly, so wasted space is only ~10%.
  • ✗ One file from level N can overlap up to ~10 files in level N+1, so pushing it down rewrites up to ~10× its size (about half that on average). Repeat for every level, and each byte is rewritten many times on its way to the bottom: roughly 5–10 times per level.

Watch them work

Compaction simulator
Each flush writes 1 unit (one memtable). Watch how each strategy merges. Size ratio = 4 to keep it small.
size 1
size 4
size 16
size 64
size 256
app wrote
0 units
disk wrote
0 units
write amp
—×
files to check now
0
most files ever
0
Press “flush ×10” a few times with each strategy and compare “write amp” with “most files ever”. (Simplified: no overwrites, whole-level merges. Real engines move one file at a time, which spreads the same work out.)

Press “flush ×10” a few times under each strategy and compare the write amp and the most files a read ever had to check. (The current file count jumps up and down as merges happen, so look at the maximum.) Size-tiered keeps write amp low but lets the file count climb higher between merges. Leveled keeps it low by rewriting data more often. Neither is free. You're choosing which cost to pay, and §07 puts numbers on it.

Compaction debt

Compaction has to keep up with the incoming writes. If writes arrive faster than compaction can merge, unmerged files pile up. That backlog is called Compaction debt. Reads slow down as it grows, and eventually the engine has to slow down or pause writes until compaction catches up: a Write stall. §11 shows how that looks in production and how to avoid it.

Practitioner§07

The amplification triangle

In one sentenceEvery storage engine pays in three currencies: extra writes, extra reads and extra disk space. Lower one and you raise another. Picking an engine means picking which one you can afford.

The three amplifications

W

Write amplification

Write amplification: bytes the disk writes ÷ bytes your app wrote. You store 1 GB; the WAL, flushes and compactions write 20 GB. Write amp = 20.

R

Read amplification

Read amplification: disk reads per lookup. A B-tree with its upper pages cached: ~1. An LSM tree with 20 overlapping files and no Bloom filters: up to 20.

S

Space amplification

Space amplification: disk used ÷ live data. 100 GB of real data taking 150 GB on disk because of old versions, tombstones or half-empty pages: 1.5.

Where does a write amp of ~20 actually come from? Follow one record:

Life of one record: where write amplification comes from
One 1 KB record through a leveled LSM tree, next to a B-tree. Approximate, ratio 10.
LSM: where the record went
app: put(user:42, 1 KB)
LSM: disk bytes so far
0 KB written for this 1 KB record
B-tree: same record
B-tree: same insert
Follow one 1 KB record through a leveled LSM tree (size ratio 10, 4 levels) and count every byte the disk writes because of it. On the right, the same record in a B-tree.
1 / 7

Researchers call this the RUM conjecture (Read, Update, Memory): a design can be very good at two of the three, but not all three at once. B-trees, size-tiered and leveled LSM trees are three different points in that triangle.

Rough numbers

These are ballpark figures for typical setups, not measurements. Real values depend heavily on the workload, cache size and tuning.

Write ampRead amp (point lookup)Space amp
B-treelow for big rows or batched updates; up to page ÷ row for small random updates (an 8 KB page for a 100 B row is 80×)~1 disk read (upper pages cached)~1.3–1.5 (pages are often ⅔ full after splits)
LSM, size-tieredlow: ~5–10 (one rewrite per tier)many files to check; ~1–2 with Bloom filters, worse for range scansup to ~2× while big merges run
LSM, leveledhigh: ~10–30 (≈ 5–10 per level)~1 with Bloom filters~1.1

Notice that the B-tree's write amp depends on the access pattern. Rewriting an 8 KB page to change 100 bytes is expensive, but if many updates hit the same page before it's written, they share the cost. LSM trees are less sensitive to that: they always write in big sequential chunks, but they rewrite the data several times over.

Play with the trade-off

The amplification trade-off
A rough model, not a benchmark. Move the sliders and watch which column each design pays in.
write amp
disk bytes per app byte
read amp
disk reads per lookup
space amp
disk used ÷ live data
B-tree
≈ 2 – 33
≈ 1.00
≈ 1.30 – 1.50
LSM size-tiered
≈ 7
≈ 1.36
≈ 1.10 – 2.00
LSM leveled
≈ 24
≈ 1.06
≈ 1.11
Model (all approximate): leveled has 4 levels below a 256 MB L1; size-tiered has 5 tiers of 64 MB flushes. Bloom false-positive rate ≈ 0.82%. Write amp counts WAL + flush + compaction (B-tree: WAL + page write; the high end is “every small update rewrites a whole 8 KB page”, the low end is “many updates share a page before it's written”). Read amp assumes index and filter blocks are cached in RAM and counts disk reads for a key that exists; the B-tree assumes its upper levels are cached. Space amp for size-tiered can reach ~2× while its biggest merge runs. Real numbers depend on workload, caching and tuning — measure yours.

Things to try:

  • Size ratio up (say 4 → 20): leveled gets fewer levels but rewrites more per level, so its write amp climbs. Size-tiered gets fewer tiers but more files waiting in each one, so its read amp climbs.
  • Bloom bits to 0: LSM read amp jumps to “one read per file”. That's why no serious LSM engine runs without filters.
  • More data: one more level or tier for every ~10× growth. Write amp grows slowly (with the logarithm of the data size), not linearly.
  • B-tree row size down to 64 B: the worst case for in-place updates explodes, because each tiny change can mean a full page write.

Why write amp matters even on fast SSDs

Disk bandwidth is shared. Compaction writes compete with your reads and writes for the same disk. With a write amp of 20, an app writing 50 MB/s keeps the disk busy writing 1 GB/s.

SSDs wear out. Flash cells survive a limited number of writes. A typical 3.84 TB data-center SSD is rated for roughly 7 000 TB written over its life (approximate; check your drive's spec sheet). An app writing 10 MB/s with write amp 20 puts 200 MB/s on the drive, about 17 TB per day: ~1 year of life. With write amp 5, about 4 years.

Practitioner§08

Build a toy LSM tree in Go and JavaScript

In one sentenceAn LSM tree you can read in one sitting: a map in memory, a log file for safety, and sorted files on disk, with reads that go newest-first.

Each piece maps to a section you've already read:

PieceIn the codeSection
WAL: append, fsync, replay after a crashwrite, Open§03
Memtable, flushed when it holds limit keysmem, flush§03
SSTable: sorted, written once, never editedwriteSorted§04
Read path: memtable, then files newest → oldest; tombstones stop the searchGet§04
Compaction: newest version wins, tombstones droppedCompact§06

To keep it short, the memtable is a plain map that's sorted at flush time, and each “SSTable” is a text file with one JSON object per line (.jsonl), so you can open it and look. Real engines use binary files with a block index and a Bloom filter, but the logic is the same.

main.go
package main

import (
	"bufio"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"slices"
)

// entry is one record. Deleted == true makes it a tombstone.
type entry struct {
	Key     string `json:"k"`
	Value   string `json:"v,omitempty"`
	Deleted bool   `json:"del,omitempty"`
}

type LSM struct {
	dir    string
	limit  int              // flush when the memtable holds this many keys
	mem    map[string]entry // memtable (real engines use a sorted skip list)
	wal    *os.File
	tables []string // SSTable file names, oldest first
	seq    int      // number for the next SSTable file
}

func Open(dir string, limit int) (*LSM, error) {
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, err
	}
	db := &LSM{dir: dir, limit: limit, mem: map[string]entry{}}
	db.tables, _ = filepath.Glob(filepath.Join(dir, "sst-*.jsonl")) // sorted by name = by age
	if n := len(db.tables); n > 0 {
		fmt.Sscanf(filepath.Base(db.tables[n-1]), "sst-%d.jsonl", &db.seq)
	}
	walPath := filepath.Join(dir, "wal.jsonl")
	for _, e := range readLines(walPath) { // crash recovery: replay the WAL
		db.mem[e.Key] = e
	}
	wal, err := os.OpenFile(walPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
	if err != nil {
		return nil, err
	}
	db.wal = wal
	return db, nil
}

func (db *LSM) Put(k, v string) error { return db.write(entry{Key: k, Value: v}) }
func (db *LSM) Delete(k string) error { return db.write(entry{Key: k, Deleted: true}) }

func (db *LSM) write(e entry) error {
	line, _ := json.Marshal(e)
	if _, err := db.wal.Write(append(line, '\n')); err != nil { // 1. append to the WAL
		return err
	}
	if err := db.wal.Sync(); err != nil { // fsync: now it survives a crash
		return err
	}
	db.mem[e.Key] = e // 2. update the memtable
	if len(db.mem) >= db.limit {
		return db.flush()
	}
	return nil
}

func (db *LSM) flush() error {
	db.seq++
	name := filepath.Join(db.dir, fmt.Sprintf("sst-%04d.jsonl", db.seq))
	if err := writeSorted(name, db.mem); err != nil {
		return err
	}
	db.tables = append(db.tables, name)
	db.mem = map[string]entry{}
	return db.wal.Truncate(0) // everything in the WAL is now safe in an SSTable
}

func (db *LSM) Get(k string) (string, bool) {
	if e, ok := db.mem[k]; ok { // 1. memtable: the newest data
		return e.Value, !e.Deleted
	}
	for i := len(db.tables) - 1; i >= 0; i-- { // 2. SSTables, newest first
		for _, e := range readLines(db.tables[i]) { // (a real one binary-searches)
			if e.Key == k {
				return e.Value, !e.Deleted // a tombstone means "deleted": stop here
			}
		}
	}
	return "", false
}

// Compact merges every SSTable into one. Newer versions win; tombstones are dropped
// because no older file is left that they would need to hide.
func (db *LSM) Compact() error {
	merged := map[string]entry{}
	for _, t := range db.tables { // oldest → newest, so newer entries overwrite older
		for _, e := range readLines(t) {
			merged[e.Key] = e
		}
	}
	for k, e := range merged {
		if e.Deleted {
			delete(merged, k)
		}
	}
	db.seq++
	name := filepath.Join(db.dir, fmt.Sprintf("sst-%04d.jsonl", db.seq))
	if err := writeSorted(name, merged); err != nil {
		return err
	}
	for _, t := range db.tables {
		os.Remove(t)
	}
	db.tables = []string{name}
	return nil
}

// writeSorted writes entries as one JSON object per line, sorted by key.
func writeSorted(name string, m map[string]entry) error {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	slices.Sort(keys) // ← the "S" in SSTable: sorted
	tmp := name + ".tmp"
	f, err := os.Create(tmp)
	if err != nil {
		return err
	}
	defer f.Close() // safety net for the early returns below
	for _, k := range keys {
		line, _ := json.Marshal(m[k])
		if _, err := f.Write(append(line, '\n')); err != nil {
			return err // e.g. disk full: never rename a half-written file
		}
	}
	if err := f.Sync(); err != nil {
		return err
	}
	if err := f.Close(); err != nil {
		return err
	}
	return os.Rename(tmp, name) // appears all at once: never half-written
}

func readLines(name string) []entry {
	f, err := os.Open(name)
	if err != nil {
		return nil // no file yet = no entries
	}
	defer f.Close()
	var out []entry
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		var e entry
		if json.Unmarshal(sc.Bytes(), &e) == nil {
			out = append(out, e)
		}
	}
	if err := sc.Err(); err != nil { // e.g. a line longer than 64 KB
		panic(err) // a toy shortcut: real code would return the error
	}
	return out
}

func main() {
	os.RemoveAll("data")     // start fresh (errors ignored below to keep the demo short)
	db, _ := Open("data", 3) // flush after 3 keys
	db.Put("cat", "meow")
	db.Put("dog", "woof")
	db.Put("ant", "tiny") // 3rd key → flush to sst-0001
	db.Put("cat", "purr") // a newer version of cat
	db.Delete("dog")      // a tombstone
	db.Put("eel", "zap")  // flush to sst-0002
	db.Put("fox", "yip")  // stays in the memtable (and the WAL)

	show(db)
	for _, k := range []string{"cat", "dog", "fox", "owl"} {
		v, ok := db.Get(k)
		fmt.Printf("get %s → %q found=%v\n", k, v, ok)
	}

	// Simulate a crash: forget everything in memory and reopen from disk.
	db.wal.Close() // a real crash would close it for us
	db, _ = Open("data", 3)
	v, ok := db.Get("fox")
	fmt.Printf("after restart: get fox → %q found=%v\n", v, ok)

	db.Compact()
	show(db)
}

// show prints every SSTable file, line by line.
func show(db *LSM) {
	for _, t := range db.tables {
		data, _ := os.ReadFile(t)
		fmt.Printf("%s:\n%s", filepath.Base(t), data)
	}
}

Run it (for Go: in an empty folder, go mod init toy, save the file as main.go, then go run .; for Node: node lsm.mjs). The Go version prints this; the Node version prints the same files and answers, written as get cat → purr and get dog → undefined:

sst-0001.jsonl:
{"k":"ant","v":"tiny"}
{"k":"cat","v":"meow"}
{"k":"dog","v":"woof"}
sst-0002.jsonl:
{"k":"cat","v":"purr"}
{"k":"dog","del":true}
{"k":"eel","v":"zap"}
get cat → "purr" found=true
get dog → "" found=false
get fox → "yip" found=true
get owl → "" found=false
after restart: get fox → "yip" found=true
sst-0003.jsonl:
{"k":"ant","v":"tiny"}
{"k":"cat","v":"purr"}
{"k":"eel","v":"zap"}

Read the output against the code:

  1. Flush #1 wrote ant, cat, dog, sorted, even though they were inserted as cat, dog, ant.
  2. Flush #2 holds the newer cat=purr and a tombstone for dog. The old cat=meow and dog=woof are still sitting in sst-0001, untouched.
  3. get cat found purr because it checked the newer file first. get dog hit the tombstone and stopped: not found.
  4. fox was never flushed. After the “crash” (a fresh Open that knows nothing in memory), the WAL replay brought it back.
  5. Compaction merged both files into sst-0003: meow and woof are gone, and so is the tombstone. That's safe here because we merged every file, so nothing older is left for it to hide.
Practitioner§09

Who uses what

In one sentenceMost databases you'll meet use one of these two structures underneath. Knowing which one tells you what that database will be good and bad at.

Many of these are embedded storage engines: libraries that store keys and values on one machine, inside your program. Full databases are built on top of them. Your app rarely talks to RocksDB directly, but the database you talk to may well be using it.

The LSM family

SystemWhat it isWorth knowing
LevelDB (Google, 2011)Embedded Key–value storeThe classic small, readable implementation. Leveled compaction gets its name from it.
RocksDB (Meta, 2012)Embedded engine, forked from LevelDBThe workhorse: inside MyRocks (MySQL), TiKV, YugabyteDB, Kafka Streams and Apache Flink state stores, and many more. Hundreds of tuning options.
CassandraDistributed wide-column databaseEach node runs its own LSM tree (memtable → SSTables). Built for huge write volumes spread across many machines.
ScyllaDBCassandra-compatible, written in C++Same data model, rebuilt for speed; added incremental compaction to avoid the size-tiered 2× space spike.
Bigtable / HBaseGoogle's distributed table store; HBase is the open-source cloneThe 2006 Bigtable paper popularised memtables and SSTables. HBase calls them MemStore and HFiles.
PebbleGo rewrite of RocksDB's ideasCockroachDB's storage engine since 2020. Pure Go, so no cgo (no C compiler needed to build it).
BadgerDBEmbedded Go key–value store (from Dgraph)Uses Key–value separation: the LSM tree holds only keys and pointers, big values live in a separate log, so compaction moves far less data.

Several time-series and analytics databases use the same idea under different names. ClickHouse's MergeTree writes sorted “parts” and merges them in the background. Older InfluxDB versions had a Time-Structured Merge tree. VictoriaMetrics merges sorted parts too.

The B-tree family

SystemWorth knowing
PostgreSQLTables are stored as pages of rows, with separate B-tree indexes. An update writes a new copy of the row (often into the same page) and a background VACUUM cleans up old copies later. Still page-based, in-place I/O.
MySQL (InnoDB)The table is a B-tree, ordered by primary key; secondary indexes are more B-trees.
SQLiteOne file, one B-tree per table and per index. Runs on every phone.
bbolt (Go)A small embedded B+tree key–value store. etcd, and so Kubernetes, keeps its data in it. Instead of overwriting a page, it writes changed pages to free space and then switches over (copy-on-write).
MongoDB (WiredTiger)B-tree based by default.
Practitioner§10

Choosing: LSM or B-tree?

In one sentenceA firehose of writes (metrics, logs, events, messages) points to an LSM tree. A read-heavy workload with updates, range scans and a need for steady latency points to a B-tree.

In practice you rarely choose a data structure directly. You choose a database, and the structure comes with it. Knowing what's underneath tells you how that database will behave under your workload.

Workload → choice

WorkloadLean towardsWhy
Metrics, IoT sensor data, logs, clickstream: 100k+ writes/s, mostly appendedLSMWrites are sequential and cheap; data arrives roughly in key order, so compaction stays light (§11).
Chat messages, activity feeds, event history across many machinesLSM (Cassandra/ScyllaDB)Write-heavy, read by key and recent range, easy to spread over a cluster.
A queue or job table where rows are inserted, updated, then deleted quicklyB-tree (or a real queue)Every delete in an LSM tree leaves a tombstone, and readers must wade through them (§11, §12).
Orders, accounts, inventory: transactions, frequent updates, ad-hoc queriesB-tree (Postgres, MySQL)Predictable one-or-two-page reads, mature tooling. Writes are usually not the bottleneck.
Read-mostly reference data: product catalog, settings, permissionsB-treeLittle write pressure to optimise for; reads stay fast and steady.
Heavy writes and you need SQL and transactionsEither, e.g. MyRocks, CockroachDB, TiDBSQL databases built on LSM engines exist precisely for this.

HDD vs SSD changes the gap

On spinning disks, random writes are ~100× slower than sequential ones (§02), so an LSM tree's advantage on writes is enormous. That's the world LSM trees were invented for.

On SSDs, random I/O is fast, and a well-cached B-tree handles a lot of writes comfortably. The LSM tree still writes in big chunks, which suits flash and can reduce wear, but the speed gap is much smaller. With fast NVMe drives, the bottleneck often moves to the CPU (compaction, compression) instead of the disk.

Don't trust a 5-minute benchmark

LSM trees are easy to benchmark wrong, because the bill arrives late:

  • Run until compaction reaches a steady state. The first minutes are pure memtable and L0 flushes, with no compaction debt yet. Run for hours, with more data than RAM, or you're measuring the honeymoon.
  • Look at p99, not the average. An LSM tree's average can look great while compaction spikes hurt the slowest 1%.
  • Use your real key pattern. Sequential keys (timestamps) and random keys (UUIDs) behave very differently in both structures.
  • Mix reads and writes the way production does. Compaction competes with reads for the disk.
  • Fill the disk to realistic levels. SSDs slow down as they fill, and size-tiered compaction needs free space.
Staff-level§11

Production: stalls, tombstones, tuning

In one sentenceAn LSM tree postpones work. Production trouble almost always comes from that postponed work arriving at a bad moment: compaction that can't keep up, tombstones nobody has cleaned up, or a WAL that takes ages to replay.

1. Compaction shows up in your p99

Compaction reads and rewrites gigabytes in the background, on the same disks and CPUs that serve your queries. While a big compaction runs, reads queue behind it, and the freshly written files aren't in the Block cache yet. Averages barely move, but the slowest requests (p99) get spikes every time a large merge runs.

What helps:

  • Rate-limit compaction so it never takes all of the disk: RocksDB's rate_limiter, Cassandra's compaction_throughput. Too low and debt builds up, though, so this is a balance.
  • Give it enough threads. RocksDB's max_background_jobs defaults to 2, which is low for a big server with fast NVMe drives.
  • Watch compaction debt, not just latency: pending compaction bytes (RocksDB), pending tasks (nodetool compactionstats in Cassandra).

2. Write stalls: the engine protects itself

If writes outrun compaction for long enough, files pile up in L0 and every read must check all of them. To stop that spiral, the engine throttles writes on purpose: a Write stall.

When compaction falls behind
writes
fast
slowed
stopped
slowed
fast
flushes
f
f
f
f
f
f
f
L0 files
2
8
20 slow
36 stop
12
3
compaction
running flat out the whole time — the disk can only do so much
A burst of writes flushes memtables faster than compaction can merge them. L0 files pile up; reads check more files and get slower. When L0 crosses the slowdown limit, the engine throttles writes; at the stop limit it pauses them entirely until compaction catches up. Your p99 write latency shows it as a cliff.

RocksDB's defaults: compaction starts at 4 L0 files, writes are slowed at 20 and stopped at 36. It also stalls when too many memtables are waiting to be flushed (max_write_buffer_number, default 2), or when pending compaction bytes pass a soft or hard limit (64 GB / 256 GB). The log line to look for is Stalling writes because….

3. See it in a real engine

Here is real output from Pebble (CockroachDB's LSM engine, written in Go). The program writes 2 million random 100-byte values over 1 million keys, as fast as it can, with a tiny 1 MB memtable so that a lot happens quickly:

main.go
package main

import (
	"fmt"
	"log"
	"math/rand/v2"
	"os"

	"github.com/cockroachdb/pebble/v2"
)

func main() {
	os.RemoveAll("demo-db")
	db, err := pebble.Open("demo-db", &pebble.Options{
		MemTableSize:          1 << 20, // 1 MB memtable (default 4 MB) so flushes happen quickly
		L0CompactionThreshold: 4,       // compact L0 once it has 4 files
	})
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	value := make([]byte, 100)
	for range 2_000_000 {
		key := fmt.Appendf(nil, "user:%07d", rand.IntN(1_000_000)) // random keys: lots of overwrites
		for j := range value {
			value[j] = byte(rand.IntN(256)) // random bytes don't compress
		}
		if err := db.Set(key, value, pebble.NoSync); err != nil {
			log.Fatal(err)
		}
	}
	db.Flush()
	fmt.Println(db.Metrics())
}

Trimmed output from one run (Pebble v2.1.7; your numbers will differ from run to run):

level | tables   size |   in   | written | r-amp  w-amp
    0 |     32  6.0MB |  263MB |  220MB |    9   0.84
    5 |     20   69MB |  109MB |  803MB |    1   7.35
    6 |     18   67MB |  105MB |  911MB |    1   8.67
total |     70  141MB |  263MB |  2.1GB |   11   8.35
WAL: in: 242MB  written: 263MB
Flushes: 290   Compactions: 81   estimated debt: 94MB

How to read it:

  • Write amp ≈ 8. The app wrote 242 MB; the engine wrote 2.1 GB, counting flushes and compactions, plus 263 MB of WAL.
  • Read amp = 11. We wrote faster than compaction could merge, so L0 holds 32 files in 9 overlapping layers (each layer counts as one place to check), plus one place each in L5 and L6 (their files don't overlap, so a lookup touches only one file per level). That's compaction debt (94 MB) made visible.
  • L1–L4 are empty. Pebble, like RocksDB's level_compaction_dynamic_level_bytes (on by default in recent versions), sizes levels from the bottom up, so a small database skips the middle levels entirely.
  • Space amp ≈ 1.45. About 865 000 distinct keys × ~112 bytes ≈ 97 MB of live data takes 141 MB on disk, because older versions waiting in L0 haven't been merged away yet.

4. Tombstones: Cassandra's classic trap

In a distributed database, a delete has to reach every replica (see Replication). If one replica was down during the delete and compaction drops the tombstone on the others, that replica's old copy can later be copied back: the deleted data comes back. So Cassandra keeps tombstones for gc_grace_seconds (default 10 days) before compaction may drop them. You must run repairs (a background job that makes replicas agree) more often than that, so every replica has seen the delete before the tombstone goes.

The trap: using a Cassandra table as a queue. Insert jobs into one partition (the rows sharing one partition key, stored together), delete each when it's done. A week later, reading “the first pending job” means scanning past hundreds of thousands of tombstones before reaching one live row. Cassandra logs a warning at 1 000 tombstones scanned in one query (tombstone_warn_threshold) and aborts the query at 100 000 (tombstone_failure_threshold).

Fixes: don't build queues on LSM tables (use a real queue, see Worker pools); split such data into time-bucketed partitions you drop whole; prefer one range tombstone (deletes every row between two keys) or partition tombstone (deletes the whole partition) over thousands of single-row ones.

5. TTLs and why time-series fits LSM trees

A TTL marks data to expire, say after 30 days. In an LSM tree, expired data is only really removed when compaction rewrites the file it's in. That can be weeks later if the file sits in a deep, rarely compacted level.

Time-series data dodges most of this, which is why LSM trees and Time-series data fit so well:

  • Keys arrive in order. Each flush covers a new, later time range, so files hardly overlap. RocksDB can then push a file down a level by just recording the move, with no rewrite (a “trivial move”), so write amp drops.
  • Old data expires in whole files. Cassandra's TimeWindowCompactionStrategy groups SSTables by time window (e.g. one per day). When a window's TTL passes, the whole file is deleted without merging anything. RocksDB has FIFO compaction and DeleteRange for the same idea.

6. The knobs that matter

Knob (RocksDB name)DefaultTurn it up →Turn it down →
Memtable size (write_buffer_size)64 MBfewer, bigger flushes; more RAM, longer WAL replaymore L0 files, more compaction
Level size ratio (max_bytes_for_level_multiplier)10fewer levels: better reads, more write amp per levelmore levels: less write amp, more files to check
L1 size (max_bytes_for_level_base)256 MBfewer levels for the same datamore levels
L0 triggers (compaction / slowdown / stop)4 / 20 / 36absorbs bursts; reads slower during themstalls sooner, reads stay fast
Bloom bits per keyoff until you set a filter policy; 10 is typicalfewer wasted reads, more RAMless RAM, more wasted reads
Block cache size32 MB if you don't set one (current RocksDB; older versions used 8 MB)more hits, fewer disk readsmore disk reads
Compression per levelSnappye.g. LZ4 for upper levels, ZSTD for the bottom level, where ~90% of the data lives—

Change one knob at a time, under a realistic load that runs long enough for compaction to settle (§10), and watch write amp, stall counters and p99 together.

7. Crash recovery and “durable” writes

After a crash, the engine replays the WAL to rebuild its memtables (§03). Two production details:

  • Replay time grows with memtable size. Several large memtables plus their WAL can mean a long startup. Bigger memtables trade restart time for fewer flushes.
  • “Written” doesn't always mean fsynced. RocksDB's WriteOptions.sync is false by default. A write survives a crash of your process (the OS still holds it), but not a power cut of the machine. Cassandra's default commitlog_sync: periodic fsyncs the commit log every 10 seconds and relies on other replicas to cover the gap. Know which one you're running, and decide whether that's acceptable for your data.
Practitioner§12

Pitfalls

In one sentenceMost LSM mistakes come from forgetting that deletes are writes and compaction is a bill that always arrives. Most B-tree mistakes come from scattered writes.
01

Using an LSM table as a queue

Insert a job, delete it when it's done, repeat a million times. Every delete leaves a Tombstone, and “give me the oldest pending job” must scan past all of them until compaction finally removes them (in Cassandra, not before gc_grace_seconds, 10 days by default). Reads get slower every day until queries fail. Fix: use a real queue, or partition by time bucket and drop whole buckets (§11).

02

Benchmarking the honeymoon

A 5-minute test on an empty database measures memtable inserts and a few flushes. Compaction debt hasn't built up yet, so you see the best write speed you'll ever get. Fix: run for hours, with more data than RAM, mixing reads and writes, and compare p99, not averages (§10).

03

Running out of disk in the middle of a compaction

A merge writes its output before deleting its inputs. Merging the biggest tier with size-tiered compaction can briefly need as much free space again as the data it merges. With 80% of the disk full, that compaction can't run, debt grows, and the node falls over. Fix: keep headroom: plan for ~50% free with size-tiered, less with leveled, and alert on it.

04

Expecting a delete to free space right away

A delete adds a record. Mass-deleting 100 GB makes the database bigger until compaction has merged the tombstones down past every old copy, which can take days for data in deep levels. Fix: for big cleanups, drop whole tables or partitions, use TTL with time-window compaction, or use a range delete (RocksDB's DeleteRange) instead of millions of single-key deletes.

05

Read-before-write on every request

“Read the counter, add 1, write it back” turns every cheap LSM write into a read plus a write. The read may have to check several files, so you lose the reason you chose an LSM tree. Fix: design for blind writes: append events and add them up later, or use engine features built for this, such as RocksDB's merge operator or Cassandra counters, which combine updates during reads and compaction.

06

Raising stall limits to make stalls go away

Write stalls are a symptom: compaction can't keep up. Raising level0_stop_writes_trigger just lets L0 grow bigger, so reads get slower and the eventual stall lasts longer. Fix: add compaction capacity (threads, faster disks), reduce write amp by tuning, or spread the load over more machines (§11).

07

Assuming “OK” means “on disk”

RocksDB doesn't fsync each write by default (sync = false). Cassandra's commit log fsyncs every 10 s by default. Both are reasonable choices, but a machine-level power cut can lose the last moments of acknowledged writes. Fix: know your engine's setting, and turn on per-write sync (with group commit) where losing acknowledged data isn't acceptable.

08

Expecting Bloom filters to speed up range scans

Filters answer “is this exact key here?”. A scan over a range must still open every file that overlaps it. Fix: keep the number of overlapping files low (leveled compaction, enough compaction throughput), and use prefix filters if your scans are always within one prefix (§05).

09

Random keys in a B-tree

Random IDs such as UUID version 4 spread inserts across every leaf page. Each insert dirties a different page, pages split everywhere and end up half-empty, and the working set no longer fits in memory. Fix: use time-ordered keys (an auto-increment id, or UUID version 7, which starts with a timestamp) so inserts go to the rightmost pages. Interestingly, this ordering helps LSM trees too, through trivial moves (§11).

10

Big values in a plain LSM tree

Storing 50 KB documents in a leveled LSM tree means compaction copies those 50 KB again at every level, even though they never change. Write amp in bytes becomes huge. Fix: use key–value separation (BadgerDB, RocksDB BlobDB) or keep big blobs in object storage and store only a reference (§11).

Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §04, Q2 in §05, Q3 in §02–§03, Q4 in §06 and §11, Q5 in §10, and Q6 uses the toy LSM tree from §08.

QUESTION 1 / 6
Which value comes back?
memtable:  (empty)
sst-2 (newer):  cat=purr   dog=☠ (tombstone)
sst-1 (older):  ant=tiny   dog=woof

get("dog") → ?
QUESTION 2 / 6
A Bloom filter for one SSTable answers “maybe” for key "owl". What do you know?
QUESTION 3 / 6
Why does an LSM tree accept writes faster than a B-tree on the same disk?
QUESTION 4 / 6
Zombie data
L1:  dog=☠ (tombstone, written today)
L2:  (nothing about dog)
L3:  dog=woof (written last year)

A compaction merges L1 into L2 and DROPS the tombstone.
Then: get("dog") → ?
QUESTION 5 / 6
Which workload fits an LSM tree best?
QUESTION 6 / 6
Predict the output (the toy LSM from §08, limit = 2)
db, _ := Open("data", 2)
db.Put("a", "1")
db.Put("b", "2")
db.Delete("a")
db.Put("c", "3")
v, ok := db.Get("a")
fmt.Println(len(db.tables), v == "", ok)
Answered 0 / 6
Foundations§14

Cheat sheet

📒
B-tree: edit in placeFind the page, change it, write the page back where it lives. Steady, fast reads; small scattered writes.
🗒️
LSM: append, merge laterWAL append + memtable insert, flush to a new sorted file when full, merge files in the background.
🔎
Read newest → oldestMemtable, then SSTables from newest to oldest. The first entry found wins, and a tombstone means “deleted”.
🌸
Bloom filter = “no” or “maybe”Lets a lookup skip files. ~10 bits per key ≈ 1% false positives. No help for range scans.
🧹
Compaction keeps it healthyMerge sorted files, newest version wins. Tombstones are dropped only when nothing older can exist below.
📐
Size-tiered vs leveledTiered: fewer rewrites, more files to read, ~2× space at peak. Leveled: more rewrites, ~1 file per level, ~10% extra space.
🔺
Pick two of threeWrite amp, read amp, space amp. Every design and every knob moves you around this triangle.
🚦
Watch compaction debtIf compaction falls behind, reads slow down and then writes stall. Measure p99 and pending compaction, not averages.
B-treeLSM, size-tieredLSM, leveled
Write pathread page, modify, write page in placeWAL + memtable, sequential flushessame
Write amp (approx.)low to ~page/row~5–10~10–30
Point read (approx.)~1 disk read~1–2 with Bloom filters~1 with Bloom filters
Range scangreatmust merge many filesgood
Space amp (approx.)~1.3–1.5up to ~2~1.1
Deletesremove in placetombstones until compactiontombstones until compaction
ExamplesPostgres, InnoDB, SQLite, bboltCassandra (default for years), ScyllaDBRocksDB, LevelDB, Pebble
If you see…Think…
Sustained 100k+ writes/s, mostly appends (metrics, logs, events)LSM tree
Read-heavy, many updates, range queries, strict p99B-tree
“Stalling writes” in the RocksDB logcompaction can't keep up: more throughput or less input
Cassandra tombstone warningsqueue-like delete pattern; redesign the partitions
Disk usage grows after a mass deletenormal: tombstones; wait for compaction or use range/partition drops
p99 spikes every few minutesbig compactions; rate-limit and add compaction threads
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.