The LSM Write/Read Pipeline, Tombstones & Compaction Strategies
Because SSTables on disk are strictly immutable, an LSM-Tree never modifies an existing file in place:
- Updates & Deletions (Tombstones): Updating key $K$ simply appends a newer version
(K, v_new, seqNo)to the MemTable. Deleting key $K$ appends a special Tombstone marker(K, TOMBSTONE, seqNo). The obsolete older record is only physically reclaimed later when a background compaction merges the SSTable containing the tombstone with the older SSTable at the bottommost level! - Point Lookups (MemTable $\to L_0 \to L_1 \dots \to L_k$): To read key $K$, we search the active MemTable, then immutable MemTables awaiting flush, then every overlapping $L_0$ SSTable (because $L_0$ files are raw MemTable flushes whose key ranges overlap!), and finally at most one SSTable per level $L_1 \dots L_k$ (since levels $\ge 1$ are partitioned into non-overlapping key ranges). To avoid reading SSTable blocks from disk for keys that are not present, every SSTable embeds an in-memory Bloom Filter (or Ribbon Filter) and fence-pointer sparse index!
| Storage Metric | B+ Tree (In-Place Page Updates) | LSM: Size-Tiered Compaction (STCS — Cassandra) | LSM: Leveled Compaction (LCS — RocksDB) |
|---|---|---|---|
| Write Amplification (WA) | High on random small writes (PageSize / RecordSize) | Low: O(log_T N) (≈ 4–8×) | Moderate: O(T · log_T N) (≈ 10–30×) |
| Read Amplification (RA) | Optimal for Range & Point (1 tree path) | Higher (Check multiple tiers) | ≈ 1 Disk Read (with 10 bits/key Bloom filters) |
| Space Amplification (SA) | ~30–45% fragmentation + internal slack | High: up to 50% temporary disk needed for largest merge! | Very Low: ≤ 10–11% stale data overhead! |
The RUM Conjecture (Athanassoulis et al., Harvard DASlab 2016) & Leveled WA Math
Define the three fundamental amplification factors of any index structure:
$$\text{WA} = \frac{\text{Bytes Written to Storage}}{\text{Logical Bytes Inserted}}, \quad \text{RA} = \frac{\text{Bytes Read from Storage}}{\text{Logical Bytes Queried}}, \quad \text{SA} = \frac{\text{Total Storage Footprint}}{\text{Logical Dataset Size}}$$
The RUM Conjecture: No access method can simultaneously optimize Read overhead, Update (Write) overhead, and Memory (Space) overhead—minimizing any two necessarily degrades the third! Specifically, for an LSM-Tree with level size multiplier $T$ (typically $T = 10$, so $L_k = 10^k \cdot L_0$) and $L = \log_T(N / M_{\text{mem}})$ levels:
- In Size-Tiered Compaction (STCS), $T$ runs of equal size are merged together only when all $T$ accumulate: each byte is rewritten once per level, giving $\text{WA} = O(\log_T N)$, but up to $T$ overlapping runs exist per level ($\text{SA} = O(T)$).
- In Leveled Compaction (LCS), each level $L_i$ ($i \ge 1$) is kept strictly sorted with non-overlapping SSTables. Merging a file from $L_{i-1}$ into $L_i$ rewrites up to $T$ overlapping files in $L_i$, yielding $\text{WA} = O(T \log_T N)$, while bounding stale duplicate space to $\sum_{i=1}^{L-1} T^{-i} \approx \frac{1}{T - 1} \approx 11.1\%$ for $T=10$!
Trace how a full B+ Tree leaf page splits and copies its separator key up to the parent directory, or switch to LSM-Tree mode to inspect MemTable flushes, L0→L1 k-way merge compaction, and Bloom filter skips.
Invariant & State
[ 30 ]Hardware Reality: Why Range Queries Bypass Standard Bloom Filters (and How Prefix/SuRF Filters Help)
A standard Bloom Filter hashes an exact key $h(K)$, so it can answer get("user:42") by skipping 99% of SSTables, reducing a point lookup to $\le 1$ disk read. However, for a short range scan (SELECT * FROM events WHERE key BETWEEN "user:42:00" AND "user:42:99"), a standard Bloom Filter is completely useless because you cannot hash every possible string in a continuous interval! Consequently, an LSM range iterator must seek into every $L_0$ SSTable and one SSTable at every level $L_1 \dots L_k$. RocksDB mitigates this with Prefix Bloom Filters (hashing a fixed key prefix like "user:42") or Zhang et al.'s SuRF (Succinct Range Filter, SIGMOD 2018) based on Fast Succinct Tries.
Implementation: LSM Compaction Stream Merger with MVCC Sequence & Tombstone Elision
// C++20: LSM Compaction K-Way Deduplicator & Bottommost-Level Tombstone Elision
#include <vector>
#include <string>
struct LSMRecord {
std::string key;
std::string value;
uint64_t seqNo; // Monotonically increasing MVCC transaction timestamp
bool isTombstone; // True if this record is a DELETE marker
};
// Given sorted records from K merged SSTable runs (ordered by key ASC, then seqNo DESC),
// deduplicates shadowed versions and drops tombstones if compacting into the bottommost level.
std::vector<LSMRecord> compactSortedStream(
const std::vector<LSMRecord>& mergedStream,
uint64_t oldestActiveSnapshotSeq,
bool isBottommostLevel
) {
std::vector<LSMRecord> output;
for (size_t i = 0; i < mergedStream.size(); ++i) {
const auto& rec = mergedStream[i];
// Skip older versions of the same key if shadowed by a version <= oldestActiveSnapshotSeq
if (!output.empty() && output.back().key == rec.key &&
output.back().seqNo <= oldestActiveSnapshotSeq) {
continue;
}
// Physical deletion: only drop a tombstone when it reaches the bottommost level
// AND no older snapshot can see past it!
if (rec.isTombstone && isBottommostLevel && rec.seqNo <= oldestActiveSnapshotSeq) {
continue;
}
output.push_back(rec);
}
return output;
}
Failure Modes & Adversarial Pitfalls
- Dropping Tombstones Above the Bottommost Level Resurrects Deleted Data ("Zombie Keys"): If Compaction merges $L_1$ and $L_2$ and discards a Tombstone for key $K$ while an older version of $K$ still sits in $L_3$, subsequent reads will fall through $L_1$ and $L_2$ and return the deleted value from $L_3$! A tombstone can only be physically dropped when it reaches the bottommost level (or when Bloom/range metadata proves no SSTable at any deeper level contains $K$).
- RocksDB Tombstone Scan Degradation ("Delete-Heavy Queue Anti-Pattern"): Using an LSM-Tree as a FIFO queue (
insertat tail,deleteat head, thenseekToFirst()) accumulates millions of consecutive tombstones at the start of the keyspace before compaction catches up—causingseekToFirst()to scan past millions of dead tombstones in $O(N)$ time!