A comprehensive deep dive into database internals — from storage engines and B-Trees to distributed consensus and real-world system architectures.
Every database has a storage engine at its core — the component responsible for writing data to disk and reading it back efficiently. When you issue a SQL query, it passes through several layers before anything touches the disk.
┌──────────────────────────────────────────────────────────┐
│ CLIENT APPLICATION │
│ SELECT * FROM users │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ SQL PARSER / LEXER │
│ Tokenize → Parse → Abstract Syntax Tree │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ QUERY PLANNER │
│ Rewrite rules → Cost estimation → Plan selection │
│ "Should I use index scan or sequential scan?" │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ EXECUTION ENGINE │
│ Volcano/Iterator model: open() → next() → close() │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ STORAGE ENGINE │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
│ │ B-Tree │ │ LSM Tree │ │ Buffer Pool / Cache │ │
│ │ Index │ │ (alt.) │ │ (pages in memory) │ │
│ └────┬─────┘ └────┬─────┘ └───────────┬───────────┘ │
│ └──────────────┴───────────────────┬┘ │
└──────────────────────────────────────────┼───────────────┘
│
┌───────────────────┴─────────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ DATA FILES │ │ WAL / LOG │
│ (pages on │ │ (append- │
│ disk) │ │ only) │
└─────────────┘ └─────────────┘
Used by PostgreSQL, MySQL/InnoDB, SQLite, Oracle. Data is organized in balanced trees of fixed-size pages. Reads are fast (O(log n) with high fan-out), but writes require updating pages in-place, which can cause write amplification and fragmentation.
Used by RocksDB, LevelDB, Cassandra, HBase. Writes go to an in-memory buffer (memtable) first, then flush to sorted files on disk (SSTables). Reads may need to check multiple files, but writes are extremely fast because they are always sequential.
"The storage engine is the beating heart of any database. Everything else — the query optimizer, the transaction manager, the replication layer — depends on its ability to store and retrieve data efficiently." — Alex Petrov, Database Internals
Databases don't read individual rows from disk — they read pages (also called blocks). A page is the smallest unit of I/O: typically 4 KB (SQLite), 8 KB (PostgreSQL), or 16 KB (MySQL/InnoDB). Understanding pages is fundamental to understanding database performance.
┌────────────────────────────────────────────────────────┐ │ PAGE (8 KB) │ ├────────────────────────────────────────────────────────┤ │ Page Header (24 bytes) │ │ ┌──────────────────────────────────────────────────┐ │ │ │ page_id | lsn | checksum | free_space | flags │ │ │ └──────────────────────────────────────────────────┘ │ ├────────────────────────────────────────────────────────┤ │ Item Pointers (line pointers) → offsets to tuples │ │ [ptr1] [ptr2] [ptr3] [ptr4] ... → grows downward │ ├────────────────────────────────────────────────────────┤ │ │ │ ◄── FREE SPACE ──► │ │ │ ├────────────────────────────────────────────────────────┤ │ Tuple data (actual rows) ← grows upward │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Row 4 │ │ Row 3 │ │ Row 2 │ │ Row 1 │ │ │ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ ├────────────────────────────────────────────────────────┤ │ Special space (B-tree pointers, etc.) │ └────────────────────────────────────────────────────────┘
| Database | Default Page Size | Configurable? | Notes |
|---|---|---|---|
| PostgreSQL | 8 KB | Compile-time only | TOAST for large values |
| MySQL/InnoDB | 16 KB | Yes (4K–64K) | Compression available |
| SQLite | 4 KB | Yes (512B–64K) | Matches OS page size |
| Oracle | 8 KB | Yes (2K–32K) | Multiple block sizes per tablespace |
| SQL Server | 8 KB | No | Extents of 8 pages (64 KB) |
The buffer pool (or buffer cache) is an in-memory area where the database caches frequently accessed pages. When a page is needed, the engine first checks the buffer pool. If it's a hit, no disk I/O is needed. On a miss, the page is fetched from disk and placed in the pool, potentially evicting a less-recently-used page (LRU or clock-sweep algorithm). A well-tuned buffer pool can serve 99%+ of reads from memory.
| Property | HDD | SSD (SATA) | SSD (NVMe) |
|---|---|---|---|
| Random read latency | 3–10 ms | 50–100 μs | 10–20 μs |
| Random IOPS | 100–400 | 50K–100K | 200K–1M |
| Sequential read | 100–200 MB/s | 500–560 MB/s | 2–7 GB/s |
| Write endurance | Unlimited | Limited (TBW) | Limited (TBW) |
| Random/Seq ratio | ~1000x gap | ~5-10x gap | ~2-4x gap |
The massive random vs sequential gap on HDDs is why B-Trees (with their large fan-out minimizing random reads) were so critical. SSDs narrow the gap but don't eliminate it — sequential I/O is still faster, which is why LSM trees still outperform for write-heavy workloads even on SSDs.
The way data is physically laid out on disk fundamentally determines what operations are fast. The two dominant approaches — row-oriented and column-oriented storage — optimize for entirely different access patterns.
Table: users(id, name, age, city) ┌────┬─────────┬─────┬──────────┐ │ id │ name │ age │ city │ ├────┼─────────┼─────┼──────────┤ │ 1 │ Alice │ 30 │ NYC │ │ 2 │ Bob │ 25 │ London │ │ 3 │ Charlie │ 35 │ Tokyo │ │ 4 │ Diana │ 28 │ Paris │ └────┴─────────┴─────┴──────────┘ ROW STORE layout (on disk): ┌──────────────────────────────────────────────────────┐ │ [1,Alice,30,NYC] [2,Bob,25,London] [3,Charlie,35,...│ └──────────────────────────────────────────────────────┘ → All columns of a row are stored together contiguously → Great for: SELECT * FROM users WHERE id = 2 COLUMN STORE layout (on disk): ┌───────────────────────┐ │ id: [1, 2, 3, 4] │ ← column file 1 ├───────────────────────┤ │ name: [Alice,Bob,...] │ ← column file 2 ├───────────────────────┤ │ age: [30, 25, 35, 28] │ ← column file 3 ├───────────────────────┤ │ city: [NYC,London,...] │ ← column file 4 └───────────────────────┘ → Each column stored separately → Great for: SELECT AVG(age) FROM users
Because each column file contains values of the same type and often similar values, column stores achieve dramatically better compression:
| Technique | How It Works | Best For |
|---|---|---|
| Run-Length Encoding | Store (value, count) pairs for repeated values | Sorted low-cardinality columns |
| Dictionary Encoding | Map distinct values to short integer codes | String columns with repeated values |
| Bit-Packing | Use minimal bits per value (e.g., 3 bits for 0–7) | Integer columns with small ranges |
| Delta Encoding | Store differences between consecutive values | Timestamps, sequential IDs |
Systems like TiDB, CockroachDB, SingleStoreDB, and SAP HANA attempt to serve both OLTP and OLAP workloads by maintaining row-oriented storage for transactional writes and a columnar replica or in-memory column index for analytical queries. The challenge is keeping both representations consistent without sacrificing performance of either workload.
The B+ Tree is the most important data structure in database engineering. Nearly every relational database uses B+ Trees for its primary indexes. It was designed specifically for disk-based systems, where minimizing the number of I/O operations is paramount.
┌─────────────────────────┐
│ [30 | 60 | 90] │ ◄─ Root (internal node)
│ / | | \ │ Keys act as separators
└──/─────|─────|──────\───┘
/ | | \
┌────────────┘ ┌────┘ └────┐ └────────────┐
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ [10|15|20|25]│ │ [35|40|45|50]│ │ [65|70|75|80]│ │ [95|97|99] │
│ │ │ │ │ │ │ │
│ Internal │ │ Internal │ │ Internal │ │ Internal │
└──┬──┬──┬──┬─┘ └──┬──┬──┬──┬─┘ └──┬──┬──┬──┬─┘ └──┬──┬──┬──┘
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌────┐┌────┐ ┌────┐┌────┐ ┌────┐┌────┐ ┌────┐┌────┐
│Leaf││Leaf│ ... │Leaf││Leaf│ ... │Leaf││Leaf│ ... │Leaf││Leaf│
│data││data│ │data││data│ │data││data│ │data││data│
└──┬─┘└──┬─┘ └──┬─┘└──┬─┘ └──┬─┘└──┬─┘ └──┬─┘└──┬─┘
└──►───┘──►──────┘──►──┘──►──────┘──►──┘──►──────┘──►──┘
▲ ▲
└─── Leaf nodes linked for range scans ───────────────────┘
Each internal node holds hundreds of keys (e.g., with 8 KB pages and 8-byte keys + 8-byte pointers, a node can hold ~500 keys). This means a tree of depth 3 can index ~125 million rows. Most queries touch just 3-4 pages.
All leaf nodes are at the same depth. Insertions and deletions maintain balance through page splits and merges, guaranteeing O(log n) access time for any key regardless of data distribution.
In B+ Trees (unlike B-Trees), all data lives in leaf nodes, which are linked together. This makes range queries (WHERE age BETWEEN 20 AND 30) efficient: find the starting leaf, then follow pointers sequentially.
Node size matches page size, so each node = one I/O operation. The high fan-out means even billion-row tables need only 3-4 levels, i.e., 3-4 disk reads for any lookup.
| Page Size | Key+Ptr Size | Fan-Out (f) | Depth for 1B rows | Pages for lookup |
|---|---|---|---|---|
| 4 KB | 16 bytes | ~250 | 4 | 4 pages |
| 8 KB | 16 bytes | ~500 | 4 | 4 pages |
| 16 KB | 16 bytes | ~1000 | 3 | 3 pages |
Formula: depth = ⌈logf(n)⌉ where f is fan-out and n is number of rows. With fan-out 500: log500(1,000,000,000) ≈ 3.3, so depth = 4.
"B-Trees are the workhorse of database indexing — not because they're theoretically optimal, but because they map perfectly to the realities of disk-based storage: large pages, high fan-out, and sequential leaf scans." — Adapted from Database Internals by Alex Petrov
The Log-Structured Merge Tree (LSM Tree) is the alternative to B-Trees, optimized for write-heavy workloads. Instead of updating data in-place, LSM Trees buffer writes in memory and periodically flush them as immutable sorted files to disk.
WRITE PATH:
┌──────────┐ ┌─────────────────────────────────────┐
│ Write │────►│ Write-Ahead Log (append-only) │
│ Request │ └─────────────────────────────────────┘
└────┬─────┘ │
│ │ (crash recovery)
▼ │
┌─────────────────────────┐ │
│ MEMTABLE │◄────┘
│ (Red-Black / Skip List)│
│ Sorted in memory │
│ [key1→v, key2→v, ...] │
└─────────┬───────────────┘
│ (when full, ~64MB)
▼
┌─────────────────────────┐
│ Immutable Memtable │──── flush to disk ────┐
└─────────────────────────┘ │
▼
DISK (sorted runs / SSTables):
┌─────────────────────────────────────────────────────┐
│ Level 0: [SST-1] [SST-2] [SST-3] (may overlap) │
├─────────────────────────────────────────────────────┤
│ Level 1: [────── SST-A ──────] [──── SST-B ────] │
├─────────────────────────────────────────────────────┤
│ Level 2: [────────────── SST-X ──────────────────] │
│ (10x larger than level 1) │
└─────────────────────────────────────────────────────┘
▲ │
└─── COMPACTION ────────────┘
(merge-sort overlapping SSTables)
| Metric | B-Tree | LSM (Leveled) | LSM (Size-Tiered) |
|---|---|---|---|
| Write amplification | ~2-4x Medium | ~10-30x High | ~4-10x Medium |
| Read amplification | ~1x Low | ~1-2x Low | ~5-20x High |
| Space amplification | ~1.5x Medium | ~1.1x Low | ~2-3x High |
Hash indexes provide O(1) average-case lookups for exact-match queries. They work by applying a hash function to the key and using the result to locate the data directly.
Hash Index Mechanism:
┌─────────────┐
hash("alice") = 3 ──────────► │ Bucket 0 │
hash("bob") = 1 ──────┐ ├─────────────┤
hash("carol") = 3 ───┐ │ │ Bucket 1 │◄── bob → row_ptr
hash("dave") = 0 ─┐ │ │ ├─────────────┤
│ │ │ │ Bucket 2 │
│ │ │ ├─────────────┤
│ │ └────► │ Bucket 3 │◄── alice → row_ptr
│ └───────► │ │ carol → row_ptr
│ ├─────────────┤ (chaining)
└─────────► │ Bucket 0 │◄── dave → row_ptr
└─────────────┘
| Aspect | Hash Index | B-Tree Index |
|---|---|---|
| Exact lookup | O(1) avg | O(log n) |
| Range queries | Not supported | Efficient |
| ORDER BY | Not supported | Supported |
| Prefix matching | No | Yes |
| Worst case | O(n) with collisions | O(log n) always |
| Resizing | Expensive (rehash) | Incremental splits |
| Disk-friendly | Poor (random access) | Good |
Supported but rarely recommended. Before v10, hash indexes weren't crash-safe (not WAL-logged). Now they're safe but still only support equality. Useful for very large text keys where B-Tree overhead is high.
Redis, Memcached, and in-memory hash tables in query execution (e.g., hash join build phase) use hash indexes heavily. Without disk I/O concerns, the O(1) lookup shines.
Bitcask uses an in-memory hash map pointing to file offsets on disk. All writes are appended to a log file. Fast reads (one disk seek) and writes, but the entire key set must fit in memory.
A Bloom filter is a space-efficient probabilistic data structure that tests whether an element is a member of a set. It can tell you "definitely not in the set" or "probably in the set" — but never gives false negatives.
Bloom Filter: bit array of m bits, k hash functions
Insert "alice":
h1("alice") = 2 h2("alice") = 5 h3("alice") = 9
Bit array: [0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]
▲ ▲ ▲
set bit 2 set bit 5 set bit 9
Insert "bob":
h1("bob") = 1 h2("bob") = 5 h3("bob") = 12
Bit array: [0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0]
▲ ▲
set bit 1 set bit 12
(bit 5 already set — shared!)
Query "carol":
h1("carol") = 2 h2("carol") = 7 h3("carol") = 12
Check: bit[2]=1 ✓ bit[7]=0 ✗ → DEFINITELY NOT IN SET
(one bit is 0, so "carol" was never inserted)
Query "dave":
h1("dave") = 1 h2("dave") = 5 h3("dave") = 9
Check: bit[1]=1 ✓ bit[5]=1 ✓ bit[9]=1 ✓ → PROBABLY IN SET
(all bits set — could be a FALSE POSITIVE if bits were set by others)
The probability of a false positive is approximately: p ≈ (1 − e−kn/m)k
| Bits per element (m/n) | Optimal k | False positive rate | Space per element |
|---|---|---|---|
| 8 | 6 | 2.2% | 1 byte |
| 10 | 7 | 0.82% | 1.25 bytes |
| 12 | 8 | 0.31% | 1.5 bytes |
| 16 | 11 | 0.046% | 2 bytes |
| 20 | 14 | 0.0066% | 2.5 bytes |
Each SSTable has an associated Bloom filter. Before reading an SSTable from disk, the filter is checked. If it says "not present," the SSTable is skipped entirely — saving a costly disk read. This transforms LSM read performance from checking all SSTables to checking only relevant ones.
Cassandra uses Bloom filters to avoid querying nodes that don't have the requested partition. In distributed joins, Bloom filters can be broadcast to filter rows before network transfer (Bloom join / semi-join optimization).
Understanding how a SQL statement goes from text to results is crucial for performance tuning and debugging.
┌──────────────────────────────────────────────────────────────┐
│ SELECT u.name, COUNT(o.id) │
│ FROM users u JOIN orders o ON u.id = o.user_id │
│ WHERE u.age > 25 │
│ GROUP BY u.name │
│ HAVING COUNT(o.id) > 5 │
│ ORDER BY COUNT(o.id) DESC │
└──────────────────────┬───────────────────────────────────────┘
│
┌─────────────▼──────────────┐
│ 1. LEXER / TOKENIZER │
│ Break SQL into tokens: │
│ SELECT, u.name, COUNT, │
│ FROM, users, JOIN, ... │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ 2. PARSER │
│ Build Abstract Syntax │
│ Tree (AST). Check syntax. │
│ Resolve table/column │
│ names against catalog. │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ 3. QUERY REWRITER │
│ Apply rewrite rules: │
│ • View expansion │
│ • Subquery flattening │
│ • Predicate pushdown │
│ • Constant folding │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ 4. QUERY OPTIMIZER │
│ Generate candidate plans. │
│ Estimate cost of each. │
│ Choose cheapest plan. │
│ (join order, index usage, │
│ join algorithm, etc.) │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ 5. PLAN EXECUTOR │
│ Execute the chosen plan. │
│ Volcano model: │
│ open() → next() → close() │
│ Pull tuples through the │
│ operator tree. │
└─────────────┬──────────────┘
│
┌─────────────▼──────────────┐
│ 6. RESULT SET │
│ Format and send results │
│ back to client via wire │
│ protocol. │
└────────────────────────────┘
The query optimizer is arguably the most complex component of a database. Its job: given a logical query, find the cheapest physical execution plan that produces correct results. This is an NP-hard problem in general, so optimizers use heuristics and dynamic programming.
The optimizer must estimate how many rows each operation produces. Selectivity is the fraction of rows that pass a filter.
| Predicate Type | Estimation Method | Accuracy |
|---|---|---|
col = value | 1 / n_distinct (uniform assumption) or histogram lookup | Good with histograms |
col > value | Fraction of histogram buckets above value | Good |
col LIKE 'abc%' | Range estimation on text | Moderate |
col1 = x AND col2 = y | Assume independence: sel(A) × sel(B) | Often wrong — correlated columns |
func(col) = value | Default estimate (often 0.1% or 1%) | Poor |
For a query joining n tables, there are n! possible join orders (ignoring commutativity, it's still Catalan numbers — exponential). A 10-table join has over 17 billion possible orderings. Optimizers use:
Joins are the most expensive operation in SQL processing. The choice of join algorithm dramatically affects performance, and the optimizer must choose wisely based on table sizes, available indexes, sort orders, and available memory.
NESTED LOOP JOIN:
for each row r in outer_table: ┌─────────┐
for each row s in inner_table: │ Outer │──┐
if r.key == s.key: │ (small) │ │ for each outer row
emit (r, s) └─────────┘ │
▼
Can use index on inner table ┌──────────────────┐
for Index Nested Loop Join │ Scan Inner table │
(avoid full inner scan each time) │ (or index lookup)│
└──────────────────┘
HASH JOIN:
Build phase: ┌─────────┐
Build hash table from │ Build │ → Hash Table
smaller table (build side) │ (small) │ {key: [rows]}
Probe phase: └─────────┘
Scan larger table, probe ┌─────────┐
hash table for matches │ Probe │ → Lookup each key
│ (large) │ in hash table
└─────────┘
MERGE JOIN:
Both inputs must be sorted on ┌────────┐ ┌────────┐
the join key (or index provides │Sorted A│ │Sorted B│
sort order) │ 1,3,5,7│ │ 2,3,5,8│
└───┬────┘ └───┬────┘
Two pointers advance together: │ │
Like merging two sorted arrays └─────┬─────┘
▼
Match: 3, 5
| Algorithm | Time Complexity | Requires | Best When | Memory |
|---|---|---|---|---|
| Nested Loop | O(N × M) or O(N × log M) with index | Nothing special | Small outer table, indexed inner table | Low |
| Hash Join | O(N + M) | Equality join only, memory for hash table | Large unsorted tables, no useful indexes | High (build table) |
| Merge Join | O(N log N + M log M) or O(N + M) if pre-sorted | Sortable join keys | Both inputs already sorted (index), or result needs ORDER BY on join key | Medium |
Nested Loop: One side is very small (< ~1000 rows) and the other has an index on the join column. The "index nested loop join" turns O(N×M) into O(N × log M). Hash Join: Both tables are large, no useful indexes, and enough memory for the hash table (or it can spill to disk). Most equi-joins on large tables use this. Merge Join: Both inputs are already sorted (from an index scan or a previous sort), especially if the query also needs ORDER BY on the join key.
Choosing the right indexes is the single most impactful thing you can do for query performance. But more indexes aren't always better — each index adds write overhead and storage cost.
| Index Type | Syntax (PostgreSQL) | Use Case | How It Works |
|---|---|---|---|
| Single Column | CREATE INDEX idx ON t(col) |
Equality and range queries on one column | B-Tree on one column's values |
| Composite | CREATE INDEX idx ON t(a, b, c) |
Queries filtering on leftmost prefix: a, (a,b), (a,b,c) | B-Tree sorted by (a, then b, then c). Left-prefix rule applies. |
| Covering | CREATE INDEX idx ON t(a) INCLUDE (b, c) |
Index-only scans — avoid table heap lookup | B-Tree on (a), with (b,c) stored in leaf pages only |
| Partial | CREATE INDEX idx ON t(col) WHERE active = true |
Index only a subset of rows — smaller, faster | B-Tree that only includes rows matching the predicate |
| Expression | CREATE INDEX idx ON t(LOWER(email)) |
Queries with function calls in WHERE | B-Tree on computed expression values |
| Hash | CREATE INDEX idx ON t USING hash(col) |
Equality-only lookups on large keys | Hash table — O(1) lookup, no range support |
| GIN | CREATE INDEX idx ON t USING gin(col) |
Full-text search, JSONB containment, array membership | Inverted index: maps each element/token to list of row IDs |
| GiST | CREATE INDEX idx ON t USING gist(col) |
Geometric/spatial data, range types, nearest-neighbor | Generalized search tree — balanced tree of bounding boxes |
Index on (country, city, street): Can satisfy: Cannot satisfy: ✓ WHERE country = 'US' ✗ WHERE city = 'NYC' ✓ WHERE country = 'US' AND city = 'NYC' ✗ WHERE street = 'Broadway' ✓ WHERE country = 'US' AND city = 'NYC' ✗ WHERE city = 'NYC' AND street = '...' AND street = 'Broadway' ✓ WHERE country = 'US' ORDER BY city (skips leftmost prefix!) Think of it like a phone book sorted by: last_name → first_name → middle_name You can look up by last_name, or (last, first), but NOT by first_name alone.
Over-indexing: Every index slows writes (INSERT, UPDATE, DELETE must update all indexes). Unused indexes: Monitor with pg_stat_user_indexes and drop unused ones. Low-selectivity indexes: An index on a boolean column (50/50 split) rarely helps — the optimizer will choose a sequential scan anyway. Wrong column order in composite indexes: The equality columns should come first, then range columns.
ACID is the set of guarantees that make database transactions reliable. But each letter means something very specific — and most explanations only scratch the surface.
Not about concurrency (that's Isolation). Atomicity means a transaction is all-or-nothing. If any part fails — whether from a constraint violation, a crash, or an explicit ROLLBACK — the entire transaction's effects are undone. Implementation: the database uses the WAL (undo log) to reverse partial changes, or shadow paging to avoid modifying data in-place until commit.
Failure scenario: A bank transfer debits account A but crashes before crediting account B. Atomicity ensures the debit is rolled back on recovery — the money isn't lost.
The weakest guarantee — partly application-level. The database enforces declared constraints (foreign keys, CHECK constraints, UNIQUE, NOT NULL). But "business logic consistency" is the application's job. If you transfer $100 but the application only subtracts it from one account, the DB can't know the total should be preserved.
Failure scenario: An INSERT violates a UNIQUE constraint. The database rejects it and the transaction is aborted — the database remains consistent. But if your application logic allows negative balances and you didn't add a CHECK constraint, that's on you.
Concurrent transactions should behave as if they ran serially (in the strongest isolation level). In practice, databases offer weaker isolation levels for better performance. Isolation prevents anomalies like dirty reads, non-repeatable reads, and phantom reads. Implementation: locks (2PL), MVCC, or optimistic concurrency control.
Failure scenario: Transaction A reads a row, transaction B updates and commits it, then A reads again and sees a different value — a non-repeatable read. Proper isolation level prevents this.
Once a transaction commits, its changes survive any subsequent failure — power loss, crash, disk failure (with replication). Implementation: the WAL is flushed to disk (fsync) before the commit is acknowledged. Even if the data pages haven't been written yet, the WAL contains enough information to reconstruct the changes on recovery.
Failure scenario: The server crashes immediately after COMMIT returns. On restart, the database replays the WAL and applies any committed changes that weren't yet in the data files. No committed data is lost.
SQL defines four isolation levels, each preventing different anomalies. Stronger isolation means fewer anomalies but more overhead (locking, version tracking, aborts).
| Anomaly | Description | Example |
|---|---|---|
| Dirty Read | Reading data written by an uncommitted transaction | T1 writes row, T2 reads it, T1 rolls back — T2 used phantom data |
| Non-Repeatable Read | A row read twice within a transaction returns different values | T1 reads row, T2 updates and commits, T1 reads again — different value |
| Phantom Read | A range query returns different rows when re-executed | T1 queries WHERE age > 25 (3 rows), T2 inserts a matching row, T1 re-queries (4 rows) |
| Write Skew | Two transactions read overlapping data, make disjoint writes, creating inconsistency | Two doctors both read "2 on-call", each withdraws themselves, leaving 0 on-call |
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read | Write Skew |
|---|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible | Possible |
| Read Committed | Prevented | Possible | Possible | Possible |
| Repeatable Read | Prevented | Prevented | DB-dependent* | Possible |
| Serializable | Prevented | Prevented | Prevented | Prevented |
*PostgreSQL's "Repeatable Read" actually uses Snapshot Isolation, which prevents phantom reads but still allows write skew. MySQL/InnoDB uses gap locks at Repeatable Read to prevent phantoms but with different trade-offs.
| Database | Default Level | Implementation |
|---|---|---|
| PostgreSQL | Read Committed | MVCC (snapshot per statement) |
| MySQL/InnoDB | Repeatable Read | MVCC + gap locks |
| Oracle | Read Committed | MVCC (undo segments) |
| SQL Server | Read Committed | Locking (or RCSI with MVCC) |
| SQLite | Serializable | Database-level locking |
MVCC is the key insight that enables high-concurrency databases: readers never block writers, and writers never block readers. Instead of locking rows, the database keeps multiple versions of each row and shows each transaction only the versions it's allowed to see.
PostgreSQL stores version info IN the heap tuple itself:
Tuple Header:
┌──────────┬──────────┬────────────┬────────────────────┐
│ xmin │ xmax │ ctid │ infomask bits │
│ (created │ (deleted │ (location │ (committed, │
│ by txn) │ by txn) │ on disk) │ aborted, etc.) │
└──────────┴──────────┴────────────┴────────────────────┘
Example: UPDATE users SET name='Bob2' WHERE id=1;
(executed by transaction 150)
BEFORE:
┌─────────────────────────────────────────┐
│ xmin=100 xmax=∞ data: {id:1, Bob} │ ← visible to all
└─────────────────────────────────────────┘
AFTER:
┌─────────────────────────────────────────┐
│ xmin=100 xmax=150 data: {id:1, Bob} │ ← old version (dead)
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ xmin=150 xmax=∞ data: {id:1, Bob2} │ ← new version (live)
└─────────────────────────────────────────┘
Visibility rule (simplified, for Read Committed):
A tuple is visible if:
xmin is committed AND (xmax is not set OR xmax is not committed)
InnoDB stores UNDO LOG pointers in each row:
Clustered Index Row:
┌──────────┬──────────┬──────────────┬──────────┐
│ trx_id │ roll_ptr │ actual data │ ... │
│ (last │ (pointer │ │ │
│ modifier)│ to undo) │ │ │
└──────────┴────┬─────┴──────────────┴──────────┘
│
▼
┌──────────────────────────────────┐
│ Undo Log Entry (prev version) │
│ trx_id=140, data: {old values} │
│ roll_ptr ──────────────────────┼──► Even older version
└──────────────────────────────────┘
→ InnoDB builds version chain via undo log
→ PostgreSQL stores all versions in heap (needs VACUUM)
The WAL is the mechanism that makes durability possible without writing every changed data page to disk on every commit. The core rule: log the change before applying it. If the system crashes, the log is replayed to reconstruct any committed changes.
Writing modified data pages directly to disk on every transaction would be enormously slow — pages are scattered across the disk (random I/O), and a single UPDATE might modify an index page, the heap page, and TOAST pages. Instead, the database writes a compact description of the change to an append-only log file (sequential I/O — very fast), and periodically flushes dirty data pages in the background.
Transaction: UPDATE accounts SET balance = 900 WHERE id = 1;
UPDATE accounts SET balance = 1100 WHERE id = 2;
COMMIT;
┌────────────────────────────────────────────────────────────┐
│ 1. Modify pages in BUFFER POOL (in memory) │
│ Page 42: account 1 balance → 900 │
│ Page 87: account 2 balance → 1100 │
│ (pages marked "dirty" but NOT written to disk yet) │
└───────────────────────┬────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 2. Write WAL records (append to WAL file) │
│ LSN 1001: page 42 offset 128 old=1000 new=900 │
│ LSN 1002: page 87 offset 128 old=1000 new=1100 │
│ LSN 1003: COMMIT transaction 500 │
└───────────────────────┬────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 3. FSYNC the WAL to disk ← DURABILITY GUARANTEE │
│ Only after WAL is on disk does COMMIT return to client │
└───────────────────────┬────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 4. Later: CHECKPOINT │
│ Background writer flushes dirty pages to data files │
│ Records checkpoint position in WAL │
│ Old WAL segments can be recycled │
└────────────────────────────────────────────────────────────┘
CRASH RECOVERY:
┌────────────────────────────────────────────────────────────┐
│ 1. Find last checkpoint in WAL │
│ 2. REDO: replay all WAL records after checkpoint │
│ → applies committed changes not yet in data files │
│ 3. UNDO: roll back uncommitted transactions │
│ → ensures atomicity │
│ 4. Database is consistent — ready to accept connections │
└────────────────────────────────────────────────────────────┘
A checkpoint flushes all dirty buffer pool pages to disk and records the current WAL position. After a checkpoint, the database only needs to replay WAL records after that point on crash recovery. Without checkpoints, recovery would need to replay the entire WAL from the beginning — potentially hours of work. PostgreSQL triggers checkpoints based on time interval (checkpoint_timeout, default 5 min) or WAL volume (max_wal_size, default 1 GB).
Replication copies data across multiple nodes for fault tolerance (survive node failures), read scaling (distribute read load), and geographic proximity (serve users from nearby datacenters).
SINGLE-LEADER (Primary/Replica):
┌─────────┐ WAL / binlog stream ┌─────────┐
│ PRIMARY │ ──────────────────────────► │ REPLICA │
│ (R/W) │ ──────────────────────────► │ (Read) │
└─────────┘ └─────────┘
│ ┌─────────┐
└────────────────────────────────► │ REPLICA │
│ (Read) │
└─────────┘
MULTI-LEADER:
┌─────────┐ ◄──────────────────────► ┌─────────┐
│ LEADER │ bidirectional │ LEADER │
│ DC-East │ replication │ DC-West │
└─────────┘ └─────────┘
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ REPLICA │ │ REPLICA │
└─────────┘ └─────────┘
LEADERLESS (Dynamo-style):
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ Node 1 │ │ Node 2 │ │ Node 3 │ │ Node 4 │ │ Node 5 │
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘
Client writes to W nodes, reads from R nodes
Consistency if W + R > N (quorum)
| Strategy | How It Works | Trade-off |
|---|---|---|
| Last Write Wins (LWW) | Highest timestamp wins | Simple but loses data; clock skew issues |
| Merge values | Union of concurrent updates (CRDTs) | Only works for certain data types |
| Application-level | Store all conflicting versions, let app resolve | Most flexible but complex (Riak sibling values) |
| Conflict avoidance | Route all writes for a key to same leader | Reduces availability; doesn't handle leader changes |
"The CAP theorem is widely misunderstood. It does NOT say 'pick 2 of 3'. It says: during a network partition, you must choose between consistency and availability. When there's no partition, you can have both." — Martin Kleppmann, Designing Data-Intensive Applications
CAP Theorem (Brewer, 2000; proven by Gilbert & Lynch, 2002):
In a distributed system, when a NETWORK PARTITION occurs,
you must choose between:
┌─────────────────────────────────┐
│ CONSISTENCY (C) │ Every read receives the most
│ │ recent write or an error
└────────────────┬────────────────┘
│
┌───────────┴───────────┐
│ PARTITION (P) │ Network partition is
│ (inevitable) │ not a choice — it WILL happen
└───────────┬───────────┘
│
┌────────────────┴────────────────┐
│ AVAILABILITY (A) │ Every request receives a
│ │ non-error response
└─────────────────────────────────┘
During a partition:
CP system: refuse to serve requests (unavailable) to maintain consistency
→ e.g., HBase, MongoDB (with majority write concern), etcd
AP system: serve potentially stale data to remain available
→ e.g., Cassandra, DynamoDB, CouchDB
When there's NO partition (normal operation):
You can have BOTH consistency AND availability!
This is why "CA" is misleading — ALL systems are CA when the network works.
| Model | Guarantee | Performance | Used By |
|---|---|---|---|
| Linearizability Strongest | Operations appear instantaneous; real-time ordering | Slowest — requires coordination | etcd, ZooKeeper, Spanner |
| Sequential Consistency | Operations ordered consistently, but not necessarily real-time | Moderate | Some cache systems |
| Causal Consistency | Causally related operations are ordered; concurrent operations may diverge | Good — only tracks dependencies | MongoDB (sessions), COPS |
| Eventual Consistency Weakest | If no new updates, all replicas eventually converge | Fastest — no coordination | Cassandra, DynamoDB (default), DNS |
CAP only describes behavior during partitions. The PACELC framework extends it: "If Partition, choose Availability or Consistency; Else (normal operation), choose Latency or Consistency." This captures the fact that even without partitions, there's a fundamental trade-off between response time and consistency — synchronous replication is consistent but slow; async replication is fast but eventually consistent.
Consensus is the problem of getting multiple nodes to agree on a value despite failures. It is the foundation of fault-tolerant distributed systems — used for leader election, distributed locking, and replicated state machines.
RAFT: Three Roles
┌──────────┐ ┌──────────┐ ┌──────────┐
│ LEADER │ │ FOLLOWER │ │ FOLLOWER │
│ (1 only) │───►│ │ │ │
│ Handles │───►│ Replicate│ │ Replicate│
│ all │ │ entries │ │ entries │
│ writes │───►│ │◄───│ │
└──────────┘ └──────────┘ └──────────┘
LEADER ELECTION (term-based):
┌──────────────────────────────────────────────────────────┐
│ 1. Leader sends heartbeats every ~150ms │
│ 2. If a follower doesn't hear from leader for │
│ election timeout (150-300ms), it becomes CANDIDATE │
│ 3. Candidate increments term, votes for itself │
│ 4. Requests votes from all other nodes │
│ 5. Wins if gets majority (N/2 + 1) votes │
│ 6. New leader starts sending heartbeats │
└──────────────────────────────────────────────────────────┘
LOG REPLICATION:
Client ──► Leader
│
├──► Append to local log
├──► Send AppendEntries RPC to all followers
├──► Wait for majority acknowledgment
├──► COMMIT entry (apply to state machine)
└──► Respond to client
Leader Log: [1:set x=1] [2:set y=2] [3:set x=3] committed
Follower A: [1:set x=1] [2:set y=2] [3:set x=3] committed
Follower B: [1:set x=1] [2:set y=2] catching up...
▲
majority (2/3) = committed
NETWORK PARTITION: ┌─────────────────────┐ ┌─────────────────────┐ │ Partition A │ ✗ │ Partition B │ │ ┌────────┐ │ ███ │ ┌────────┐ │ │ │Leader │ │ ███ │ │Follower│ │ │ │(old) │ │ ███ │ │ │ │ │ │ │ │ ███ │ └────────┘ │ │ └────────┘ │ ███ │ ┌────────┐ │ │ ┌────────┐ │ ███ │ │Follower│ │ │ │Follower│ │ ███ │ │ │ │ │ └────────┘ │ │ └────────┘ │ └─────────────────────┘ └─────────────────────┘ 2 nodes — can't get 3 nodes — CAN elect majority (need 3/5) new leader (3/5 = majority) Old leader can't commit! New leader takes over. → Only ONE partition can have a majority → no split brain → This is why clusters use odd numbers (3, 5, 7 nodes)
PostgreSQL is an excellent case study because it's open-source, well-documented, and contains a full-featured implementation of nearly every concept discussed in this guide.
┌─────────────────────────────────────────────────────────────┐ │ PostgreSQL Architecture │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Client ──► Postmaster (listener, PID 1) │ │ │ │ │ ├──► Backend Process 1 (one per connection) │ │ ├──► Backend Process 2 │ │ └──► Backend Process 3 │ │ │ │ Background Processes: │ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ │ │ WAL Writer │ │ BG Writer │ │ Autovacuum Launcher │ │ │ │ (flush WAL) │ │ (dirty pages)│ │ → Autovacuum Workers│ │ │ └──────────────┘ └──────────────┘ └─────────────────────┘ │ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ │ │ Checkpointer │ │ Stats │ │ Logical Replication │ │ │ │ │ │ Collector │ │ Workers │ │ │ └──────────────┘ └──────────────┘ └─────────────────────┘ │ │ │ │ Shared Memory: │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Shared Buffers (default 128MB, tune to 25% of RAM) │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │ │ │Page 1│ │Page 2│ │Page 3│ │ ... │ │Page N│ │ │ │ │ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │ ├─────────────────────────────────────────────────────┤ │ │ │ WAL Buffers │ Lock Table │ CLOG │ Proc Array │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ On Disk: │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │ Data Dir │ │ WAL Dir │ │ CLOG │ │ pg_stat_tmp │ │ │ │ (base/) │ │ (pg_wal/)│ │(pg_xact/)│ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────┘
PostgreSQL uses a process-per-connection model (not threads). Each client connection gets a dedicated backend process via fork(). This provides excellent isolation (a crash in one backend doesn't affect others) but higher memory overhead compared to thread-per-connection (MySQL) or async I/O (Node.js-style) models. Connection poolers like PgBouncer or pgcat are essential for high-connection workloads.
PostgreSQL's buffer cache for data pages. Default 128 MB — typically tuned to ~25% of system RAM. Uses a clock-sweep algorithm for page eviction (similar to LRU but more efficient). Each 8 KB page has a pin count and usage count.
16 MB segment files. WAL records are written by backends, flushed by the WAL writer or at commit time (synchronous_commit). Supports streaming replication to replicas. WAL is also the basis for Point-in-Time Recovery (PITR).
Transaction status is tracked in the Commit Log (pg_xact/). Each transaction gets a 32-bit XID. When checking visibility, the backend looks up the XID's status (in-progress, committed, aborted) in the CLOG, cached in shared memory.
Removes dead tuples left by MVCC. Also updates the visibility map (tracks all-visible pages for index-only scans) and free space map (tracks available space). Autovacuum triggers based on tuple change thresholds. VACUUM FREEZE prevents XID wraparound — critical for long-lived databases.
The Oversized-Attribute Storage Technique. When a row exceeds ~2 KB (1/4 of 8 KB page), large column values are compressed and/or moved to a separate TOAST table. The main row stores a pointer to the TOAST data. Transparent to the application — you never interact with TOAST directly.
ANALYZE collects statistics: row count, distinct values per column, most common values, histogram of value distribution, correlation (physical vs logical ordering). The query planner depends entirely on these statistics for cost estimation.
Each database makes different architectural trade-offs. Understanding these trade-offs helps you choose the right tool for your workload.
| Feature | PostgreSQL | MySQL/InnoDB | SQLite | MongoDB | Redis | Cassandra |
|---|---|---|---|---|---|---|
| Type | Relational | Relational | Relational (embedded) | Document | Key-Value / Data Structure | Wide-Column |
| Storage Engine | Custom (heap + B-Tree) | InnoDB (B+ Tree) | B-Tree (single file) | WiredTiger (B-Tree + LSM) | In-memory + optional AOF/RDB | LSM Tree (SSTables) |
| Concurrency | MVCC (in-heap versions) | MVCC (undo log) | WAL mode: readers don't block writers | Document-level locking + MVCC | Single-threaded event loop | No locks (eventual consistency) |
| Process Model | Process-per-connection | Thread-per-connection (thread pool in Enterprise) | In-process library | Thread pool | Single-threaded (+ I/O threads in 6.0+) | Thread-per-core (SEDA) |
| Replication | Streaming (WAL-based) + logical | Binlog (statement/row/mixed) | Litestream / LiteFS (community) | Replica set (Raft-like) | Async primary-replica | Leaderless (Dynamo-style) |
| Sharding | Citus extension / app-level | Vitess / ProxySQL / app-level | N/A (embedded) | Built-in (auto-sharding) | Redis Cluster (hash slots) | Built-in (consistent hashing) |
| ACID | Full ACID | Full ACID | Full ACID | Multi-doc txns (4.0+) | Lua scripts only | Eventual (tunable) |
| Best For | Complex queries, extensions, correctness | Web apps, read-heavy, mature ecosystem | Embedded, mobile, edge, testing | Flexible schema, rapid iteration | Caching, sessions, real-time leaderboards | High write throughput, time-series, geographic distribution |
| Weakness | VACUUM overhead, connection model | Fewer advanced features, optimizer less sophisticated | Single writer, no network access | Joins, complex transactions | Data must fit in memory, limited queries | No joins, no ad-hoc queries, read-before-write expensive |
Multi-process, shared-nothing between backends (except shared buffers). Each backend has its own memory context for sorts, hash tables, etc. Extensible: custom types, operators, index methods, procedural languages, foreign data wrappers.
Separation between SQL layer and storage engine. InnoDB: clustered index (primary key IS the table), change buffer for secondary indexes, doublewrite buffer for torn-page protection, adaptive hash index.
Entire database in a single file. No server process — it's a library linked into your application. Uses a single B-Tree per table (rowid-based) and per index. WAL mode enables concurrent readers. Remarkably, it's the most deployed database in the world (every phone, browser, OS).
BSON documents stored in WiredTiger. Each collection can have B-Tree or LSM indexes. Replica sets use a Raft-inspired election protocol. Auto-sharding with configurable shard keys. Aggregation pipeline for analytical queries.
All data in memory with optional disk persistence (RDB snapshots + AOF log). Rich data structures: strings, hashes, lists, sets, sorted sets, streams, HyperLogLog. Single-threaded command execution ensures atomicity without locks. Sub-millisecond latency.
LSM tree storage, leaderless replication, tunable consistency (ONE, QUORUM, ALL). Partition key determines node placement. Clustering columns determine sort order within a partition. Designed for massive write throughput across multiple datacenters.
"There is no 'best' database — only the best database for your specific workload, consistency requirements, operational capacity, and scale. The engineering art is in understanding the trade-offs deeply enough to make an informed choice." — Adapted from Designing Data-Intensive Applications by Martin Kleppmann