Storage engines: LSM-tree vs B-tree, simulated
A B+tree and an LSM-tree side by side in your browser, fed the same operations: page splits and merges, the WAL and buffer pool, memtable flushes, leveled, size-tiered and lazy-leveling compaction, Bloom filters, fence pointers and tombstones — with write, read and space amplification counted as you watch, guided scenarios and the textbook cost models.
Added
Works with
- NoSQL capacity and cost Databases and streamingbudget the disk headroom that size-tiered, leveled and time-window compaction need on a real cluster
How to use it
Two storage engines in miniature, running entirely in this page. A B+tree keeps every key in one sorted tree of fixed-size pages and updates pages in place; an LSM-tree appends every write to a log and a sorted in-memory table, writes immutable sorted files (SSTables) and merges them in the background. They take the same stream of inserts, updates, deletes, lookups and scans, and every byte written, every page or block read and every byte on disk is counted — so the trade-offs show up as numbers: write amplification (bytes the engine writes per byte you write), read amplification (disk reads per lookup or scan) and space amplification (bytes on disk per byte of live data). Nothing here talks to a real database.
How to use it
Pick a scenario and press Start. Each step sends some operations and plays for a few simulated seconds, then pauses with an explanation. Next step (or →) moves on, Back rewinds (the simulator is deterministic, so it replays the earlier steps), ⏭ jumps to the end of the step and Step ⇥ advances to the next single thing that happens. Space plays and pauses. Speed scales the clock; Hop time is how long each drawn operation stays on screen at 1× (the simulated clock slows while one is in flight — it never changes what happens); Sim clock turns that off.
⛶ Full screen (or F) makes the diagram fill the window — the browser’s full screen where it is allowed, a full-window layer otherwise (iPhone) — and Esc leaves. ☰ Narration hides the narration, speed row, legend and inspector so the diagram can use the whole height.
-
The diagram. The B+tree on the left (on a phone: on top): its pages by level, root at the top, each page’s fill shaded, its keys written in when they fit (otherwise
used/capacity), the leaf chain dashed. A dot on a page means it is in the buffer pool (orange: dirty, not yet on disk). Splits, borrows and merges flash on the pages involved. Above the tree: the WAL, the buffer pool and the disk counters. The LSM-tree on the right: the WAL, the memtable (its keys, sorted) and the immutable memtable being flushed, then one band per level (L0, L1, …; one band for size-tiered). Each SSTable is drawn over the key range it covers, so overlap is visible; one row per sorted run. Ticks on a file are its fence pointers (one per block), the dark part is the share of tombstones, stripes mean it is being compacted. Operations travel as labelled packets: a write goes to the WAL and memtable; a lookup visits the memtable and then each file it has to read (a ✕ marks a file whose Bloom filter said "not here", skipped without I/O); a B+tree operation walks from the root to a leaf. Flushes and compactions move blocks from their input files to the level they write. Tap a page, a file, a level, the memtable, the WAL or the buffer pool to inspect it and act on it (look up a key, delete a page’s keys, compact a file down, flush, checkpoint). -
Counters, per engine: write amplification, reads per lookup that finds its key, per lookup of a missing key, and per range scan, space amplification, splits and merges, buffer-pool hit ratio, Bloom-filter negatives and false positives, compactions, tombstones and write stalls.
-
Amplification over the run: write, space and read amplification against the number of operations, for both engines.
-
Do something: send 1–1,000 inserts, updates, deletes, lookups (of existing or missing keys) or 20-key range scans, with random, ascending, descending or Zipf-skewed keys; flush the memtable, run a full compaction, toggle Bloom filters, checkpoint the B+tree, or empty both engines.
-
Event log: splits, merges, flushes, compactions, stalls and checkpoints.
-
Engine settings rebuild the engines (listed below).
The Cost models tab sets the textbook estimates side by side for any N, entry size, page size, memtable size, size ratio, Bloom bits per key and fanout, and can add the current simulation’s measured numbers as rows.
The scenarios
-
B+tree: pages split as keys arrive — ascending keys leave half-full pages with 50/50 splits; the rightmost-split optimisation fills them; random keys settle near 69% (ln 2).
-
B+tree: deletes, borrowing and merging — under-full pages borrow from a sibling or merge, and the root collapses.
-
LSM: WAL, memtable and flush to L0 — the write path, overlapping L0 files and newest-first reads.
-
Leveled compaction: the cascade — L0 → L1 → L2 with targets T times apart, and what it costs in writes.
-
Size-tiered compaction and its space spike — Cassandra’s STCS buckets, overwrites piling up, and a major compaction that briefly needs the old and the new data on disk.
-
Tombstones: deletes are writes — deletes add data, scans wade through tombstones, compaction drops them at the bottom.
-
Bloom filters save lookups of missing keys — a block read per run without filters, almost none with them.
-
Range scans favour the B+tree — one descent and a leaf walk against a block from every run.
-
Update-heavy: write amplification compared — a page rewritten per update against append and compact, and what a buffer pool does to the B+tree.
-
Playground — both engines, empty.
What it models
-
B+tree (Comer 1979): leaves hold entries and are chained; inner pages hold separators. An insert into a full leaf splits it 50/50 (or, with the rightmost-split option, leaves an appended-to last leaf full, as PostgreSQL and InnoDB do for ascending keys) and pushes a separator up, splitting inner pages and growing a new root as needed. A delete that leaves a page under half full borrows from a sibling or merges with it; a root with one child is removed. Page size is derived: 64 bytes of header plus the entries. Every page access goes through an optional LRU buffer pool (write-back: dirty pages reach the disk at eviction or checkpoint); every change appends a WAL record, and with full-page writes the first change to a page after a checkpoint puts the whole page in the WAL, as PostgreSQL’s
full_page_writesdoes. -
LSM-tree (O’Neil et al. 1996): a write appends to the WAL and goes into the memtable; a full memtable becomes immutable and is flushed to L0 as an SSTable while a new one takes writes (if the previous flush has not finished, or L0 has reached its stop trigger, writes stall). Each SSTable has a Bloom filter with k = round(bits per key × ln 2) and fence pointers, one per block, both in memory: a point lookup asks each candidate file’s filter and reads one block on a "maybe"; a range scan reads the blocks covering the range in every overlapping file.
-
Leveled compaction (RocksDB): L0 files overlap; at
l0Triggerfiles they are merged with the overlapping L1 files. Level 1’s target isl0Trigger × memtableentries and each level below is T times larger; a level over target merges one file (round-robin by key) with its overlap below. Files in L1+ never overlap. Size-tiered (Cassandra STCS): runs are bucketed by size (0.5–1.5× the bucket average, small ones together) and a bucket withmin_thresholdruns is merged, up tomax_threshold. Lazy leveling (Dostoevsky): levels above the last accumulate up to T runs and merge them into the next level; the last level is a single run that incoming runs merge into. -
Merging keeps the newest version of each key. A tombstone is dropped only when no file outside the merge at that level or below (for STCS: no other SSTable) covers its key.
-
Amplification: write = (WAL + page writes × page size) ÷ user bytes for the B+tree, (WAL
flushes + compactions) ÷ user bytes for the LSM-tree; read = disk page or block reads per operation; space = (pages × page size + WAL since checkpoint) or (SSTables + live WAL + a running compaction’s output) ÷ live bytes.
Settings
| Setting | Meaning |
|---|---|
Engines |
Both, B+tree only, LSM-tree only |
Key space, key bytes, value bytes |
Keys are integers 0 … n−1; entry size for byte accounting |
ms between operations, hop latency, seed |
Workload pacing, how long a drawn hop takes, the random seed |
Entries per leaf, children per inner page |
B+tree page capacities |
Leaf split |
50/50, or rightmost (sequential-insert optimisation) |
Buffer pool pages |
LRU pages cached (0 = every access is I/O) |
Operations per checkpoint, full-page writes |
Checkpoint interval; PostgreSQL-style page images in the WAL |
Compaction |
Leveled, size-tiered (STCS) or lazy leveling |
Memtable entries, size ratio T, levels |
LSM shape |
L0 compaction trigger, L0 stop-writes |
RocksDB’s |
Entries per SSTable, entries per block |
File size in a leveled run; fence-pointer granularity |
Bloom filters, bits per key |
Filters on new files (toggling rebuilds them on all files) |
min_threshold, max_threshold |
STCS bucket merge limits |
Flush / compaction ms per entry |
How long background work takes |
Limits
The sizes are tiny on purpose — pages of a few entries, memtables of a few dozen — so that a few hundred operations show every mechanism; real pages hold hundreds of entries and real memtables millions. Ratios carry over, absolute numbers do not. Entries are fixed-size, keys are integers, there are no snapshots, no compression, no block cache on the LSM side (its filters and fence pointers are always in memory), one compaction at a time, no sub-compactions, no partitioned filters, no trivial moves and no TTL. The B+tree has no free-space map, no fill factor setting and no concurrency. Write stalls stop LSM writes but not the B+tree’s. Treat the numbers as an explanation of where amplification comes from, not as a benchmark: measure your own workload with your own engine.
Sources: P. O’Neil, E. Cheng, D. Gawlick, E. O’Neil, The Log-Structured Merge-Tree (LSM-Tree) (Acta Informatica, 1996); D. Comer, The Ubiquitous B-Tree (ACM Computing Surveys, 1979); the RocksDB wiki: Leveled Compaction, Universal Compaction and the Tuning Guide; Cassandra’s Size Tiered Compaction Strategy; N. Dayan, M. Athanassoulis, S. Idreos, Monkey: Optimal Navigable Key-Value Store (SIGMOD 2017); N. Dayan, S. Idreos, Dostoevsky: Better Space-Time Trade-Offs for LSM-Tree Based Key-Value Stores (SIGMOD 2018); M. Athanassoulis et al., Designing Access Methods: The RUM Conjecture (EDBT 2016); M. Kleppmann, Designing Data-Intensive Applications, chapter 3; the PostgreSQL B-tree implementation notes and WAL configuration.
Related: Postgres playground (real B-tree indexes and the WAL), probabilistic data structures (Bloom filters in depth), cache simulator (buffer pools and block caches are caches), Kafka, simulated (log segments and log compaction), Elasticsearch, simulated (Lucene segments and merges), latency numbers (why a disk read costs what it does), Redis client.
Linking to an example
Share link copies a URL that opens the simulator exactly as configured. Hand-written links work
too: ?example=<scenario> opens a scenario with its own settings, and any setting written beside
it overrides the scenario’s, by the names below.
| Parameter | Meaning | Example |
|---|---|---|
|
A scenario (see the list below) |
|
|
The same, without resetting other settings |
|
|
|
|
|
Key space and entry bytes |
|
|
ms between operations, hop latency, random seed |
|
|
B+tree page capacities; |
|
|
Buffer pool pages, operations per checkpoint, full-page writes |
|
|
|
|
|
LSM shape and L0 triggers |
|
|
Entries per SSTable and per block |
|
|
Bloom filters on or off, bits per key |
|
|
STCS merge thresholds |
|
|
Background work speed |
|
|
Playback speed, hop time in seconds or |
|
example= |
Opens |
|---|---|
|
B+tree splits, sequential vs random (open) |
|
Deletes, borrowing and merging (open) |
|
WAL, memtable and flush to L0 (open) |
|
The leveled compaction cascade (open) |
|
STCS and its space-amplification spike (open) |
|
Deletes as writes, and tombstone GC (open) |
|
Bloom filters and lookups of missing keys (open) |
|
Range scans: B+tree against LSM-tree (open) |
|
Write amplification under skewed updates (open) |
|
Both engines, empty (open) |
For example ?example=leveled&T=10 runs the
leveled cascade with a size ratio of 10, and
?example=bloom&bitsPerKey=4 shows a
weaker filter.
Feedback is a gift. Something to improve, a feature to add, or a bug? Tell me.
Runs entirely in your browser; nothing you type leaves this page.