Compaction: The Merge That Pays for Cheap Writes
Every flush adds a file; every update adds a version; every delete adds a tombstone. Compaction is the background k-way merge that folds files together, keeps the newest version of each key, drops the rest — and, depending on how files are chosen, decides whether the engine is cheap to write, cheap to read, or cheap on disk.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
After a day of ingest the store has 1,300 SSTables. The same key has versions in a dozen of them, deleted rows still occupy space as values plus tombstones, and every point read consults hundreds of bloom filters.
↓ - Naive solution
Leave the files alone. Reads walk all of them newest to oldest; the newest version wins, so the answers are correct.
↓ - Why it breaks
Read cost grows with file count, space grows with every overwrite, and bloom filters for 1,300 files no longer fit in memory. Range scans have to merge 1,300 streams.
↓ - Better idea
The files are sorted, so several can be merged into one sorted file in a single sequential pass, exactly like the merge step of merge sort. During the merge, when the same key comes from several inputs, keep only the newest version.
↓ - Internal mechanism
A k-way merge with one cursor per input: repeatedly take the smallest key across cursors, emit the version with the highest sequence number, discard the others; drop a tombstone only if no older file below could still hold the key. Write the output as a new SSTable, then delete the inputs. A strategy — size-tiered or leveled — decides which files to merge and where the output goes.
↓ - Trade-offs
Compaction rewrites data that was already written: write amplification. It reads and writes sequentially at full bandwidth, which competes with foreground reads and flushes for the same disk. If it falls behind, files accumulate and the engine must slow or stall writes.
↓ - Real database
RocksDB's leveled compaction with a 10× level ratio and its "universal" (tiered) mode; Cassandra's SizeTieredCompactionStrategy, LeveledCompactionStrategy and TimeWindowCompactionStrategy; HBase minor and major compactions.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
Because SSTables are never modified, updates and deletes pile up as newer versions in newer files. Compaction takes a few files, merges them into one, and throws away every version that has been superseded. The store gets smaller, reads have fewer files to check, and deleted data finally disappears.
It runs in the background, continuously, and it is the hidden cost of the LSM tree: the cheap write you made this morning gets rewritten several times over the coming days.
Why files must be merged
An LSM store with no compaction is a stack of flushes. Every overwrite of a key leaves the old version in an older file; every delete leaves the value *and* a tombstone; every flush adds a file whose range overlaps all the others. Three things degrade together: space (every stale version is still on disk), read cost (every point read checks every file, every range scan merges every file), and memory (a bloom filter and index per file). The engine is correct — newest wins — but it gets slower every hour.
The remedy follows from the files being sorted: k sorted runs merge into one sorted run in a single sequential pass, reading each input once and writing the output once. That is the merge of Merge Sort running over files instead of arrays, and the cursors are the same-direction Two Pointers (Same Direction) pattern generalised to k inputs — engines use a Min-Heap of cursors so the next smallest key is found in O(log k).
The merge, step by step
Inputs: SSTables A (newest), B, C. One cursor each. Loop: find the smallest key under any cursor; among the cursors on that key, the entry with the highest sequence number is the live version; emit it (or handle it as a tombstone), count the others as stale, advance every cursor that was on that key. When all cursors are exhausted, the output file is complete: fsync it, publish the new file list (a manifest entry), delete A, B and C once no reader still holds them.
The counters that matter: bytes read (the sum of the input files), bytes written (the output), stale versions dropped, tombstones dropped or carried. Bytes written divided by the bytes the application originally wrote is the compaction's contribution to write amplification — the subject of Write, Read and Space Amplification.
A (newest) k03=c3 s33 k07=⌫ s35 k12=l2 s37 k20=t1 s39 B k03=c2 s21 k09=i2 s24 k12=l1 s26 k15=⌫ s28 k18=r1 s29 C (oldest) k01=a1 s11 k05=⌫ s13 k07=g1 s15 k09=i1 s17 k15=o1 s18 k22=v1 s19 step 1 smallest = k01 only C → emit k01=a1 step 2 smallest = k03 A(s33) B(s21) → emit k03=c3, drop c2 (stale) step 3 smallest = k05 C: tombstone → bottom level? drop it : carry it step 4 smallest = k07 A(⌫ s35) C(s15) → tombstone wins; drop g1; tombstone dropped or carried step 5 smallest = k09 B(s24) C(s17) → emit i2, drop i1 … output k01 k03 [k05] [k07] k09 k12 [k15] k18 k20 k22 one sorted run
When a tombstone may finally go
A tombstone exists to shadow older versions in older files. Dropping it is safe only when there is nothing left for it to shadow: every file that could hold an older version of the key has been merged with it. In leveled compaction that is the bottom level — a merge into the last level drops both the tombstone and whatever it shadowed. In size-tiered compaction a merge of three small files cannot drop a tombstone, because a bigger, older tier below may still contain the value; the tombstone is carried into the output.
Drop it too early and the old value resurrects: the next read walks past where the tombstone used to be and finds the stale value in an older file. This is a real bug class in distributed LSM stores, which is why Cassandra keeps tombstones for gc_grace_seconds (ten days by default) so that every replica has had a chance to see the delete before any replica purges it. The UPDATE, DELETE and Dead Tuples lesson shows the B+ tree world's version of the same problem: dead tuples that VACUUM may reclaim only once no snapshot can still see them.
Size-tiered vs leveled
The merge is the same; the strategy decides *which* files and *where the output goes*, and that decision sets the engine's amplification profile. Size-tiered waits until there are ~4 files of roughly the same size, merges them into one file of the next size class, and leaves other tiers alone. A byte is merged about once per tier, so writes are cheap; but a tier holds several overlapping runs, so reads may check all of them and a key overwritten many times occupies space in each run until that tier is merged.
Leveled organises the store as levels L1, L2, … each a single sorted run of fixed-size (~64 MB) non-overlapping files, each level ~10× the previous. When level N exceeds its budget, one file is picked and merged with the ~10 files of level N+1 that overlap its range; the output replaces those files. A read checks at most one file per level, and space overhead is bounded by the size ratio (~10%). But merging one file into ten rewrites ten bytes of old data per byte of new data, at every level: the highest write amplification of the three designs.
| Size-tiered | Leveled | Time-window | |
|---|---|---|---|
| Inputs chosen | ~4 files of similar size | 1 file of level N + overlapping files of N+1 | files in the same time bucket |
| Runs per level | several, overlapping | one, non-overlapping | one per window |
| Write amplification | low (≈ 1 per tier) | high (≈ 10 per level) | lowest |
| Point-read files | many (bloom-mitigated) | L0 + one per level | few for recent data |
| Range-read runs | many | L0 + one per level | one per window in range |
| Space amplification | high (up to the tier ratio) | low (≈ 1.1×) | low; expiry deletes whole files |
| Tombstones dropped | rarely (older tiers below) | at the bottom level | with the window |
| Default in | Cassandra, RocksDB universal | LevelDB, RocksDB | Cassandra TWCS for time series |
Backlog, stalls and the fight for I/O
Compaction is a debt: ingest creates files faster than reads would like, and background threads pay the debt down. When they fall behind — a burst of writes, a slow disk, too few threads — level 0 grows. Every extra L0 file is one more bloom filter per point read and one more run per range scan, so read latency degrades first. Engines then protect themselves: RocksDB slows writes at 20 L0 files and stops them at 36; Cassandra logs "compaction is behind" and its read latency climbs. A write stall is the engine refusing writes until compaction catches up, and it is the most common production incident in LSM systems.
The underlying conflict is physical: compaction reads and writes at full sequential bandwidth on the same device that serves foreground reads, flushes and WAL fsyncs. A 64 MB-per-second compaction stream on a 500 MB/s disk is 13% of the bandwidth gone, plus the block-cache churn as new files replace the ones readers had cached. The controls are rate limits on compaction I/O, separate threads for flushes so they are never queued behind a large merge, and placing WAL and data on different devices. The Performance Internals: Why the Slow Node Is Slow lesson lists "compaction behind" among the branches of a slow-read investigation.
1compact(inputs, output_level, is_bottom):2 heap = min-heap of (key, -seq, cursor) for each input's first entry3 while heap not empty:4 key = heap.peek().key5 versions = pop every heap entry with this key # newest first because of -seq6 newest = versions[0]; stale += len(versions) - 17 if newest.is_tombstone:8 if is_bottom: tombstones_dropped += 1 # nothing below can hold the key9 else: output.write(newest) # carry it: it still shadows something10 else: output.write(newest)11 for v in versions: advance v.cursor; push next entry into heap12 output.finish(); manifest.replace(inputs, output); delete inputs when unreferencedKey points
- Compaction is a k-way merge of sorted files: smallest key first, highest sequence number wins, stale versions dropped.
- A tombstone can be dropped only when no older file could still hold the key — at the bottom level; dropping early resurrects deleted data.
- Size-tiered: cheap writes, more files per read, more space. Leveled: expensive writes, one file per level, ~10% space overhead.
- Compaction is where write amplification is paid; it runs at sequential bandwidth and competes with foreground I/O.
- A compaction backlog shows up as growing L0 counts and rising read latency, then as write throttling and stalls.
Compaction
When to use — and when not
- Leveled compaction fits read-heavy or space-constrained stores; size-tiered fits ingest-heavy stores; time-window fits time series with expiry.
- Tuning compaction fits any LSM deployment where read latency or disk usage drifts over days.
- Manual "major compaction" as a routine does not fit size-tiered stores — it produces one giant file that will never be merged again.
- Leveled compaction does not fit a workload whose write rate already saturates the disk.
Failure modes
- L0 pile-up and write stall after a write burst the compaction threads cannot absorb.
- Resurrected deletes after a tombstone was purged before every replica saw it.
- Range scans crawling through millions of tombstones after a bulk delete.
- Compaction I/O saturating the disk and turning p99 read latency into seconds.
- A single oversized file from a manual major compaction that dominates every later merge.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.