LSM Compaction — the trade-off you cannot escape
Published
Your bar: redraw the two compaction strategies, the amplification triangle, and the resurrection bug on a whiteboard — and pick a strategy per table by its access pattern. Lesson 3 left “compaction” as one word. It is really a policy, and it is the single biggest performance decision in an LSM engine. 1
One sentence holds the whole deep dive. Each level just unpacks one chip of it:
but every policy is scored on 3 amplifications
you can only win two of three (RUM),
and getting deletes wrong resurrects data.
The job & the three amplifications
The LSM path is append-only: dead data piles up and one key scatters across files. Compaction’s bill comes in three currencies.
The job Reclaim space from dead records (overwrites, deletes) and consolidate a key’s scattered history into fewer files.
Why it exists The LSM path is append-only — you can’t update in place, so garbage and duplicate keys accumulate forever without it.
Write amp Disk bytes ÷ app bytes. Background rewrites cost real write bandwidth and flash endurance.
Read / Space amp Files touched per logical read; and disk bytes ÷ live bytes. One read latency, one storage bill.
⚖️ Iron law: Lowering one amplification raises another — there is no free strategy, only one matched to your workload.
(RUM conjecture 3 )
Memory check
- Name the three amplifications. → write, read, space
- What forces compaction to exist? → the append-only path piles up dead data
- The iron law in one line? → lower one amp, another rises
Size-tiered (STCS) — the write-optimized corner
Bigtable’s original, Cassandra’s historical default. Group SSTables by size; merge a batch of same-size tables into one of the next size up.
log(N) times — cheap writes.
The bill is disk: a big merge briefly needs ~2× the dataset, and stale copies live across tiers.
Write amp · LOW A key is rewritten once per tier it passes, tiers grow geometrically
→ ~log(N) total. Writes are cheap.
Space amp · HIGH A merge needs room for inputs + output at once (~2× worst case); stale copies linger across un-merged tiers.
Read amp · MED–HIGH A point lookup may consult one (or more) SSTable per tier; a key that exists can be chased through several.
Reach for it Write-heavy ingest, disk to spare. The write-optimized corner of the triangle.
✓ A single big merge can momentarily double disk; clusters have run out of disk because of compaction
📥 Memory rule: Size-tiered = merge same-size tables upward; cheap writes (~log N), but high space (~2× spikes) and duplicate keys across tiers.
Memory check
- What groups SSTables in STCS? → their size
- Which amp is its weakness? → space (~2× during a big merge)
- When to reach for it? → write-heavy, disk to spare
Leveled (LCS) — the read-and-space-optimized corner
LevelDB’s design; RocksDB’s & modern Cassandra’s choice for read-heavy data. Levels
L0,L1,L2… each ~T× (≈10) the last.

Size-tiered vs leveled, side by side. Left: STCS merges same-size tables into bigger tiers (cheap writes, lots of space, a key may live in several tiers). Right: LCS keeps each level non-overlapping and ~10× the last, merging one table down at a time (cheap reads + space, but each key rewritten many times).
| Dimension | Size-tiered (STCS) | Leveled (LCS) |
|---|---|---|
| Groups by | Size of SSTable | Level (each ~10× the last) |
| Overlap | Tables can overlap | Non-overlapping below L0 |
| Write amp | Low (~log N) | High (10–30×) |
| Read amp | Med–high | Low (≤1 file/level) |
| Space amp | High (~2× spikes) | Low (~1.1×) |
✓ It buys low read + space by paying 10–30× write bandwidth; wrong for write-heavy ingest
📤 Memory rule: Leveled = non-overlapping levels, ~10× each. Read amp ≤1 file/level, space ~1.1×, but write amp ~T·log_T(N) = 10–30×.
Memory check
- The LCS invariant below L0? → non-overlapping key ranges, ≤1 table/level
- Why is L0 the exception? → it takes raw flushes that overlap
- The number to remember? → write amp ~10–30×
The trade-off triangle — pick two of three
Put both strategies on one picture and RUM gets concrete: minimise any two of read/write/space and the third suffers.

The amplification trade-off. Read, write, and space form a triangle; a strategy is a point inside it. Leveled sits near low-read/low-space (paying write); size-tiered sits near low-write (paying read and space). You cannot reach all three corners at once.
| Strategy | Write | Read | Space | Reach for it when |
|---|---|---|---|---|
| Size-tiered | low | med–high | high | write-heavy, disk to spare |
| Leveled | high | low | low | read-heavy, space-constrained |
| Time-window (TWCS) | low | low* | low | time-series with TTLs |
- within a time window. Hybrids sit between corners: RocksDB universal compaction is tiered-leaning; Cassandra TWCS buckets SSTables by time so whole old buckets drop at once.
✓ Senior move: choose per table / column family by access pattern — like choosing indexes per query
🔺 Memory rule: RUM: you may minimise two of {read, write, space} — never all three. Match the corner to the table’s workload.
Memory check
- How many of the three can you minimise? → two
- Where does TWCS fit and why? → time-series/TTL; whole old buckets drop at once
- At what granularity should you choose? → per table / column family
Tombstones & the resurrection bug
A delete can’t erase in place — the data is in immutable SSTables. So a delete writes a tombstone: “this key is dead as of this sequence number.”

Tombstone resurrection. A delete writes a tombstone, but an older SSTable still holds the value. Purge the tombstone before that old SSTable is compacted away, and the value reappears. The tombstone must survive until compacted past all older copies.
The rule Purge a tombstone only after compaction has carried it where no older data for that key can remain — bottom level (LCS), or merged past every older tier (STCS).
Distributed twist Cassandra holds tombstones for gc_grace_seconds
(default 10 days) — the delete must reach every replica first, or read-repair/hinted-handoff
re-shares the old value cluster-wide.
Read amp from tombstones Tombstones are data until purged; a range read scans them all. A queue-like (write/read/delete) workload buries a partition in them.
The symptom “My queue table times out reads” is, 9 times in 10, tombstone overload —
Cassandra aborts past tombstone_failure_threshold (default 100,000).
✓ A delete WRITES a marker; the key + tombstone are dropped together only after compaction passes every older copy
🪦 Memory rule: A tombstone must outlive every older copy of its key — premature GC is a real, shipped data-loss-in-reverse bug.
Memory check
- What does a delete actually write? → a tombstone (a marker), not an erase
- When may a tombstone be purged? → once compacted past all older copies of the key
- Why
gc_grace_seconds? → the delete must reach every replica first
L0, write stalls & back-pressure
The operational reality that pages you. L0 takes raw flushes (overlapping), and it’s the pressure gauge for the whole tree.
Why L0 is special It takes raw memtable flushes that overlap → a read scans all of L0, and its file count is the pressure gauge for the tree.
Back-pressure If writes outrun the drain rate, the engine deliberately slows then stops writes so compaction can catch up — that’s the stall, not a failure.
RocksDB knobs level0_slowdown_writes_trigger (throttle),
level0_stop_writes_trigger (halt), and soft/hard
pending_compaction_bytes_limit (by debt).
The fix Never “write faster”: give compaction more I/O (threads, faster disks), pick a cheaper-write strategy (tiered), or smooth the write rate.
✓ It’s back-pressuring: compaction fell behind L0 and the engine is throttling on purpose
🚦 Deepest LSM truth: Your sustainable write rate is bounded by your compaction throughput, not your disk’s raw write speed.
Memory check
- Why must a read touch all of L0? → its SSTables overlap; no binary search
- What are stalls, really? → deliberate back-pressure while compaction catches up
- What bounds sustainable write rate? → compaction throughput, not raw disk speed
Interview — pick your bar
Answer out loud in ~60s, then reveal. Core = recall · Senior = trade-offs & failure modes · Staff = synthesis under ambiguity · System Design = open design round (a different axis, not a harder level).
Walk the LSM write path from a client write to a key on disk: memtable, WAL, SSTable.
What is an SSTable, and why does its sortedness make merging cheap?
key,value entries sorted by key, with a sparse index so a key can be found with one seek into a block → immutability means readers and compaction never contend over in-place edits — you only ever create new files and delete old ones → because every SSTable is already sorted, merging N of them is a linear k-way merge in one pass, like merge-sort's merge step: no random I/O, no re-sorting → during the merge you keep the newest value per key and drop shadowed/tombstoned ones → that "two sorted files merge in one sequential pass" is the property the whole engine is built on.Name the three amplifications and say who pays for each.
What does a Bloom filter do in an LSM read path, and what guarantee does it give?
Compare size-tiered and leveled compaction. When do you pick each?
log N times → that buys low write amp but costs space (a big merge needs ~2× the dataset, and stale copies linger across un-merged tiers) and med–high read amp → LCS keeps levels L1+ non-overlapping, each ~10× the last, merging one table down into the few it overlaps → that buys low read amp (≤1 file per level + L0) and low space amp (~1.1×) but pays write amp ~T·log_T(N) ≈ 10–30× → pick STCS for write-heavy ingest with disk to spare; LCS for read-heavy, space-constrained data.Why can leveled compaction guarantee a point read touches at most one SSTable per level?
log_T(N) → that bounded read amp is exactly what you pay the 10–30× write amp to get.Walk me through the tombstone resurrection bug and how engines prevent it.
gc_grace_seconds (default 10d) so the delete propagates to every replica before GC, or a node that missed the delete re-replicates the stale value.What triggers compaction, and why does an LSM under heavy ingest start stalling writes?
Should one compaction strategy apply to the whole database? Make the principal-level call.
"Leveled is the modern default, just use it everywhere." Steelman it, then take it apart.
Where do TWCS and RocksDB's universal compaction sit relative to STCS and LCS, and why do hybrids exist?
- Clarify the workload per table: write:read ratio, point vs range reads, update/delete rate, TTLs.
- State the SLO and budget: p99 read latency, sustainable write rate, disk headroom.
- Map onto RUM: which of read/write/space can you afford to spend, which must stay low.
- Pick the corner: STCS (write-heavy), LCS (read/space-heavy), TWCS (time-series + TTL), or a hybrid.
- Handle deletes: tombstone GC vs replication window (
gc_grace_seconds), avoid tombstone-heavy access patterns. - Protect reads: Bloom filters, block cache, partition/clustering key so hot data sits in fresh runs.
- Trip-wires: L0 / pending-compaction bytes, write stalls, space-amp spikes during merges — and the metric that says re-tune.
Design the compaction strategy for a write-heavy ingest table vs a read-heavy lookup table in the same cluster.
log N rewrites keep write amp low and protect the sustainable write rate; accept the ~2× space spikes and med–high read amp, and provision disk headroom so a big merge can't exhaust the volume → if events carry TTLs, prefer TWCS so whole old buckets drop at once — lowest write + space + read within a window → read-heavy lookup (point reads, latency-sensitive, space-constrained): leveled, paying 10–30× write amp to buy ≤1 file/level read amp and ~1.1× space; lean on Bloom filters and block cache for negative/point lookups → name the trade-offs: STCS risks running out of disk during merges and slower reads; LCS risks write stalls if the "read-heavy" table actually takes heavy writes → monitor L0/pending-compaction and p99 read per table, and re-tune when a table's real workload diverges from the assumption — sustainable write rate is bounded by compaction throughput, so size compaction I/O to the ingest table's rate.Design deletes + compaction for a Cassandra time-series store that must honor data-retention (TTL) and GDPR-style erasure.
gc_grace_seconds long enough for the delete to reach every replica (default 10d) before GC, balanced against not letting tombstones pile up → avoid tombstone-heavy access (no queue/range-scan-over-deleted patterns) since reads scan tombstones and hit tombstone_failure_threshold → for GDPR erasure, ensure the delete propagates and is actually purged after the older SSTables compact away — TTL alone shadows but doesn't immediately reclaim, so verify space returns after compaction → name the trade-offs: TWCS gives near-free retention drops but is wrong if the table takes out-of-window updates or random deletes; tuning gc_grace_seconds trades resurrection safety against tombstone read cost and erasure latency.Retrieval practice — mix the levels
Reading builds fluency (feels familiar). Recall builds storage (lasts). Answer from memory — instant feedback.
Q1. Compared with size-tiered, leveled compaction primarily trades…
Q2. In leveled compaction, levels below L0 hold any given key in at most one SSTable because…
Q3. A tombstone must be retained until compaction has carried it past all older copies because otherwise…
Q4. An LSM database showing sudden write stalls under heavy ingest is usually…
Q5. Size-tiered compaction's worst-case weakness is…
Q6. The RUM conjecture says a compaction strategy can minimise…
Reconstruct the deep dive from a blank page
Write the six levels in order, one line each, with nothing on screen. Then reveal and compare — gaps are your next drill.
1 Job: reclaim space + consolidate; scored on write/read/space amp (iron law) · 2 STCS: merge same-size upward; low write (~log N), high space (~2×), med–hi read · 3 LCS: non-overlapping levels ~10×; low read (≤1/level) + low space (~1.1×), high write (10–30×) · 4 Triangle: RUM, pick two of three; choose per table; TWCS/universal are hybrids · 5 Tombstones: delete = marker; must outlive every older copy or data resurrects; gc_grace_seconds · 6 L0 + back-pressure: writes > drain ⇒ slowdown/stop triggers; sustainable write rate = compaction throughput.
I’m your teacher — ask me anything. Say “grill me on compaction” for mixed no-clue questions, or hand me a real table (its read/write mix, TTLs, delete pattern) and we’ll place it on the amplification triangle and pick STCS / LCS / TWCS together. Want a whiteboard round on diagnosing a write stall? Just ask.
★ Cheat sheet — compaction in one screen
Mental models: Compaction = GC + consolidation for the LSM tree · 3 amps = write / read / space · Iron law = lower one, raise another (RUM) · STCS = write-optimized corner · LCS = read+space corner · Tombstone = a delete marker that must outlive every older copy · L0 = the pressure gauge · Stall = deliberate back-pressure.
Strategy → workload
| If the table is… | Reach for |
|---|---|
| Write-heavy ingest, disk to spare | Size-tiered (STCS) |
| Read-heavy, space-constrained | Leveled (LCS) |
| Append-only time-series with TTLs | Time-window (TWCS) |
| Mixed / tiered-leaning, one engine | Universal (RocksDB) |
Numbers to remember
STCS write amp ≈ log(N), space ≈ 2× during a big merge · LCS write
amp ≈ T·log_T(N) = 10–30×, space ≈ 1.1×, read ≤ 1
file/level · Cassandra gc_grace_seconds = 10 days,
tombstone_failure_threshold = 100,000.
Three rules for the senior chair
① Choose a strategy per table by access pattern, not one global setting. ② Deletes don’t erase — they write markers that must outlive every older copy. ③ A write stall is compaction falling behind; give it I/O, don’t write faster.
These notes reflect my current understanding and are updated as I learn, build, and discover better explanations.