Internals · LSMlsmmemtablesstablewrite-optimisedtombstone

LSM Trees: Why Some Engines Favour Writes

When a database must absorb far more writes than it serves reads, updating a B+ tree page per row is the wrong shape. The log-structured merge tree appends every write, sorts in memory, flushes immutable sorted files and reconciles them later — trading cheap writes for a read path that has to look in several places.

▶ InteractiveInterview question
Progress

Why this exists

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

  1. Problem

    A metrics pipeline writes 400,000 rows per second — sensor readings, click events, message receipts — and reads a tiny fraction of them, mostly recent. The engine must never fall behind the writers.

  2. Naive solution

    Use the B+ tree from B+ Tree Internals: Pages, Splits, Merges: every insert descends three levels and modifies a leaf page in place, and the buffer pool eventually writes that 8 KB page back.

  3. Why it breaks

    Rows arrive with random keys, so every insert dirties a different leaf. At 400,000 inserts/s the engine dirties hundreds of thousands of pages per second, each a random 8 KB write for a 100-byte row — an 80× write amplification that no disk can sustain.

  4. Better idea

    Stop updating in place. Append every write to a log (sequential I/O is 100× cheaper than random), keep the latest values sorted in memory, and write them out in large sorted batches. Never modify a file after writing it.

  5. Internal mechanism

    WRITE → WAL append → memtable (a sorted in-memory map: skip list or red-black tree) → when the memtable reaches ~64 MB, flush it as an immutable sorted file (SSTable) → in the background, compaction merges SSTables and discards overwritten versions. A read checks the memtable, then SSTables newest to oldest; a delete writes a tombstone marker.

  6. Trade-offs

    Writes are sequential and batched, so ingest is bounded by disk bandwidth rather than random IOPS. Reads may have to consult several files (bloom filters make most of those consultations free), range scans must merge runs, and compaction rewrites data repeatedly in the background, competing with foreground I/O.

  7. Real database

    LevelDB and RocksDB (the embedded engines under many systems), Apache Cassandra and ScyllaDB, HBase, and MyRocks inside MySQL are LSM engines. Redis is not — it keeps everything in memory — but its AOF append-only file and RDB snapshots use the same log-then-snapshot idea.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

Append now, tidy up later

A B+ tree keeps one copy of every row in its place and updates it there, which is ideal for reads and expensive for random writes. An LSM tree does the opposite: it never updates anything in place. New values are appended to a log and a sorted in-memory buffer, then written out as sorted files, and old versions are cleaned up later by a background merge.

That makes writes almost free at the moment they happen — a sequential append — and pushes the cost into compaction. Reads pay a little more because a key may live in the memory buffer or any of several files.

The workload that breaks the B+ tree

Take an event table receiving 400,000 inserts per second with keys that are not monotonic — (device_id, ts) across two million devices, or UUIDs. In a B+ tree each insert lands on a different leaf page. The buffer pool absorbs the change in memory, but at the next checkpoint every dirtied page must be written back: an 8 KB page write for each 100-byte row that touched it. If the working set of leaves is larger than RAM, the descent itself needs a random read first. The engine is limited by random IOPS, and random IOPS are the scarcest thing a disk has.

Sequential writes are a different order of magnitude: an NVMe SSD sustains a few GB/s sequentially but only a few hundred thousand random 4 KB operations per second; a hard disk manages ~150 MB/s sequential and ~100 random operations. The design question is therefore not "how do we make random writes faster?" but "how do we stop doing random writes at all?".

From log to sorted files

Step one: append every write to a log. That alone makes writes sequential and durable, but a read would have to scan the whole log. Step two: keep the latest value per key in a sorted in-memory structure — the memtable — so reads of recent data are a map lookup, and so the data can be written out *sorted*. Step three: when the memtable reaches a threshold (64 MB in RocksDB by default), write it to disk as one SSTable (Sorted String Table): an immutable file of key/value pairs in key order, with an index and a bloom filter. Step four: because every flush produces another file, and the same key may now sit in several, run compaction in the background: merge files, keep the newest version of each key, drop the rest.

The memtable needs ordered iteration and cheap concurrent inserts. RocksDB and LevelDB use a Skip List: O(log n) insert, lock-free readers, in-order traversal for the flush. A Red-Black Tree works equally well for a single writer. A hash map would not — it cannot be flushed in sorted order.

The write path and the read path
WRITE  PUT k=v
   │
   ├─▶ WAL          append "seq=812 PUT k=v"  (sequential, fsync per group)
   └─▶ MemTable     skip list, sorted by key, ~64 MB
             │ full
             ▼ FLUSH (sequential write of one file)
        L0: [sst-041] [sst-040] [sst-039]      ← key ranges overlap, newest first
             │ COMPACTION (background k-way merge)
             ▼
        L1: [a…f] [g…m] [n…s] [t…z]            ← one sorted run, no overlap
        L2: [a…c] [d…e] … (10× bigger)

READ   GET k     memtable → L0 newest…oldest → L1 → L2 …   stop at first hit
                 (each SSTable: bloom filter → sparse index → one block)
DELETE k         PUT k=⌫ (tombstone, seq=813) — nothing is erased until compaction

Reads: newest first, stop at the first hit

A point read checks the memtable, then each level-0 file from newest to oldest, then one file per deeper level (their ranges do not overlap, so at most one can hold the key). The first version found is the answer, because anything older was overwritten. Without help this would be a block read per file; the Bloom Filters: Skipping Files That Cannot Contain the Key lesson shows how a few bits per key let the reader skip almost every file that does not hold the key, and SSTables: The Immutable Sorted File shows how a sparse index turns the remaining candidates into one block read each.

Range scans get no such help. WHERE ts BETWEEN a AND b must open every file whose range intersects [a, b] and merge their sorted streams, skipping tombstones. This is the read cost an LSM engine cannot hide, and the reason Storage Engine Comparison: B+ Tree vs LSM Tree gives the B+ tree the range-scan column.

Deletes are writes: tombstones

An SSTable is immutable, so DELETE k cannot remove k from any file. Instead it writes a tombstone: a version of k whose value means "deleted", with a fresh sequence number. Reads that find the tombstone first report "not found" and stop, so older values below are hidden. Compaction drops the older values when it merges them with the tombstone — but the tombstone itself can only be dropped once no file below it can still contain the key, which in practice means when it reaches the bottom level.

This has a famous consequence: a workload that deletes heavily grows for a while before it shrinks, and a range scan over a heavily deleted region reads tombstones for rows that no longer exist. Cassandra's gc_grace_seconds (default ten days) is exactly the tombstone-retention window, and "tombstone overwhelming" warnings are the failure mode.

Real engines

LevelDB (Google) and RocksDB (Meta's fork) are embedded LSM key-value libraries; RocksDB is the storage layer under MyRocks, CockroachDB (until Pebble), TiKV, Kafka Streams state stores and many others. Apache Cassandra and ScyllaDB are LSM engines by design with size-tiered compaction by default. HBase stores memstores and HFiles on HDFS with the same shape. WiredTiger (MongoDB's engine) implements both a B+ tree and an LSM layout.

Redis is not an LSM tree — data lives in memory in hash tables and skip lists — but its durability options rhyme with this lesson: the AOF is an append-only log replayed at start-up, RDB is a periodic snapshot, and AOF rewrite is a compaction of the log. See Redis: Data Structures, Not a Cache for the in-memory side.

Where the pieces live in real engines
EngineMemtableOn-disk fileDefault compactionTypical use
RocksDB / LevelDBskip listSST (block-based table)leveledembedded KV under other databases
Cassandra / ScyllaDBskip list / B-treeSSTable + index + bloomsize-tieredwide-column, write-heavy, multi-DC
HBaseconcurrent skip list (memstore)HFile on HDFSminor/major (tiered)Hadoop-adjacent big tables
MyRocksskip listSSTleveledMySQL with 2–3× less space than InnoDB
Redis (AOF)in-memory dictappend-only logAOF rewritenot an LSM: in-memory store with a log

Key points

  • The LSM tree exists because random in-place page writes cannot keep up with high write rates; appending and sorting in memory turns every write into sequential I/O.
  • Write path: WAL append → memtable (skip list) → flush to an immutable SSTable → background compaction.
  • Read path: memtable, then SSTables newest to oldest; the first version found wins. Bloom filters and sparse indexes keep that near one block read.
  • Deletes are tombstone writes; the data is physically removed only during compaction, and the tombstone itself only at the bottom level.
  • Range scans must merge every overlapping run — the cost an LSM engine cannot hide.
  • RocksDB, LevelDB, Cassandra, ScyllaDB, HBase are LSM engines; Redis is in-memory but its AOF is the same append-log idea.

LSM tree: WAL → MemTable → SSTable

An LSM tree you can drive
Every write goes to the WAL and the memtable; at 6 entries the memtable is flushed into an immutable level-0 SSTable; compaction merges files into level 1. Reads check newest first.
|
Write-ahead log
(empty — truncated at last flush)
MemTable (sorted)
(empty)
0/6 entries
Level 0 — one file per flush, newest first, key ranges overlap
no SSTables yet — flush the memtable
Level 1 — compacted, one sorted run, tombstones dropped
nothing compacted yet
WAL bytes
0 B
memtable entries
0/6
SSTables
0 (L0 0 · L1 0)
bytes written total
0 B
write amplification
write amplification = bytes written to any file (WAL appends + flushes + compaction outputs, 0 B) ÷ bytes the application asked to store (0 B). 0 flushes, 0 compactions, 0 B read so far.
Try: Put a key, Get it (memtable hit, 0 bytes). Flush, Get again (bloom → index → block). Delete it and Get (tombstone). Insert random ×20 a few times, watch L0 pile up and write amplification stay near 1 — then Compact and watch the write amplification jump.
Log
(no operations yet)
Educational simulation — bytes are key + value + 10 B overhead per entry; blocks hold 4 entries; bloom filters use 10 bits/key, k = 3.

When to use — and when not

Use it when
  • This design fits when writes dominate reads and arrive with non-sequential keys: event ingest, time series, message logs, counters.
  • It fits when disk space or write bandwidth is the constraint and reads are mostly point lookups of recent data.
  • It fits embedded key-value storage where a small, predictable write path matters more than range-scan speed.
Avoid it when
  • This design does not fit read-heavy OLTP with hot in-place updates and many range scans — the B+ tree's home ground.
  • It does not fit workloads that delete heavily and expect space back immediately.
  • It does not fit latency-critical services that cannot tolerate compaction bursts without careful tuning.

Failure modes

  • Compaction cannot keep up with ingest; L0 files pile up, every read consults dozens of files, and the engine finally stalls writes to catch up.
  • A tombstone flood: bulk deletes make range scans slower for days while tombstones wait to reach the bottom level.
  • Treating Redis as an LSM store and expecting on-disk durability from an in-memory engine.
  • Sizing the memtable too small: constant flushes produce thousands of tiny L0 files and pathological read amplification.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.