Day 47 Hard Storage Engine B-tree / LSM WAL / MVCC

Database Internals & Storage Engines — What Actually Happens to a Read/Write on DiskB-tree vs LSM, WAL & Crash Recovery, MVCC, Query Optimizer

Problem Scenario + Constraints

You're choosing the storage engine under a transactional/ledger system: order-table write peak 100K QPS, point lookups (by order id) 50K QPS, a single table growing past 10 TB, and after a crash you must recover with zero data loss (RPO=0), while heavy concurrent reads and writes must not block each other. The real question isn't "MySQL or Postgres" — it's: how do these engines guarantee that one UPDATE is fast, durable, and simultaneously readable as a consistent snapshot by another transaction?

Answering that means opening the database black box and seeing four things clearly: how data is laid out on disk (B-tree vs LSM), how nothing is lost on crash (WAL), how concurrent reads/writes avoid fighting (MVCC), and how one SQL statement becomes an optimal on-disk access path (the optimizer). This is also the dividing line between building storage-intensive systems and merely using them.

High-Level Architecture (Storage Engine Cross-Section)

graph TD
    SQL["SQL query"] --> PARSE["Parser + Planner
cost-based optimizer"] PARSE --> EXEC["Executor
Access Method API"] EXEC --> AM{"Storage engine"} AM -->|read-heavy| BT["B+Tree
InnoDB / Postgres"] AM -->|write-heavy| LSM["LSM-Tree
RocksDB / Cassandra"] BT --> BP["Buffer Pool
page cache · dirty pages"] LSM --> MEM["MemTable
in-memory sorted table"] WRITE["write path"] -.first write.-> WAL[("WAL / redo log
sequential append")] BP -->|checkpoint flushes dirty| DISK[("data files
heap / SSTable")] MEM -->|flush + compaction| DISK WAL -.crash recovery redo/undo.-> BP classDef q fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef eng fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef dur fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 class SQL,PARSE,EXEC q class BT,LSM,BP,MEM eng class WAL,DISK dur

The core invariant: before any data lands, write the WAL first (sequential IO); the data structure itself dictates the read/write/space amplification trade-off

Key Technical Points

1. B-tree vs LSM-tree — you can't have low read AND write amplification

Principle: on disk (HDD and even SSD) sequential writes far outrun random writes. A B+Tree organizes data in place into sorted pages: lookups are O(log n) and a point query reads one leaf page — read-friendly; but updating a row means reading its page in, modifying, and writing it back — a random write, and changing a few bytes still flushes a whole page (write amplification). An LSM-Tree flips this: writes only append to an in-memory MemTable, which when full is flushed sequentially into immutable SSTables, later merged by background compaction — turning random writes into sequential ones, giving huge write throughput; the cost is that a key may be scattered across multiple SSTable levels, so a point query probes several levels (read amplification), mitigated by Bloom Filters.

Trade-off (the RUM triangle: Read / Update / Memory amplification — optimize at most two):
# LSM point read: newest to oldest levels, Bloom Filter skips absent levels
def lsm_get(key):
    if v := memtable.get(key):           # (1) memory, newest
        return None if v is TOMBSTONE else v
    for sst in sstables_newest_to_oldest():   # (2) L0->Ln, level by level
        if not sst.bloom.might_contain(key):  # ~1% false positive rate
            continue                          # most levels skipped
        if v := sst.get(key):                 # hit (delete = tombstone)
            return None if v is TOMBSTONE else v
    return None
# write: append only; delete also "writes a tombstone", real reclaim at compaction
Real cases:

2. WAL & Crash Recovery — log first, only then touch data

Principle: a data page is modified in the in-memory buffer pool into a "dirty page"; if the machine crashes before that dirty page reaches disk, the change is lost. The iron rule of WAL (Write-Ahead Logging): the redo log for any page modification must be sequentially written and fsync'd to disk before the dirty page is allowed to flush. Then, after a crash, replaying the WAL rebuilds the lost in-memory changes. Sequential WAL writes also amortize "many random writes" into "one sequential write + async dirty flush", which actually speeds things up. The industry standard is ARIES: three-phase recovery — Analysis (find dirty pages and active transactions at crash time), Redo (replay to the exact crash-instant state, including uncommitted transactions), Undo (roll back uncommitted transactions) — with LSN, checkpoints, and CLRs making it idempotent and restartable.

Trade-off (durability vs latency, driven by fsync timing):
# WAL ordering at commit (simplified)
def commit(txn):
    lsn = wal.append(txn.redo_records)   # (1) append redo sequentially (incl. undo info)
    wal.fsync_up_to(lsn)                 # (2) force to disk -- only after this is it "committed"
    mark_committed(txn)                  # (3) only now reply OK to the client
    # dirty pages flushed later by checkpoint; a crash is repaired by redo replay
Real cases:

3. MVCC — reads don't block writes, writes don't block reads

Principle: if reads and writes mutually exclude via locks, one long-running read blocks all writes. MVCC (Multi-Version Concurrency Control) makes each update not overwrite the old row but produce a new version, each row tagged with the creating/deleting transaction timestamps (xmin/xmax). A transaction takes a snapshot at start and, when reading, only sees versions "visible to me" — so reads never take locks, see a consistent snapshot, and writes never wait on reads. This is the basis of REPEATABLE READ / Snapshot Isolation. The cost is that old versions pile up and must be reclaimed: Postgres uses VACUUM to clean dead tuples, InnoDB uses a purge thread to clean undo.

Trade-off (where old versions live -> decides write amp and reclamation pain):
# Visibility check (Postgres-style, simplified)
def visible(tuple, snapshot):
    # the tx that created this version committed, and before my snapshot
    if not committed_before(tuple.xmin, snapshot):
        return False
    # this version isn't deleted, or the deleting tx is invisible to me
    if tuple.xmax and committed_before(tuple.xmax, snapshot):
        return False
    return True
# a read just scans the version chain and picks the "visible" one -- no locks
Real cases:

4. Index Internals & Query Optimizer — a SQL's optimal on-disk path

Principle: the same SELECT ... WHERE a=? AND b>? can execute several ways: full scan, use a's index then filter, or use composite index (a,b) to locate directly. A cost-based optimizer (CBO) uses statistics (histograms, distinct counts, row counts) to estimate how many pages each plan reads and how big the result is, and picks the cheapest. The index itself is a B+Tree: a clustered index leaf stores the whole row, a secondary index leaf stores the PK (needs a back-lookup), and a covering index puts all queried columns in the index to skip the back-lookup. Selectivity is key — indexing a low-selectivity column (like gender) can be slower than a scan.

Trade-off:
-- Use EXPLAIN to see what the optimizer actually does
EXPLAIN ANALYZE
SELECT order_id, amount FROM orders
WHERE user_id = 42 AND created_at > '2026-01-01';
-- Expect: Index Scan using idx_user_created (user_id, created_at)
-- If you see Seq Scan + Filter -> stale stats or bad selectivity, run ANALYZE orders;
-- Composite index (user_id, created_at): equality user_id first, range column last
Real cases:

Scaling & Optimization

Common Pitfalls + Interview Follow-ups

1. "LSM is always faster than B-tree"? Wrong. LSM only wins when write-heavy; for point-query-heavy, range-scan-heavy, read-latency-sensitive workloads, B-tree's low read amp is better. Check the read/write ratio first.
2. Disabling fsync for throughput? innodb_flush_log_at_trx_commit=2 or synchronous_commit=off loses OK'd committed transactions on crash. Never for a ledger/payments — that is RPO≠0.
3. Postgres updates slow / table keeps growing? Usually MVCC dead-tuple pileup + every update touching all indexes. A long transaction blocking VACUUM is the #1 culprit.
4. Built an index but it's not used? Leftmost-prefix mismatch, column wrapped by a function/implicit cast, too-low selectivity, or stale stats — verify with EXPLAIN, don't guess.

Frequent interview follow-ups: (1) "Why are sequential writes faster than random — does it still hold on SSD?" (2) "The WAL holds both redo and undo — what does each do? The ARIES three phases?" (3) "Under MVCC, what happens when two transactions update the same row (write-write conflict / first-committer-wins)?" (4) "Clustered vs secondary index, what's a back-lookup, how does a covering index avoid it?" (5) "What anomalies does Snapshot Isolation prevent, and what does it NOT (write skew)?"

Deep Resources

Food for Thought (click to expand)

1. In the SSD era random writes aren't slow anymore — why does LSM's "sequential write" advantage still hold?

On the surface SSD random IOPS is high, but the advantage doesn't vanish, because of write amplification and SSD internals:

  • SSDs have their own FTL and erase blocks: flash is written per "page" and erased per larger "block"; small random writes trigger internal GC and read-modify-write, amplifying physical writes to flash (device-level write amp) and accelerating wear. Large sequential writes are far friendlier to the FTL.
  • B-tree's page-level write amp: changing a few bytes flushes a whole 16KB page, plus a WAL write — one logical write becomes several physical ones. LSM batches many changes into one big sequential block, amortizing per-write cost.
  • But LSM has compaction write amp: data is repeatedly merged and rewritten (leveled can hit 10x+). So "LSM always saves writes" isn't absolute — it saves foreground random writes at the cost of background sequential rewrites.

Conclusion: SSD narrowed the gap but didn't erase it; what really matters is end-to-end write amplification and latency spikes, not raw IOPS.

2. A 3-hour analytics transaction causes the Postgres primary's disk to balloon and writes to slow. What's the chain of causation?

This is the classic MVCC + VACUUM cascade:

  • The long transaction holds an old snapshot, and VACUUM dares not reclaim any dead tuple that "might still be visible to this old snapshot" — even if they're long invisible to every new transaction.
  • So dead tuples from UPDATE/DELETE keep piling up, table and indexes bloat, disk usage balloons.
  • Bigger table -> sequential and index scans read more pages -> buffer-pool hit ratio drops -> IO rises, queries slow.
  • Bloat also drags VACUUM itself (more pages to scan), forming a vicious cycle.

Fixes: split/bound long transactions (idle_in_transaction_session_timeout), run analytics on a read replica, monitor xact_start in pg_stat_activity and the oldest snapshot age, and VACUUM FULL or pg_repack to reclaim space if needed. This is also a core reason to separate OLTP from OLAP.

3. Snapshot Isolation looks strong, but it doesn't prevent write skew. Give a real example and how to fix it.

Classic example — on-call doctors: the constraint is "at least 1 doctor on call at any time." Currently Alice and Bob are both on call. Both simultaneously click "take leave":

  • Each transaction's snapshot sees "2 doctors on call" -> both conclude "dropping to 1 still satisfies the constraint" -> both commit.
  • Result: 0 doctors on call, constraint violated. The two transactions modified different rows (each their own on-call record), so there's no write-write conflict and SI lets them through.

This is write skew: two transactions decide based on the same read snapshot, each writes its own row, and together they violate a cross-row invariant.

Fixes: (1) Serializable Snapshot Isolation (SSI) — Postgres's SERIALIZABLE detects read-write dependency cycles and aborts one transaction. (2) Materialize the conflict with explicit locksSELECT ... FOR UPDATE to lock the relevant rows, or put a lock/counter on the "on-call count" aggregate, turning the implicit conflict into an explicit write-write one.

4. Why do secondary indexes usually store the "PK value" rather than the row's physical address? Costs of each?

Both designs exist in real systems, with opposite trade-offs:

  • Store the PK value (InnoDB-style): secondary index leaf -> PK -> then look up the clustered index for the full row (the back-lookup). The upside: when the row's position in the clustered index changes (page split, MVCC update), the secondary index needn't change as long as the PK is stable. The cost: an extra B+Tree lookup per secondary-index query.
  • Store the physical address (Postgres ctid / heap-only idea): the index points straight to the physical heap location — fast, no back-lookup. But once a row moves (MVCC produces a new tuple at a new location on every update), all secondary indexes must update their pointers — precisely the source of Postgres update write amplification. HOT updates exist to keep the new version in the same page (when index columns don't change) and avoid touching indexes.

The essence is "where to put the indirection": the PK indirection buys index stability on update (write savings), the physical address buys one fewer hop on read (read savings) — once again the read/write amplification opposition.

5. During crash recovery, why replay the modifications of "uncommitted" transactions in the WAL, only to roll them back? Why not just skip them?

You can't — this is exactly the elegance of ARIES's "Redo everything, then Undo the uncommitted", and the core is idempotence and a known state:

  • Why redo the uncommitted: at the crash instant, the on-disk pages are in an arbitrary intermediate state — some dirty pages of an uncommitted transaction may have flushed, some may not. ARIES uses "repeating history": first replay the WAL indiscriminately to the exact crash-instant state (including uncommitted changes), so the memory/disk state is known and determinate, giving Undo a stable starting point.
  • Why then undo: after replay, uncommitted modifications are present too, so they must be rolled back one by one via undo info, restoring "as if these transactions never happened".
  • Why idempotent: recovery itself may crash again. Each page carries an LSN recording "applied up to here"; redo only re-applies records where page.LSN < record.LSN; undo writes CLRs (compensation log records) tracking rollback progress. So recovery can restart repeatedly without error.

"Just skip the uncommitted" sounds easier, but you simply cannot know which uncommitted changes already reached disk — without replaying to a known state, there's no safe baseline to roll back from.