Guide contents

Lesson 25 of 30

LSM Trees vs B-Trees

Storage engines organise data on disk in one of two main ways.

Family Idea Used by
B-tree Sorted fixed-size pages updated in place PostgreSQL, MySQL InnoDB, SQL Server, Oracle, SQLite
LSM tree (log-structured merge tree) Buffer writes in memory, flush sorted immutable files, merge them in the background RocksDB, LevelDB, Cassandra, HBase, ScyllaDB

For a broader system-design view see storage engines.

Example 1: B-tree structure

A B-tree is a shallow tree of pages (typically 8 KB in PostgreSQL, 16 KB in InnoDB). Internal pages hold keys and child pointers; leaf pages hold keys and row locations (or the rows themselves in a clustered index).

flowchart TD
    R["Root: 40 | 80"] --> A["Internal: 10 | 25"]
    R --> B["Internal: 50 | 65"]
    R --> C["Internal: 90 | 110"]
    A --> L1["Leaf: 1..9"]
    A --> L2["Leaf: 10..24"]
    A --> L3["Leaf: 25..39"]
    B --> L4["Leaf: 40..49"]
    B --> L5["Leaf: 50..64"]

A lookup reads one page per level. With a fan-out of several hundred keys per page, 3 to 4 levels hold hundreds of millions of rows.

Example 2: B-tree in-place update and page splits

An insert or update finds the leaf page and modifies it in place. When the page is full, it splits into two pages and the parent gets a new key.

flowchart LR
    subgraph Before
        P["Leaf (full): 10 12 14 16 18 20"]
    end
    subgraph After["After inserting 15"]
        P1["Leaf: 10 12 14 15"]
        P2["Leaf: 16 18 20"]
    end
    P --> P1
    P --> P2

Consequences:

  • Writes go to random pages, so they cause random I/O.
  • A split touches three pages (two leaves and the parent), which is why a WAL is required for crash safety (see WAL and crash recovery).
  • Sequential keys fill pages almost completely. Random keys leave pages about 70% full after splits.

Example 3: page splits visible in index size (PostgreSQL)

Two tables hold 100000 keys each, with the index created before the load. One is loaded in key order, the other in pseudo-random order (md5 of the number).

SQL
CREATE TABLE idx_seq (k text);
CREATE TABLE idx_rnd (k text);
CREATE INDEX idx_seq_k ON idx_seq (k);
INSERT INTO idx_seq SELECT lpad(g::text, 32, '0') FROM generate_series(1, 100000) g;
CREATE INDEX idx_rnd_k ON idx_rnd (k);
INSERT INTO idx_rnd SELECT md5(g::text) FROM generate_series(1, 100000) g;
SELECT 'sequential' AS load_order,
       pg_size_pretty(pg_relation_size('idx_seq_k')) AS index_size
UNION ALL
SELECT 'random', pg_size_pretty(pg_relation_size('idx_rnd_k'));
Output
 load_order | index_size 
------------+------------
 sequential | 5792 kB
 random     | 7632 kB
(2 rows)

The random-order index is larger because splits leave pages partly empty. Sequential inserts always split the rightmost page, which keeps the other leaves packed to the fill factor (90%).

Example 4: update in place vs new version (PostgreSQL)

PostgreSQL writes a new heap tuple version on every UPDATE (see MVCC and vacuum), so ctid changes. InnoDB updates the clustered-index row in place and keeps old versions in the undo log.

SQL
CREATE TABLE kv (id int PRIMARY KEY, v text);
INSERT INTO kv VALUES (1, 'a');
SELECT ctid, id, v FROM kv;
UPDATE kv SET v = 'b' WHERE id = 1;
SELECT ctid, id, v FROM kv;
Output
 ctid  | id | v 
-------+----+---
 (0,1) |  1 | a
(1 row)

 ctid  | id | v 
-------+----+---
 (0,2) |  1 | b
(1 row)

Example 5: LSM write path

An LSM engine never modifies a file after it is written.

flowchart LR
    W["Write"] --> WAL["WAL append (sequential, durable)"]
    W --> M["Memtable (sorted, in memory)"]
    M -->|"full"| F["Flush"]
    F --> S0["SSTable L0 (immutable, sorted)"]
    S0 -->|"compaction"| S1["SSTables L1..Ln (merged, sorted)"]
Component Role
WAL Sequential log so the memtable can be rebuilt after a crash
Memtable In-memory sorted structure (skip list or tree) taking all writes
SSTable Sorted String Table: immutable sorted file with an index block
Tombstone Marker written by a delete; the row is removed when compaction reaches it
Compaction Background merge of SSTables that drops overwritten values and tombstones

Every write is one sequential log append plus one memory insert, which makes LSM writes fast.

Example 6: LSM read path and bloom filters

A read checks the memtable, then SSTables from newest to oldest until the key is found. A key that does not exist would touch every file. A bloom filter per SSTable answers "definitely not here" or "maybe here" from a small in-memory bit array.

Bloom filter answer Meaning Action
No Key is definitely absent Skip this SSTable (no disk read)
Maybe Key is present, or a false positive Read the SSTable index and data block

With 10 bits per key the false-positive rate is about 1%. Bloom filters never give false negatives.

Example 7: compaction strategies

Size-tiered (STCS) Leveled (LCS)
Idea Merge SSTables of similar size into one bigger table Each level is a sorted run of non-overlapping files, each level about 10x larger than the previous
Write amplification Lower Higher (a byte is rewritten about once per level)
Read amplification Higher (many overlapping tables) Lower (about one table per level)
Space amplification Higher (up to 2x during merges) Lower (about 1.1x)
Typical use Write-heavy, append-mostly Read-heavy, update-heavy

Cassandra offers both (SizeTieredCompactionStrategy, LeveledCompactionStrategy). RocksDB defaults to leveled compaction.

Example 8: amplification worked numbers

Write amplification (WA) is bytes written to disk divided by bytes written by the application.

Scenario Calculation WA
B-tree, 100 byte row change, 8 KB page rewritten 8192 / 100 about 82 (plus WAL bytes)
Leveled LSM, 10x size ratio, 4 levels each byte rewritten about 10 times per level boundary in the worst case, about 10 x 4 up to about 40
Size-tiered LSM, 4 tiers each byte merged once per tier about 4

Read amplification example: a leveled LSM with L0 holding 4 files and 4 more levels needs up to 4 + 4 = 8 SSTable probes for a miss without bloom filters. With a 1% false-positive filter the expected disk reads drop to about 0.08.

Example 9: comparison table

Property B-tree LSM tree
Write path Random in-place page updates plus WAL Sequential appends, later compaction
Write amplification Moderate to high for small rows Low (tiered) to high (leveled)
Point read 1 lookup, 3-4 page reads Memtable plus several SSTables, reduced by bloom filters
Range scan Excellent, leaf pages are linked Good, but must merge across SSTables
Space amplification Page fragmentation, about 30% free space after random inserts Stale versions until compaction, plus temporary space during it
Latency Predictable Compaction can cause spikes and write stalls
Deletes In place, space reused Tombstones, space freed at compaction
Transactions and locking Mature (page and row locks, MVCC) Usually implemented on top (RocksDB transactions)

Example 10: which to choose

Workload Better fit
General OLTP with reads, updates, range scans, joins B-tree (PostgreSQL, MySQL)
Very high write rate, time-series, event logs, metrics LSM (Cassandra, RocksDB)
Read-mostly with predictable latency B-tree
SSD wear matters and data is append-heavy LSM with tiered compaction
Embedded key-value store RocksDB or LevelDB (LSM), SQLite or LMDB (B-tree)

LSM engines also sit inside SQL systems: MyRocks (MySQL), CockroachDB and TiKV (RocksDB or Pebble) use LSM storage under a SQL layer.

Interview points

  • The RUM conjecture: an engine cannot minimise Read, Update, and Memory (space) overhead at the same time.
  • B-tree updates are in place, so they need a WAL. LSM writes are appends, and the log is the durability mechanism.
  • Bloom filters remove most disk reads for missing keys in LSM trees.
  • Leveled compaction favours reads and space, tiered compaction favours writes.
  • Deletes in an LSM tree write tombstones, so heavy deletes can slow reads until compaction runs.

Summary

  • B-trees update fixed-size pages in place, split full pages, and give predictable point and range reads.
  • LSM trees write to a WAL and memtable, flush immutable SSTables, and merge them by compaction.
  • Bloom filters cut disk reads for missing keys; compaction strategy trades write against read and space amplification.
  • PostgreSQL and InnoDB use B-trees; RocksDB, LevelDB, and Cassandra use LSM trees.
  • Choose B-trees for general OLTP and LSM trees for write-heavy ingestion.