Skip to main content
Storage Engine Deep Dive Concept

Storage Engine Deep Dive

This module provides the internals knowledge required to work on storage engines at companies building database infrastructure. We cover PostgreSQL’s MVCC, vacuum, WAL, and recovery systems in production-level depth.
Target Audience: Storage engine and infrastructure engineers
Prerequisites: Data Structures, Query Processing modules
Source Directories: src/backend/storage/, src/backend/access/
Interview Relevance: Staff+ database infrastructure roles

Part 1: MVCC Implementation

MVCC Explained

1.1 Transaction ID System

Real-world analogy: Transaction IDs are like the ticket numbers at a bakery. Every customer gets the next number in sequence. The critical difference is that PostgreSQL’s counter is only 32 bits — meaning it can only count to about 4 billion before it wraps around to zero. When it wraps, suddenly “new” tickets look like they came before “old” tickets, breaking visibility rules. This is the transaction ID wraparound problem, and it is why VACUUM’s “freezing” operation is not optional — it is an existential requirement that prevents the database from becoming unusable.

1.2 Tuple Visibility Rules

1.3 Hint Bits

Real-world analogy: Hint bits are like sticky notes on a file folder. The first time someone needs to know whether a transaction committed, they have to walk to the filing cabinet (CLOG) and look it up. Then they stick a note on the folder saying “committed” or “aborted.” Every subsequent reader just looks at the sticky note instead of walking to the cabinet. The tradeoff: sticking the note makes the folder “dirty” (it has been modified), so it needs to be written back to disk eventually. Common pitfall: Hint-bit setting is the reason you sometimes see unexpected write I/O on a read-only replica. The first read of a tuple after a checkpoint may set hint bits, dirtying the page. This is normal behavior, not corruption, but it can be surprising when monitoring shows write activity on a server that should be read-only.

1.4 CLOG (Commit Log)

The CLOG (now called pg_xact/ on disk, though the internal terminology persists) is a remarkably compact data structure. It stores the status of every transaction that has ever run using just 2 bits per transaction. This means 4 billion transaction statuses fit in about 1GB of disk space — and only a small portion is cached in shared memory at any given time. Practical tip: If you see high SLRUReadPage wait events in pg_stat_activity, backends are contending on CLOG buffer pages. This typically happens when many transactions are performing visibility checks on tuples whose hint bits have not been set yet (common after a crash recovery or after restoring from a backup). Running VACUUM on affected tables will set hint bits, eliminating the CLOG lookups.

Part 2: Physical Storage Layout

Understanding how bits are laid out on disk is essential for high-performance database engineering.

2.1 The 8KB Page Structure

PostgreSQL data is stored in fixed-size Pages (default 8KB).
  • Item Identifiers (Linp): Array of 4-byte pointers to the actual tuples.
  • Upper and Lower Pointers: Free space grows from the header down and the tuples grow from the bottom up. When they meet, the page is full.

2.2 Heap File Segments

Tables are stored in Relation Forks in the base/<db_oid>/<rel_oid> directory.
  • MAIN Fork: The actual data. Files are limited to 1GB segments (e.g., 16384, 16384.1, 16384.2).
  • FSM (Free Space Map): _fsm suffix. Tracks free space per page using a tree structure for O(logN)O(\log N) lookup of “a page with X bytes free”.
  • VM (Visibility Map): _vm suffix. Tracks page visibility for Vacuum skipping and Index-Only Scans.

2.3 The Buffer Manager & Dirty Pages

When a page is modified:
  1. It is loaded into Shared Buffers.
  2. Modified in memory (“Dirtied”).
  3. WAL record is written to ensure durability.
  4. Checkpointer eventually flushes the dirty page to disk.

Part 3: Vacuum Deep Dive

2.1 Why Vacuum Exists

2.2 Vacuum Algorithm

2.3 Visibility Map

2.4 XID Wraparound Prevention

2.5 Autovacuum Tuning


Part 3: WAL (Write-Ahead Log) Deep Dive

3.1 WAL Architecture

3.2 WAL Record Format

3.3 Full Page Writes

3.4 Checkpoints

3.5 Checkpoint Tuning


Part 4: Recovery Process

4.1 Crash Recovery

4.2 LSN Tracking

4.3 PITR (Point-In-Time Recovery)


Part 5: Logical Decoding

5.1 Logical Replication Architecture

5.2 Output Plugins

5.3 Change Data Capture (CDC)


Part 6: Interview Questions

Storage Engine Deep Dive

Answer:PostgreSQL uses Full Page Writes (FPW) to prevent torn page corruption:
  1. The Problem
    • PostgreSQL pages are 8KB
    • Disk sectors are 512B or 4KB
    • If crash during write: some sectors written, some not
    • Page becomes corrupted (torn)
  2. The Solution
    • First modification to a page after checkpoint: write entire 8KB page to WAL
    • This is called a Full Page Image (FPI)
    • Subsequent changes to same page: only write delta
  3. Recovery Process
    • Find last checkpoint
    • If page is torn: restore from FPI in WAL
    • Then apply subsequent WAL records
  4. Trade-offs
    • FPW increases WAL volume significantly
    • Especially after checkpoint (many FPIs)
    • Can be disabled with full_page_writes=off if hardware/filesystem guarantees atomic 8KB writes
    • Some use checksums + ZFS for detection without FPW
Answer:Why VACUUM exists:
  1. MVCC creates multiple tuple versions
  2. Old versions must be preserved until no transaction needs them
  3. Dead tuples accumulate, causing bloat
  4. XID wraparound must be prevented (32-bit counter)
Problems with current design:
  • Table bloat grows between vacuums
  • Can cause significant I/O
  • Must track all dead tuples in memory (TidStore)
  • Index vacuuming can be expensive
Alternative designs (how others solve it):
  1. Undo logs (Oracle, MySQL InnoDB)
    • Store old versions in separate undo space
    • Main table always has latest version
    • No need to vacuum table (just undo log)
    • Trade-off: Longer transactions = longer undo retention
  2. Append-only with compaction (RocksDB, Cassandra)
    • Never update in place
    • Background compaction merges and removes old versions
    • Trade-off: Write amplification
  3. Garbage collection (CockroachDB)
    • Similar to VACUUM but distributed
    • Each range tracks its own garbage
    • GC based on TTL, not transaction visibility
What I might change:
  • Inline microvacuum on UPDATE (like HOT but for dead tuples)
  • Incremental index vacuum (don’t scan whole index)
  • Better parallelization of vacuum
Answer:Group Commit optimization:
  1. Basic durability requirement:
    • Transaction durability: once COMMIT returns, data survives crash
    • Requires: WAL on persistent storage before ACK
  2. Naive approach (slow):
    • Each commit: write WAL, fsync, return
    • Fsync is expensive (~1-10ms)
    • Limits to ~100-1000 commits/sec
  3. Group commit (what PostgreSQL does):
    • Multiple backends write to WAL buffer
    • One backend does fsync
    • Fsync covers ALL pending commits
    • All waiting backends released together
  4. Configuration:
    • Wait briefly for more transactions to batch
  5. Async commit option:
    • Very fast (no fsync wait)
    • Risk: Lose last few milliseconds of commits on crash
    • Acceptable for many workloads (analytics, logging)

Next Steps

Distributed Systems

Replication, consensus, and distributed transactions

Contributing to PostgreSQL

Submit your first patch to PostgreSQL

Interview Deep-Dive

Strong Answer:
  • The WAL protocol guarantees that no data page modification is written to disk before the corresponding WAL record is flushed. On COMMIT, PostgreSQL flushes the WAL (fsync) but does NOT immediately flush the modified data pages. Dirty pages remain in shared_buffers and are written lazily by the background writer or checkpointer.
  • Durability: if the server crashes after COMMIT, dirty data pages may be lost, but the WAL on disk contains enough information to reconstruct those pages during recovery by replaying from the last checkpoint.
  • Performance: WAL writes are sequential appends to a single file. Data page writes are random I/O scattered across hundreds of files. Sequential I/O is 10-100x faster on spinning disks and 2-5x faster on SSDs. A transaction modifying 50 pages only needs one sequential WAL flush instead of 50 random page writes.
  • The checkpoint mechanism periodically flushes all dirty pages to disk, limiting recovery time. checkpoint_completion_target = 0.9 spreads checkpoint I/O over 90% of the interval to smooth the impact.
Follow-up: What is full_page_writes and why is it important?After a checkpoint, the first modification to any page triggers a full 8KB page image write to WAL (not just the delta). This protects against torn pages — if a crash occurs during a partial page write to disk, recovery would otherwise apply a WAL delta to a corrupted page. The cost is WAL volume inflation immediately after each checkpoint. This is a significant factor in WAL sizing and replication bandwidth planning.
Strong Answer:
  • This is classic table bloat. Common causes: autovacuum throttled too aggressively and could not keep up with dead tuple creation, a long-running transaction prevented VACUUM from removing tuples visible to that snapshot, a massive UPDATE created millions of dead tuples faster than autovacuum could process them, or hot_standby_feedback on a replica fed back an old xmin.
  • Online fix: use pg_repack. It creates a new copy of the table with only live tuples, builds new indexes, and swaps atomically with a brief exclusive lock. The table remains readable and writable throughout. You need roughly 100GB of free disk temporarily.
  • If pg_repack is unavailable, VACUUM FULL rewrites the table but holds an ACCESS EXCLUSIVE lock for the entire duration (potentially hours for 100GB). Never run this during business hours.
  • Prevention: tune autovacuum per-table for high-churn tables (autovacuum_vacuum_scale_factor = 0.01), monitor n_dead_tup / n_live_tup ratio, and alert when it exceeds 20%. Kill transactions older than 1 hour.
Follow-up: Why does regular VACUUM not return space to the OS?VACUUM marks dead tuple space as reusable within PostgreSQL’s file but can only truncate empty pages at the END of the file. If the last page has even one live tuple, the file cannot shrink. Truncating mid-file would invalidate all ctid references used by indexes, requiring a full index rebuild. VACUUM FULL and pg_repack create entirely new files with only live data and rebuild all indexes.
Strong Answer:
  • Every data file is divided into 8KB pages. Each page structure: page header (24 bytes) containing LSN of last WAL modification, flags, and free space boundaries. Then a line pointer array (4-byte entries growing forward) where each pointer contains the offset and length of a tuple. Then free space in the middle. Then tuples stored from the end of the page backward, each with a header (23 bytes minimum) containing xmin, xmax, infomask flags, null bitmap, and actual column data.
  • Line pointer indirection is key: tuples are never accessed directly, always through their line pointer. This enables HOT updates (redirect a line pointer without touching indexes) and makes VACUUM’s dead tuple cleanup possible without index updates.
  • Practical debugging: the pageinspect extension lets you examine raw page contents. SELECT * FROM heap_page_items(get_raw_page('users', 0)) shows every tuple on page 0, including dead tuples, xmin/xmax, and infomask flags. This is invaluable for diagnosing visibility issues, corruption, or unexpected bloat patterns.
Follow-up: What is TOAST and when does it activate?TOAST (The Oversized-Attribute Storage Technique) handles values exceeding approximately 2KB per column. Large values are compressed and/or moved to a separate TOAST table, with the main heap storing a small pointer. The key production impact: TOAST reads are additional random I/O. A query selecting a TOASTed text column is slower than selecting an integer on the same row. This is why SELECT * is discouraged — you may trigger TOAST decompression for columns you do not need.