Internals · LSMcompactionk-way mergesize-tieredleveledtombstone

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.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. 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.

  2. Naive solution

    Leave the files alone. Reads walk all of them newest to oldest; the newest version wins, so the answers are correct.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

Merging files, keeping the newest

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.

Three inputs, cursors ▲, output growing
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.

Compaction strategies
Size-tieredLeveledTime-window
Inputs chosen~4 files of similar size1 file of level N + overlapping files of N+1files in the same time bucket
Runs per levelseveral, overlappingone, non-overlappingone per window
Write amplificationlow (≈ 1 per tier)high (≈ 10 per level)lowest
Point-read filesmany (bloom-mitigated)L0 + one per levelfew for recent data
Range-read runsmanyL0 + one per levelone per window in range
Space amplificationhigh (up to the tier ratio)low (≈ 1.1×)low; expiry deletes whole files
Tombstones droppedrarely (older tiers below)at the bottom levelwith the window
Default inCassandra, RocksDB universalLevelDB, RocksDBCassandra 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.

The k-way merge with a min-heap of cursors
1compact(inputs, output_level, is_bottom):
2 heap = min-heap of (key, -seq, cursor) for each input's first entry
3 while heap not empty:
4 key = heap.peek().key
5 versions = pop every heap entry with this key # newest first because of -seq
6 newest = versions[0]; stale += len(versions) - 1
7 if newest.is_tombstone:
8 if is_bottom: tombstones_dropped += 1 # nothing below can hold the key
9 else: output.write(newest) # carry it: it still shadows something
10 else: output.write(newest)
11 for v in versions: advance v.cursor; push next entry into heap
12 output.finish(); manifest.replace(inputs, output); delete inputs when unreferenced

Key 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

Compaction is a k-way merge
Sorted inputs, one cursor each; at every step take the smallest key across cursors, keep the newest version, drop the rest. The output is a new sorted file. Toggle the strategy to change which files are chosen.
A L0
58 B · input
k03c3s33k07s35k12l2s37k20t1s39
B L0
73 B · input
k03c2s21k09i2s24k12l1s26k15s28k18r1s29
C L0
88 B · input
k01a1s11k05s13k07g1s15k09i1s17k15o1s18k22v1s19
D1 L1
75 B · untouched
k01a0s2k02b0s3k05e0s4k07g0s5k10j0s6
D2 L1
60 B · untouched
k13m0s7k15o0s8k18r0s9k25y0s9
output
new tier-1 file (A+B+C)
(empty — press Next)
bytes read
0 / 219
bytes written
0
stale dropped
0
tombstones kept
0
output entries
0
Start state. Size-tiered: files of similar size in the same tier are merged together into one bigger file for the next tier. A, B and C are all ~1 flush in size, so they are the inputs; D1 and D2 (an older, bigger tier) are not touched. Because older data still exists below, tombstones must be KEPT.
Size-tiered writes each byte once per tier (low write amplification) but leaves many overlapping runs per tier, so a point read consults more files and space is held by duplicates until the big merge. Cassandra's default; RocksDB "universal". The merge loop itself is the same as the one in merge sort, and the cursors are the same-direction two-pointer pattern.
Educational simulation — page sizes, costs and counters are modelled, not measured from a real engine.
1/11 · start

When to use — and when not

Use it when
  • 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.
Avoid it when
  • 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.