Database Storage Engines — Physical Layout, Logical Design, and the Papers Behind Them

Database Storage Engines — Physical Layout, Logical Design, and the Papers Behind Them

Every database sits on top of a storage engine — the layer that decides how rows become bytes on disk, how concurrent readers and writers are isolated, and how the database survives a crash. The engine shapes every performance tradeoff you will ever hit: write throughput, read latency, space amplification, and recovery time.

This post covers four major architectures — B+ Tree (InnoDB, PostgreSQL), LSM Tree (RocksDB, Cassandra), Columnar (ClickHouse, Parquet), and In-Memory (Redis) — with enough internals to make tuning decisions legible.


The Papers That Started It All

Paper Year Contribution
Bayer & McCreight — Organization and Maintenance of Large Ordered Indexes 1972 Invented the B-Tree
Gray & Reuter — Transaction Processing: Concepts and Techniques 1992 ACID, locking, WAL
Mohan et al. — ARIES: A Transaction Recovery Method 1992 Write-ahead logging, redo/undo recovery
Stonebraker & Rowe — The Design of POSTGRES 1987 MVCC via tuple versioning, no in-place updates
O'Neil et al. — The Log-Structured Merge-Tree (LSM-Tree) 1996 Foundation for all LSM engines
Chang et al. — Bigtable: A Distributed Storage System for Structured Data 2006 SSTable format, compaction, column families
Stonebraker et al. — C-Store: A Column-Oriented DBMS 2005 Columnar compression and late materialisation
Melnik et al. — Dremel: Interactive Analysis of Web-Scale Datasets 2010 Nested record encoding in columnar format
Abadi et al. — Column-Stores vs. Row-Stores: How Different Are They Really? 2008 Quantified columnar vs row-store OLAP tradeoffs

1. B+ Tree Storage Engine

The B+ Tree is the default for relational databases. It was designed for disk-based random access and has dominated OLTP for fifty years because it balances reads, writes, and data locality in one structure.

Inspired by: Bayer & McCreight (1972) for the tree itself; ARIES (Mohan et al., 1992) for durability; Stonebraker & Rowe (1987) for PostgreSQL's specific MVCC design.


InnoDB (MySQL)

Pages — The Unit of Everything

InnoDB organises every .ibd table file into fixed 16 KB pages. A page is the unit of I/O — InnoDB always reads and writes one full page at a time, aligning with the OS virtual memory system. Each page has a small header (checksum, page type, LSN for crash recovery, prev/next pointers for the leaf chain) and a footer that duplicates the LSN for torn-write detection.

Inside a B+ Tree, internal pages carry routing keys and child page pointers. Leaf pages carry the actual row data, sorted by primary key, linked in a doubly-linked chain for range scans. A table with 100 million rows and a branching factor of ~400 per page needs only 3–4 levels, so most point lookups touch 3–4 pages.

B+ Tree:
                      [root]
               /        |        \
        [internal]  [internal]  [internal]    ← routing keys + child pointers
         /    \       /    \       /    \
       [L]←→[L]←→[L]←→[L]←→[L]←→[L]        ← leaf pages: full rows, doubly linked

Leaf page layout is sparse-indexed internally: a small page directory at the end of the page holds one slot per 8 records pointing into the record chain. A lookup binary-searches the directory (log(N/8) steps) then walks the chain for the last few records (~O(8)). This avoids storing a separate dense index for every row on the page.

The Buffer Pool

The buffer pool is InnoDB's page cache in RAM — the single most impactful performance lever. Every read and write goes through it.

  • A hash table maps (tablespace_id, page_number) → buffer frame pointer. A cache hit returns the frame in nanoseconds; a miss triggers a 16 KB pread() from the .ibd file.
  • The LRU list has a midpoint: new pages enter at the midpoint (the "old" half), not the head. Only pages accessed again within innodb_old_blocks_time (default 1 s) get promoted to the hot "young" half. This prevents a full-table scan from evicting hot OLTP pages.
  • The flush list tracks dirty pages ordered by LSN so the page cleaner thread can flush them in WAL order.

When the buffer pool is full and a miss occurs, InnoDB evicts a victim from the old sublist tail. If dirty, it flushes first. The larger the buffer pool, the fewer evictions and disk reads.

Clustered Index and Secondary Indexes

InnoDB's primary key is a clustered index: the leaf pages are the rows, physically sorted by PK. There is no separate heap — the table is the tree. This gives excellent locality for PK lookups and range scans.

Secondary indexes store (secondary_key, primary_key) at their leaf nodes. A secondary index lookup therefore does a double lookup: first find the PK in the secondary index, then traverse the clustered index again to fetch the full row. If the queried columns are all in the secondary index (covering index), the second lookup is skipped.

MVCC — Undo Log

Each row carries two hidden columns: DB_TRX_ID (transaction that last wrote it) and DB_ROLL_PTR (pointer into the undo log). When a transaction reads a row written by a newer transaction, it follows DB_ROLL_PTR through the undo log chain to reconstruct the version visible to its snapshot. Old versions live in the undo tablespace, not in the data pages.

Leaf page (current version):
  DB_TRX_ID=1050 | DB_ROLL_PTR ──► undo entry (txn 900) ──► undo entry (txn 700)

Reader with snapshot before txn 1050 → follows chain, reads txn 700 version

Write Path

On an INSERT, InnoDB: writes a redo record to the in-memory log buffer, modifies the target leaf page in the buffer pool (splitting the page if full), writes an undo log entry for MVCC, and on commit fsyncs the redo log buffer to disk. The data page stays dirty in the buffer pool and is flushed asynchronously by the page cleaner. The redo log guarantees the write is recoverable even before the page reaches disk.

On a page split (leaf full), InnoDB allocates a new page, redistributes records, updates parent pointers — potentially cascading up but rarely reaching the root. UUID primary keys cause frequent random splits because inserts land at arbitrary positions; auto-increment PKs append to the rightmost leaf and split predictably.

Read Path

On a SELECT, InnoDB traverses the B+ Tree from the root (almost always in the buffer pool) to the target leaf. Each internal page lookup is a buffer pool hash check — usually a hit. For the leaf page: hash check → hit (no I/O) or miss (16 KB pread). After reading the leaf, InnoDB binary-searches the page directory, walks the record chain to the matching row, checks MVCC visibility via DB_TRX_ID against the transaction's read snapshot (following the undo chain if needed), and extracts the column bytes.

For range scans, InnoDB follows the doubly-linked leaf chain after finding the start position, prefetching pages ahead via linear read-ahead when it detects sequential access.

Tuning Parameters

Parameter Default What it controls
innodb_buffer_pool_size 128 MB Most impactful. Set to 70–80% of RAM. More = more pages cached = fewer disk reads.
innodb_buffer_pool_instances 8 (if >1 GB) Partition pool into N independent LRUs to reduce mutex contention. Match to CPU cores (up to 8).
innodb_redo_log_capacity 100 MB Larger = fewer checkpoints = better write throughput. Set to hold at least 1 hour of writes.
innodb_log_buffer_size 16 MB In-memory buffer for redo records before fsync. Increase for large transactions.
innodb_flush_log_at_trx_commit 1 1 = fsync on every commit (fully ACID). 2 = write to OS buffer, fsync every second. 0 = flush every second (can lose 1 s on crash).
innodb_flush_method fsync O_DIRECT bypasses OS page cache (avoids double-caching). Recommended for SSDs.
innodb_io_capacity 200 Background flush IOPS budget. Set to actual disk IOPS (SSD: 2000–10000).
innodb_old_blocks_time 1000 ms Pages must be re-accessed within this window to move to the young (hot) LRU half. Increase to protect hot pages during large scans.
innodb_change_buffer_max_size 25 % of buffer pool for the change buffer. Defers secondary index writes for cold pages; merges on read.
innodb_fill_factor 100 % of page to fill during index builds. Set to ~80 for random-insert workloads to reduce future splits.

PostgreSQL

Heap — No Clustered Index

PostgreSQL stores all rows in an unsorted heap file — literally a pile of 8 KB pages, one file per table. Every index (including the primary key) is a separate B-Tree file. Index leaf nodes store (key, ctid) where ctid is the physical (page_number, slot_number) address of the row in the heap.

A read therefore does two I/Os: one to find the ctid in the index, one to fetch the row from the heap. However, if all needed columns are in the index (index-only scan), and the visibility map confirms the heap page is fully visible to all transactions, the heap read is skipped entirely.

PostgreSQL has a CLUSTER command to physically reorder a table by an index, but the ordering drifts as rows are inserted and updated. There is no auto-clustering on writes.

MVCC — No Undo Log

PostgreSQL's defining choice: old row versions live directly in the heap. On UPDATE, the old tuple is kept in place with t_xmax set to the updating transaction. A new tuple is written to the heap with t_xmin set to the same transaction. Both versions coexist until VACUUM removes the dead one.

Heap page after UPDATE by txn 1050:
  slot 3: [t_xmin=700, t_xmax=1050, col='old']  ← dead; visible only to snapshots < 1050
  slot 4: [t_xmin=1050, t_xmax=0,   col='new']  ← live

A reader checks t_xmin and t_xmax against its snapshot — no undo log traversal. Fast for reads. The cost is dead tuple accumulation: VACUUM must scan and reclaim them, and all indexes must be updated on every UPDATE because the ctid of the live tuple changes.

HOT (Heap-Only Tuple) is an optimisation: if an updated row fits on the same page and the updated columns are not indexed, the new tuple links back to the old via a redirect chain and indexes are not touched.

Shared Buffer Cache and OS Page Cache

PostgreSQL's shared buffer cache (shared_buffers) is its in-memory page pool, but unlike InnoDB, PostgreSQL also relies heavily on the OS page cache as a second tier. Data read from disk passes through both: kernel page cache → shared buffer frame. This double-buffering is a known inefficiency but means that even data not in shared_buffers may still be served from RAM by the OS.

The cache uses a clock sweep for eviction: a "hand" sweeps through buffer descriptors, decrementing a usage_count (0–5) on each pass and evicting the first frame with count=0. Simpler than InnoDB's partitioned LRU, but no midpoint protection — large sequential scans use a separate ring buffer strategy to avoid polluting the cache.

The visibility map (_vm file, 2 bits per heap page) tracks whether all tuples on a page are visible to every transaction. Index-only scans check the VM first; if a page is all-visible, the heap page is never read. The VACUUM process sets VM bits, so regular autovacuum directly improves query performance.

The free space map (_fsm file) tracks available free bytes per page so inserts can find a suitable page without scanning the heap.

TOAST

Values larger than ~2 KB are automatically moved to a separate TOAST table (compressed and chunked). The main row stores a pointer. TOAST is fetched only when that column is actually accessed — large text/JSON fields cost nothing to I/O if they are not in the SELECT.

Write and Read Path

On INSERT, PostgreSQL finds a heap page with enough free space (via FSM), writes the WAL record to WAL buffers, inserts the tuple into the page (updating the slot array and free-space pointers), and marks the buffer dirty. On commit, WAL buffers are flushed to pg_wal/ (fsync if synchronous_commit=on). Dirty heap pages are written later by bgwriter and checkpointer.

On SELECT with an index: traverse the index B-Tree to get ctid, check the visibility map, then read the heap page at the physical offset encoded in ctid. Check t_xmin/t_xmax against the transaction snapshot — usually answered from cached hint bits in the tuple header without consulting pg_xact. If TOAST columns are needed, fetch them from the TOAST table.

Tuning Parameters

Parameter Default What it controls
shared_buffers 128 MB Primary page cache. Set to ~25% of RAM — leave the rest for the OS page cache.
effective_cache_size 4 GB Planner hint for total available memory (shared_buffers + OS cache). Set to ~75% of RAM. Affects index vs seqscan cost estimates.
work_mem 4 MB Memory per sort/hash per query node. Each parallel worker gets its own. Start at 16–64 MB; watch total = connections × workers × work_mem.
maintenance_work_mem 64 MB Memory for VACUUM, CREATE INDEX. Increase to 1–2 GB for large tables.
synchronous_commit on on = fsync WAL before returning. off = return before fsync (3–5× throughput, can lose last ~200 ms of commits on crash).
checkpoint_timeout 5 min Time between checkpoints. Increase to 15–30 min to reduce checkpoint I/O frequency.
max_wal_size 1 GB WAL accumulation before forcing a checkpoint. Increase to 4–16 GB for write-heavy loads.
random_page_cost 4.0 Planner's cost of a random page read. Set to 1.1 for SSD — makes the planner use index scans instead of seqscans.
effective_io_concurrency 1 Concurrent I/Os the OS can serve. Set to 200 for SSD. Controls prefetch depth for bitmap heap scans.
autovacuum_vacuum_scale_factor 0.2 Fraction of table with dead tuples before autovacuum triggers. Reduce to 0.01–0.05 for large tables.
autovacuum_vacuum_cost_delay 2 ms Throttle between VACUUM I/O bursts. Reduce to 0–1 ms for SSDs.

2. LSM Tree Storage Engine

LSM (Log-Structured Merge-tree) was invented to solve the random-write bottleneck of B-Trees: instead of seeking to an arbitrary page and modifying it, every write is a sequential append. All random writes become sequential — as fast as the storage medium allows.

Inspired by: O'Neil et al. — The Log-Structured Merge-Tree (1996). SSTable format and compaction come from Chang et al. — Bigtable (2006). LevelDB (Jeff Dean & Sanjay Ghemawat, 2011) was the first open-source implementation; RocksDB is Facebook's high-performance fork.

How Sorting Works — MemTable to SSTable

Every write goes into a MemTable first — an in-memory sorted structure (skip-list in RocksDB) that maintains key order on every insert at O(log n).

MemTable skip-list after inserts (apple, mango, cherry, banana):

Level 3: HEAD ─────────────────────── mango ── TAIL
Level 2: HEAD ──────── cherry ─────── mango ── TAIL
Level 1: HEAD ── apple ── cherry ──── mango ── TAIL
Level 0: HEAD ── apple ── banana ── cherry ── mango ── TAIL
                 seq=1     seq=2      seq=3     seq=4

When the MemTable hits its size limit (e.g., 64 MB), it freezes and a background thread flushes it to disk by iterating in sorted order and writing one sequential SSTable file. Every byte written to an SSTable is written once, sequentially — no random I/O.

Write path:
  client.Put(key, value)
    → WAL append (sequential, crash-safe)
    → MemTable insert (O(log n), sorted in memory)
    → [when full] flush → L0 SSTable (immutable, sorted, sequential write)
    → background compaction: L0 → L1 → L2 → ...

SSTable Internal Structure and the Sparse Index

An SSTable is an immutable, sorted file. Internally it is divided into ~4 KB data blocks, each holding a sorted run of key-value pairs (compressed). The index block — the sparse index — stores only the last key of each data block, plus that block's file offset.

SSTable file:
┌──────────────────────────────────────┐
│ Data Block 0: [alice→v1][bob→v2][carol→v3]   ← sorted, compressed (~4 KB)
│ Data Block 1: [dave→v4][eve→v5][frank→v6]
│ Data Block 2: [grace→v7][hank→v8][ivan→v9]
├──────────────────────────────────────┤
│ Index Block (sparse):                │
│   "carol" → offset=0,    size=4096  │  ← last key of block 0
│   "frank" → offset=4096, size=4096  │  ← last key of block 1
│   "ivan"  → offset=8192, size=4096  │  ← last key of block 2
├──────────────────────────────────────┤
│ Bloom Filter Block                   │  one per SSTable — "is key in this file?"
├──────────────────────────────────────┤
│ Footer                               │  offsets to index + bloom filter blocks
└──────────────────────────────────────┘

To find key "eve":

  1. Load the index block (small, usually cached in memory)
  2. Binary search: "carol" < "eve", "frank" ≥ "eve" → key is in data block 1
  3. Read and decompress data block 1 from offset 4096
  4. Scan within the block → find eve

The index is sparse because it only needs one entry per block, not per key. For a 256 MB SSTable with 4 KB blocks, the index has 65 K entries (2 MB total) — easily held in memory.

Bloom Filters

A key may exist in the MemTable, an immutable MemTable being flushed, or any SSTable at any level. Checking all of them naively is expensive. Each SSTable carries a Bloom filter — a compact bit-array that answers "definitely not in this file" with zero false negatives.

Get("eve"):
  1. Check MemTable → miss
  2. For each SSTable (newest first):
       bloom.MayContain("eve")? → NO  → skip this file entirely (no disk read)
                                  YES → binary-search index → read data block → scan
  3. Return first match

At 1% false positive rate, a Bloom filter uses ~10 bits per key. A 100M-key SSTable needs ~120 MB of filter — much cheaper than loading data. The block cache (RocksDB's equivalent of the buffer pool) keeps index blocks and Bloom filters in memory so they never cost a disk read.

How Compaction Merging Works

Compaction merges multiple SSTables into one (or more) new SSTables, sorting the output and discarding stale/deleted versions. It is an N-way merge sort.

Before compaction (L0 — files may overlap):

SSTable A (oldest):  [alice:seq=1] [bob:seq=3]   [carol:seq=5]
SSTable B:           [bob:seq=7]   [dave:seq=9]  [eve:seq=11]
SSTable C (newest):  [alice:seq=12][carol:seq=14][frank:seq=15]

Compaction — N-way merge with a min-heap:

Open one sorted iterator per SSTable.
Push each iterator's first entry into a min-heap keyed by (key ASC, seq DESC).

Heap step-by-step:
  Pop alice:seq=12 (C) → write to output (newest alice)
  Pop alice:seq=1  (A) → SKIP (same key, older)
  Pop bob:seq=7    (B) → write to output (newest bob)
  Pop bob:seq=3    (A) → SKIP
  Pop carol:seq=14 (C) → write to output (newest carol)
  Pop carol:seq=5  (A) → SKIP
  Pop dave:seq=9   (B) → write to output
  Pop eve:seq=11   (B) → write to output
  Pop frank:seq=15 (C) → write to output

After compaction (L1 — no overlaps, one entry per key):

[alice:seq=12] [bob:seq=7] [carol:seq=14] [dave:seq=9] [eve:seq=11] [frank:seq=15]

Tombstones (delete markers) are kept through all levels until the bottom level, where no older copy can exist and the tombstone is finally dropped.

Leveled Compaction

L0: [A: alice–carol] [B: bob–eve] [C: alice–frank]   ← overlapping, 4 files → trigger
      ↓ compact all L0 files + overlapping L1 files
L1: [alice–frank]                                     ← sorted, non-overlapping, ≤10 MB
      ↓ L1 full → compact one L1 SSTable into L2
L2: [alice–dave] [eve–zara]                           ← sorted, non-overlapping, ≤100 MB
      ↓
L3: [alice–bob] [carol–eve] [frank–mango] [nate–zara] ← ≤1 GB

At L1 and below, key ranges within a level never overlap — a point read needs at most one SSTable per level (binary search on file key ranges) plus its Bloom filter.


3. Columnar Storage Engine

Columnar engines store all values of each column together rather than all columns of each row together. A query reading 2 of 50 columns touches 4% of the data a row-store would read.

Inspired by: Stonebraker et al. — C-Store (2005), Abadi et al. — Column-Stores vs. Row-Stores (2008), Melnik et al. — Dremel (2010). ClickHouse (Yandex, open-sourced 2016) brought real-time columnar OLAP to large-scale production.

The Example Table

row │ date       │ region │ product │ price │ qty
────┼────────────┼────────┼─────────┼───────┼────
 1  │ 2024-01-01 │ US     │ Widget  │  9.99 │ 10
 2  │ 2024-01-01 │ US     │ Widget  │  9.99 │  5
 3  │ 2024-01-01 │ EU     │ Gadget  │ 19.99 │  2
 4  │ 2024-01-02 │ US     │ Widget  │  9.99 │  8
 5  │ 2024-01-02 │ EU     │ Widget  │  9.99 │  3
 6  │ 2024-01-02 │ EU     │ Gadget  │ 19.99 │  1
 7  │ 2024-01-03 │ US     │ Gadget  │ 19.99 │  4
 8  │ 2024-01-03 │ US     │ Widget  │  9.99 │  6

Columnar storage splits this into one contiguous sequence per column:

date.bin:    [2024-01-01][2024-01-01][2024-01-01][2024-01-02]...
region.bin:  [US][US][EU][US][EU][EU][US][US]
product.bin: [Widget][Widget][Gadget][Widget][Widget][Gadget][Gadget][Widget]
price.bin:   [9.99][9.99][19.99][9.99][9.99][19.99][19.99][9.99]
qty.bin:     [10][5][2][8][3][1][4][6]

The N-th value in every file corresponds to the same row — position is the identity. There are no row IDs. For SELECT AVG(qty) WHERE region = 'US', only region.bin and qty.bin are read. date.bin, product.bin, and price.bin are never touched.

Dictionary Encoding

For low-cardinality columns (region, product, status), replace each string with a small integer code and store only the codes.

product column:
  Raw:        [Widget, Widget, Gadget, Widget, Widget, Gadget, Gadget, Widget]
  Dictionary: {Widget=0, Gadget=1}
  Codes:      [0, 0, 1, 0, 0, 1, 1, 0]
  Bit-packed: 00100011  ← 8 values packed into 1 byte (1 bit each, since 2 distinct values)

  Raw storage: 8 × ~6 bytes = 48 bytes
  Encoded:     dictionary (14 bytes) + codes (1 byte) = 15 bytes  →  3× compression

ClickHouse's LowCardinality(String) type enables this. Equality filters on codes compare integers instead of strings — much faster for both filtering and grouping.

Run-Length Encoding (RLE)

RLE replaces consecutive repeated values with (value, count) pairs. It is only effective when data is sorted — the same value appears in long runs. This is why the sort key matters so much.

Without sorting:

region: [US, US, EU, US, EU, EU, US, US]
RLE:    [(US,2), (EU,1), (US,1), (EU,2), (US,2)]  → 5 pairs, worse than 8 raw values

After sorting by (date, region):

region: [EU, US, US, EU, EU, US, US, US]
RLE:    [(EU,1), (US,2), (EU,2), (US,3)]  → 4 pairs

product (sorted): [Gadget, Widget, Widget, Widget, Gadget, Widget, Widget, Gadget]
Dict codes:       [1, 0, 0, 0, 1, 0, 0, 1]
RLE on codes:     [(1,1), (0,3), (1,1), (0,2), (1,1)]  → 5 pairs vs 8 codes

RLE + dictionary combined is the standard: store small integer codes with run-length compression on top.

Bitmap Encoding

For low-cardinality equality filters, store one bit per row for each distinct value:

region column (original order):
  Bitmap for US: 1 1 0 1 0 0 1 1  →  0b11010011  →  1 byte
  Bitmap for EU: 0 0 1 0 1 1 0 0  →  0b00101100  →  1 byte

WHERE region='US' AND product='Widget':
  Bitmap(region='US'):   11010011
  Bitmap(product='Widget'): 11011001
  AND:                   11010001  →  rows 1, 2, 4, 8

This AND operation is a single 64-bit instruction per 64 rows. SIMD instructions process 256 or 512 bits at once, enabling billions of rows per second for simple filter queries. Roaring Bitmaps (used in ClickHouse, Druid, Pinot) extend this with adaptive compression for sparse bitmaps.

Delta Encoding

For sorted numeric or timestamp columns, store only the differences between consecutive values:

date column (as Unix seconds, sorted):
  Raw:    [1704067200, 1704067200, 1704067200, 1704153600, 1704153600, ...]
  Deltas: [0, 0, 0, 86400, 0, 0, 86400, 0]  ← values fit in 17 bits, not 32
  RLE:    [(0,3), (86400,1), (0,2), (86400,1), (0,1)]  ← excellent after sorting

Delta encoding shrinks the bit width of values, enabling aggressive bit-packing before general compression (LZ4/Zstd).

How the Sort Key Works

The sort key physically orders rows within each data part. This is the single most impactful schema decision:

CREATE TABLE sales (
    date    Date,
    region  LowCardinality(String),
    product LowCardinality(String),
    price   Float64,
    qty     UInt32
) ENGINE = MergeTree()
ORDER BY (date, region, product);

After sorting by (date, region, product), the sparse primary index stores the first key value of every 8192-row granule. A query filtering on date = '2024-01-02' AND region = 'EU' binary-searches the index to find the matching granule range and reads only those granules — skipping the rest without touching disk.

primary.idx (one entry per 8192 rows):
  Granule 0: (2024-01-01, EU, Gadget)   covers rows 0–8191
  Granule 1: (2024-01-05, US, Widget)   covers rows 8192–16383

Query: date='2024-01-02' AND region='EU'
  Binary search → Granule 0 only (2024-01-02 < 2024-01-05)
  → read ~8192 rows instead of 800M

A marks file (.mrk) maps each granule entry to its byte offset in each column file, so the engine seeks directly to the right compressed block.

Write Path — From INSERT to Disk

Columnar engines never modify existing data files. Every write creates a new data part (an immutable directory of column files), merged in the background.

INSERT INTO sales VALUES (...)
  ↓
1. Accumulate rows in an in-memory columnar block
   (anti-pattern: tiny inserts create many small parts → use batch inserts or async_insert)
  ↓
2. Sort the block by the ORDER BY key (in memory)
  ↓
3. For each column:
   encode (dictionary + RLE + delta) → split into compressed blocks → write to column.bin
   write one mark per block to column.mrk (byte offsets for seek)
  ↓
4. Write primary.idx (first key of each granule)
  ↓
5. Atomic rename: tmp_part/ → part/  (makes it visible instantly, no partial state)
  ↓
Background: merge small parts into larger parts (N-way merge sort, same as LSM compaction)
            → longer runs → better RLE → better compression

On disk each data part is a directory:

20240101_1_1_0/
  date.bin   date.mrk
  region.bin region.mrk
  product.bin product.mrk
  price.bin  price.mrk
  qty.bin    qty.mrk
  primary.idx
  checksums.txt

Materialized Views on Top of Columnar Storage

A columnar table has one sort order. Queries with a different leading filter column cannot use the sparse primary index — they must scan all granules. Materialized views solve this by keeping a pre-computed, physically separate projection with a different sort key or pre-aggregated data.

-- Source table sorted by (date, region, product)

-- MV 1: pre-aggregated daily totals — avoids reading raw rows for dashboards
CREATE MATERIALIZED VIEW sales_daily_mv TO sales_daily AS
SELECT date, region, SUM(qty) AS total_qty, SUM(price * qty) AS revenue
FROM sales GROUP BY date, region;

CREATE TABLE sales_daily (...) ENGINE = SummingMergeTree() ORDER BY (date, region);

-- MV 2: same data but with region as the leading sort key
CREATE MATERIALIZED VIEW sales_by_region_mv TO sales_by_region AS SELECT * FROM sales;

CREATE TABLE sales_by_region (...) ENGINE = MergeTree() ORDER BY (region, date, product);

How writes propagate:

INSERT INTO sales
  ├──► write new data part to sales/            (primary table)
  └──► for each MV: apply MV's SELECT, write result to MV's target table
       (synchronous — all succeed or all fail together)

Query routing:

WHERE date='2024-01-02' AND region='US'     → use sales (date is leading key)
WHERE region='EU'                           → use sales_by_region (region is leading key)
SUM(qty) GROUP BY date, region              → use sales_daily (pre-aggregated, tiny)

AggregatingMergeTree extends this further: each MV row stores a partial aggregate state (e.g., a HyperLogLog sketch for uniq()), and background merges combine partial states — enabling approximate distinct counts at near-zero query cost.


4. In-Memory Storage Engine

In-memory engines eliminate disk I/O from the read/write path. All data lives in RAM; durability is handled by an append-only log and/or periodic snapshots in the background.

Inspired by: DeWitt et al. — Main Memory Database Systems (1992). Redis (2009) and Memcached (2003) popularised the approach for caching workloads.

The primary data structure is a hash table keyed by the user key, pointing to typed value objects: strings, lists, hashes, sets, sorted sets (skip-lists), etc. A read or write resolves to a memory pointer — no page fetch, no lock manager, sub-microsecond latency.

Durability options (Redis):

  • RDB snapshot: fork the process, serialize the entire keyspace to a binary file. Point-in-time, compact. Recovery loads the file — fast but can lose up to the snapshot interval.
  • AOF log: append every write command as text. Replay on startup to reconstruct state. Durable to the last command (fsync-per-write mode) or last second (fsync-every-second). Larger file, slower recovery.
  • RDB+AOF hybrid (Redis 7.0+): AOF starts with an embedded RDB snapshot then appends only incremental commands. Fast recovery + good durability.
In-Memory
Read / write latency Sub-microsecond
Throughput Millions of ops/sec per node
Durability Asynchronous (last snapshot lag) or synchronous (fsync-per-write)
Capacity Limited to available RAM
Recovery Load RDB or replay AOF on startup

Popular implementations: Redis, Memcached, DragonflyDB, VoltDB (in-memory SQL with ACID).


Comparison

Engine Physical unit Logical model Optimised for
B+ Tree (InnoDB) 16 KB pages, clustered B-Tree Rows, SQL OLTP — point reads, mixed writes
B+ Tree (PostgreSQL) 8 KB pages, heap + separate indexes Rows, SQL OLTP — rich query planning, MVCC
LSM Tree (RocksDB) SSTable + WAL Key-value, wide-column Write-heavy, sequential I/O
Columnar (ClickHouse) Data parts, column files Rows (read as columns) OLAP — aggregations over few columns
In-Memory (Redis) Hash table + skip-list in RAM Key-value, data structures Ultra-low latency, cache

Write Amplification — B-Tree vs LSM Tree

Write amplification (WA) is bytes physically written per byte of user data. It determines SSD wear and disk I/O pressure.

B-Tree (InnoDB)

A single committed row touches disk in several places:

  1. WAL (redo log): sequential fsync on commit — the only synchronous write
  2. Doublewrite buffer: InnoDB writes the full 16 KB page to a safe area in ibdata1 before writing to the actual page location, protecting against torn writes — doubles the page write I/O
  3. Data page: async 16 KB write by the page cleaner

With a warm buffer pool, many rows share one page flush. Effective WA ≈ 3–5× for typical OLTP. UUID primary keys hurt more: random inserts cause frequent page splits, each splitting into 2–3 page writes.

LSM Tree (RocksDB)

Writes are cheap upfront (WAL append + MemTable insert). The WA comes from compaction — data is rewritten once per level.

WA ≈ 1 (flush to L0) + size_ratio (L0→L1) + size_ratio (L1→L2) + ...
With size_ratio=10, 4 levels: WA ≈ 1 + 10 + 10 + 10 ≈ 30×

Compaction runs in the background and doesn't block foreground writes, but it competes for disk bandwidth.

HDD vs SSD Impact

HDD:
  Random write: 5–15 ms seek + 0.1 ms transfer per 16 KB
  Sequential write: 150–200 MB/s
  B-Tree random insert (cold page): ~30 ms (3 seeks: read leaf, write doublewrite, write page)
  LSM write: sequential → HDD-friendly, dramatically outperforms B-Tree for write-heavy loads

SSD (NVMe):
  Random write: 20–100 µs (100–200× faster than HDD)
  Sequential write: 1–7 GB/s
  B-Tree random insert: now only 50–200 µs → competitive
  LSM write: still sequential advantage, but write volume (WA 10–30×) causes SSD wear
  A 1 TB NVMe (600 TBW endurance) at WA=20: sustains ~30 TB of user writes before wearing out
Workload fit summary:
  Write-heavy + HDD  → LSM wins clearly (all I/O sequential)
  Read-heavy + HDD   → B-Tree wins (fewer seeks per read)
  Mixed + SSD        → B-Tree competitive; LSM for write-dominated
  Time-series + SSD  → LSM (append-only pattern = low actual WA, natural compaction)

Summary Table

Engine Write amplification Read amplification Space amplification
B-Tree InnoDB 3–5× 3–4 page I/Os Low (in-place, reuses pages)
B-Tree PostgreSQL 5–10× (all indexes updated on UPDATE) 1–2 I/Os + heap Medium (dead tuples until VACUUM)
LSM leveled 10–30× (compaction per level) ~1 with bloom filters Medium (stale until compaction)
LSM tiered 3–10× (fewer merge passes) Higher (more files per level) Higher
Columnar 2–5× (batch writes amortise) columns/total (e.g., 2/50 = 4%) Low (5–15× compression)
In-Memory ~1× (AOF only) 0 disk I/Os 1× (no compression by default)

The right engine is the one whose amplification profile matches your workload — not the one with the longest feature list.