Chapter 9: Storage Engines
B-Trees vs LSM-Trees, Write Amplification, Compaction
1. What is a Storage Engine?
A storage engine is the component of a database that manages how data is stored, retrieved, and updated on disk. It sits below the query layer and is responsible for physical data organization, indexing, concurrency control, and crash recovery.
MySQL supports multiple storage engines (InnoDB, MyISAM, Memory). MongoDB switched from MMAPv1 to WiredTiger. PostgreSQL has pluggable table access methods (heap, zheap). This allows you to choose the right tradeoffs per workload.
Storage Engine Responsibilities
| Responsibility | Description | Example Mechanisms |
|---|---|---|
| Data Layout | How rows/documents are physically organized | B-Tree pages, LSM SSTables, heap files |
| Indexing | Fast lookup structures for queries | B-Tree indexes, hash indexes, bloom filters |
| Concurrency Control | Multi-version concurrency control (MVCC) | Row versioning, timestamps, locks |
| Crash Recovery | Durability guarantees (ACID) | Write-Ahead Log (WAL), checkpoints |
| Caching | In-memory buffering to reduce disk I/O | Buffer pool (InnoDB), block cache (RocksDB) |
| Compression | Reduce storage footprint | Page compression, prefix compression |
Answer: Different workloads have different needs. InnoDB is optimized for OLTP (transactional, row-level locking, ACID). MyISAM was historically faster for read-heavy workloads with table-level locking. Memory engine is for temporary tables. Pluggable engines let you optimize per table.
2. B-Tree Deep Dive
B-Trees (Balanced Trees) are the workhorse of traditional databases. They provide O(log n) reads and writes, efficient range scans, and in-place updates.
Structure & Properties
- Balanced: All leaf nodes are at the same depth
- Branching Factor: Each node has up to
mchildren (typically 100-200 for disk-based trees) - Sorted: Keys are kept in sorted order within nodes
- Self-Balancing: Automatically rebalances on insert/delete via splits and merges
B-Tree vs B+ Tree
| Feature | B-Tree | B+ Tree |
|---|---|---|
| Data Location | Internal nodes AND leaves store data | Only leaf nodes store data |
| Leaf Linking | No links between leaves | Leaves linked as doubly-linked list |
| Range Scans | Requires tree traversal | Sequential scan of leaf nodes |
| Internal Node Size | Smaller fanout (stores data) | Higher fanout (only keys + pointers) |
| Used By | File systems (NTFS, HFS+) | Most databases (InnoDB, PostgreSQL) |
- Range Scans: Linked leaf nodes enable fast sequential reads (
SELECT * WHERE age BETWEEN 25 AND 35) - Higher Fanout: Internal nodes are smaller, so more fit in memory → fewer disk seeks
- Predictable Performance: All data at same depth → consistent latency
Operations
Search - O(log n)
Insertion - Page Splits
When a node overflows (exceeds max keys), it splits into two nodes. This can cascade up the tree.
Insert 25"] --> B["Node Full! Split at median (25)"] B --> C["Left: [10, 20]"] B --> D["Right: [30, 40]"] B --> E["Promote 25 to parent"] style A fill:#ffd93d,color:#1a1a2e,stroke:#f5b800 style B fill:#ff6b6b,color:#1a1a2e,stroke:#c92a2a style C fill:#6bcf7f,color:#1a1a2e,stroke:#37a850 style D fill:#6bcf7f,color:#1a1a2e,stroke:#37a850
Page Size Tradeoffs
| Page Size | Pros | Cons | Use Case |
|---|---|---|---|
| 4 KB | Matches OS page, less write amplification | Lower fanout, more tree height | OLTP, small rows |
| 8 KB | PostgreSQL default, good balance | Middle ground | General purpose |
| 16 KB | InnoDB default, higher fanout, fewer seeks | More write amplification, wasted space for small rows | OLTP with large rows |
| 32-64 KB | Very high fanout, shallow tree | High write amplification, SSD inefficiency | Analytical workloads |
Answer: High fanout. With 16KB pages, 100-byte rows, and 8-byte pointers, each internal node can have ~160 children. A 4-level tree can store 160^3 ≈ 4 million leaf pages × 100 rows/page = 400 million rows. With compression and larger pages, billions of rows fit in 4 levels.
Write Amplification in B-Trees
A 100-byte write can trigger:
- Read: Load 16KB page from disk
- Modify: Update 100 bytes in memory
- Write: Write entire 16KB page back (160x amplification)
- Cascading Splits: If page splits, write 2 pages + update parent (up to 48KB for single insert)
- WAL: Write log entry (~200 bytes)
Total: 10-30x write amplification is typical.
3. LSM-Tree Deep Dive
Log-Structured Merge Trees optimize for write throughput by batching writes in memory and sequentially flushing to disk. Reads are slower but mitigated with caching and bloom filters.
Architecture
Write Path - Sequential Writes Win
- Write to WAL: Append-only log for crash recovery (~200 bytes, <1ms)
- Insert into Memtable: In-memory skip list or AVL tree (O(log n), microseconds)
- Flush to SSTable: When memtable reaches size threshold (e.g., 64MB), write sorted run to L0 (sequential disk write, ~100 MB/s on SSD)
- Background Compaction: Merge overlapping SSTables across levels to maintain sorted order
Sequential disk writes are 10-100x faster than random writes on SSDs (500 MB/s vs 50 MB/s for random). LSM converts random writes into sequential ones via batching.
Read Path - Multi-Level Lookup
- Check Memtable: Most recent writes (O(log n))
- Check Immutable Memtable: Being flushed to disk (if exists)
- Check L0 SSTables: May overlap, need to check all (use bloom filters)
- Check L1...Ln SSTables: Non-overlapping per level, binary search by key range
- Merge: If key exists in multiple levels (e.g., updates), return most recent version
SSTable Structure
| Component | Purpose | Size Example |
|---|---|---|
| Data Block | Sorted key-value pairs, compressed | 64 KB per block |
| Index Block | Sparse index: first key of each data block | ~1% of file size |
| Bloom Filter | Probabilistic test: "key definitely NOT here" or "maybe here" | 10 bits/key ≈ 1% false positive |
| Footer | Metadata: compression, block offsets | ~100 bytes |
Levels & Compaction
| Level | Size Limit | Overlapping Keys? | Files |
|---|---|---|---|
| L0 | 10-20 files | Yes (each flush creates new file) | 10-20 SSTables |
| L1 | 10 MB (RocksDB default) | No (sorted runs) | ~10 files × 1 MB |
| L2 | 100 MB (10x L1) | No | ~100 files × 1 MB |
| L3 | 1 GB | No | ~1000 files × 1 MB |
| L4 | 10 GB | No | ~10000 files × 1 MB |
| L5 | 100 GB | No | ~100000 files × 1 MB |
| L6 | 1 TB+ | No | ~1M files × 1 MB |
Bloom Filters - Avoiding Disk Reads
A bloom filter is a probabilistic data structure that can answer "definitely NOT in set" or "maybe in set".
Yes = Maybe exists
No = Definitely not] style A fill:#ff6b6b,color:#1a1a2e,stroke:#c92a2a style E fill:#6bcf7f,color:#1a1a2e,stroke:#37a850 style F fill:#4ecdc4,color:#1a1a2e,stroke:#0d9488
| Bits per Key | False Positive Rate | Overhead (1M keys) |
|---|---|---|
| 8 bits | 2.3% | 1 MB |
| 10 bits | 0.9% | 1.25 MB |
| 12 bits | 0.4% | 1.5 MB |
| 16 bits | 0.02% | 2 MB |
Impact: With 10 bits/key and 20 SSTables in L0, bloom filters eliminate ~19.8 unnecessary disk reads per lookup (only 0.9% false positives).
4. B-Tree vs LSM-Tree Comparison
| Dimension | B-Tree | LSM-Tree | Winner |
|---|---|---|---|
| Write Throughput | 10-50K writes/sec (random I/O) | 100-500K writes/sec (sequential I/O) | LSM (5-10x) |
| Write Amplification | 10-30x (page updates + WAL) | 10-50x (compaction overhead) | Tie (depends on workload) |
| Read Latency | O(log n) - Single tree lookup | O(log n × levels) - Check multiple levels | B-Tree (2-5x faster) |
| Read Amplification | 1-2 pages (tree path) | 1-10+ SSTables (worst case) | B-Tree |
| Range Scans | Excellent (sequential leaf scan) | Good (but requires merging across levels) | B-Tree |
| Space Amplification | Low (in-place updates, 10-20% overhead) | High (multiple versions during compaction, 20-50%) | B-Tree |
| Write Stalls | Rare (only during page splits) | Common (L0 compaction backlog) | B-Tree |
| Point Lookups | Fast (O(log n), ~3-4 disk reads) | Slower (bloom filters + multiple SSTables) | B-Tree |
| Updates | In-place (fast) | Append-only (treated as insert + tombstone) | B-Tree |
| Deletes | In-place (fast) | Tombstone marker (space not freed until compaction) | B-Tree |
| Concurrency | Latch contention on hot pages | Lock-free writes to memtable | LSM |
| SSD Wear | Moderate (random writes) | High (compaction rewrites data) | B-Tree |
Performance Numbers - Real Hardware
| Benchmark | B-Tree (InnoDB) | LSM (RocksDB) | Hardware |
|---|---|---|---|
| Random Writes | 20K ops/sec | 150K ops/sec | NVMe SSD, 16 threads |
| Random Reads | 80K ops/sec | 40K ops/sec | 50% cache hit rate |
| Range Scan (1M rows) | 2.5 sec | 4.1 sec | Sequential scan |
| Bulk Load (100M rows) | 45 min | 15 min | Sorted input |
When to Choose B-Tree
- Read-Heavy: OLTP workloads with 80%+ reads (e.g., web app backends)
- Point Lookups:
SELECT * WHERE id = ?dominates - Updates: Frequent in-place updates (
UPDATE users SET last_login = NOW()) - Consistent Latency: No compaction stalls (trading higher p99 for predictable p50)
- Low Space Overhead: Storage constrained
When to Choose LSM-Tree
- Write-Heavy: Time-series, logging, analytics ingestion (90%+ writes)
- High Throughput: Need to sustain 100K+ writes/sec
- Append-Only: Inserts dominate (no updates/deletes)
- SSD Optimization: Leverage sequential write performance
- Compression: Better compression ratios (entire SSTables compressed)
Answer: Cassandra targets write-heavy time-series and logging (append-only). MongoDB is general-purpose OLTP with mixed reads/writes and frequent updates — B-Tree's in-place updates and faster reads win. Note: WiredTiger actually uses an LSM option for specific workloads.
5. Write Amplification
Write Amplification (WA) = Total bytes written to disk / Bytes written by application
A 1 KB application write might cause 30 KB of disk writes due to:
- Updating data structures (pages, SSTables)
- Write-ahead logging
- Compaction (LSM) or page splits (B-Tree)
B-Tree Write Amplification
WA = 16,484 / 100 ≈ 165x
Factors:
- Page Size: 16 KB pages → 160x amplification for 100-byte writes
- Page Splits: Can trigger 2-3 page writes (48 KB)
- WAL: Additional 100-500 bytes per write
- Double-Write Buffer: InnoDB writes pages twice (once to double-write buffer, then to data file) → 2x amplification
Typical WA: 10-30x for OLTP workloads
LSM-Tree Write Amplification
≈ 800MB for 64MB of user data
WA ≈ 12.5x
Compaction Overhead:
- Leveled Compaction (RocksDB): Each level is 10x the previous. A write at L0 gets rewritten ~5 times → WA ≈ 10-30x
- Size-Tiered Compaction (Cassandra): Merge SSTables of similar size. WA ≈ 5-10x but more space amplification
Calculation: For leveled compaction with 10x growth and 6 levels:
WA = 1 (WAL) + 1 (L0 flush) + 10 (L0→L1) + 10 (L1→L2) + 10 (L2→L3) + ... ≈ 50x worst case
SSD Wear & Endurance
| SSD Type | TBW (Terabytes Written) | Daily Writes @ 100 GB/day | Lifespan with WA=30x |
|---|---|---|---|
| Consumer (QLC) | 600 TBW (1 TB drive) | 3 TB/day (30x amp) | 200 days |
| Pro (TLC) | 1,200 TBW | 3 TB/day | 400 days |
| Enterprise (SLC/MLC) | 10,000 TBW | 3 TB/day | 3,333 days (~9 years) |
Minimizing Write Amplification
| Technique | Storage Engine | WA Reduction |
|---|---|---|
| Larger Memtables | LSM | Fewer flushes → fewer compactions (128MB vs 64MB: 2x reduction) |
| Universal Compaction | LSM (RocksDB) | Compact all levels at once → WA ≈ 10x (vs 30x leveled) |
| Disable WAL | Both | Save 1x amplification (but lose durability!) |
| Group Commit | Both | Batch WAL writes → amortize fsync cost |
| Compression | Both | Fewer bytes written (e.g., 50% compression → 0.5x multiplier) |
| Disable Double-Write | B-Tree (InnoDB) | Save 2x amplification (but risk partial page writes) |
Answer: (1) Leveled compaction instead of size-tiered, (2) Bloom filters reduce unnecessary compactions, (3) Compression at SSTable level, (4) Dynamic level sizing (levels grow geometrically), (5) Subcompaction (parallelize compaction across key ranges).
6. Compaction Strategies
Compaction merges SSTables to remove duplicate keys, apply tombstones, and maintain sorted order. The strategy trades off write amplification, space amplification, and read amplification.
Size-Tiered Compaction (STCS)
Used by: Cassandra (default), HBase
Algorithm:
- Group SSTables of similar size (e.g., 10 MB)
- When 4 SSTables accumulate, merge them into one (40 MB)
- Repeat at next tier (4 × 40 MB → 160 MB)
Pros:
- Low write amplification (each SSTable compacted ~log4(N) times → WA ≈ 5-10x)
- Simple to implement
Cons:
- High space amplification (multiple overlapping SSTables → 2-3x overhead)
- Slow reads (must check many SSTables)
- Temporary space spike during compaction (need 2x space)
Leveled Compaction (LCS)
Used by: RocksDB (default), LevelDB, Cassandra (optional)
Algorithm:
- L0 allows overlapping SSTables (direct memtable flushes)
- L1...Ln have non-overlapping SSTables within each level
- Li+1 is 10x the size of Li
- When Li exceeds threshold, pick overlapping SSTables from Li and Li+1, merge, write to Li+1
Pros:
- Fast point lookups (bloom filters + at most 1 SSTable per level → check ~7 SSTables)
- Low space amplification (~10% overhead, no overlapping SSTables except L0)
- Predictable performance (no temporary space spikes)
Cons:
- High write amplification (each byte rewritten ~10x per level → WA ≈ 30x)
- Compaction can bottleneck writes (L0 stalls)
Universal Compaction
Used by: RocksDB (option for write-heavy workloads)
Algorithm: Hybrid between size-tiered and leveled. Pick SSTables to compact based on size ratio and age.
Pros:
- Lower write amplification than leveled (WA ≈ 10-15x)
- Good for write-heavy workloads
Cons:
- Higher space amplification than leveled
- More complex tuning
FIFO Compaction
Used by: RocksDB (time-series data)
Algorithm: Drop oldest SSTables when total size exceeds threshold. No merging.
Pros:
- Minimal write amplification (WA ≈ 1x, just WAL + flush)
- Perfect for TTL-based data (logs, metrics)
Cons:
- No duplicate removal (wastes space if keys updated)
- Requires time-ordered keys
Compaction Strategy Comparison
| Strategy | Write Amp | Space Amp | Read Amp | Use Case |
|---|---|---|---|---|
| Size-Tiered | Low (5-10x) | High (200-300%) | High (10-20 SSTables) | Write-heavy, storage abundant |
| Leveled | High (20-40x) | Low (110-120%) | Low (1-2 SSTables/level) | Read-heavy, point lookups |
| Universal | Medium (10-20x) | Medium (150-200%) | Medium (5-10 SSTables) | Balanced workloads |
| FIFO | Minimal (1-2x) | Low (110%) | High (all SSTables) | Time-series, TTL data |
For time-series metrics (write-heavy, TTL-based), switch from leveled to FIFO compaction. This reduced write amplification from 30x to 2x, increasing write throughput from 50K ops/sec to 400K ops/sec.
Compaction Triggers & Throttling
Compaction runs in background threads but can stall writes if it falls behind.
| Condition | Action | Impact |
|---|---|---|
| L0 reaches soft limit (e.g., 20 files) | Slow down writes (sleep 1ms per write) | Write throughput drops 50% |
| L0 reaches hard limit (e.g., 40 files) | Stall writes until compaction catches up | Write latency spikes to seconds |
| Memtable full | Flush to L0 | Triggers L0→L1 compaction |
| Disk space low | Aggressive compaction or reject writes | Availability risk |
Answer: Cassandra targets write-heavy workloads (time-series, logging). Size-tiered has 5-10x write amplification vs 30x for leveled. Cassandra accepts higher space amplification (200-300%) and slower reads in exchange for 3x higher write throughput. For read-heavy tables, Cassandra supports leveled compaction as an option.
7. Write-Ahead Log (WAL)
The Write-Ahead Log ensures durability by logging all changes before applying them to the database. If the database crashes, it replays the WAL to recover uncommitted changes.
Why WAL is Needed
With WAL: Replay log entry
Problem: Databases use in-memory buffer pools to cache pages. If a transaction commits but the dirty page hasn't been flushed to disk, a crash loses the change.
Solution: Write to WAL first (cheap sequential append), then modify buffer pool. On crash, replay WAL.
WAL Structure
| Field | Size | Description |
|---|---|---|
| LSN | 8 bytes | Log Sequence Number (monotonically increasing) |
| Transaction ID | 8 bytes | Which transaction made this change |
| Record Type | 1 byte | INSERT, UPDATE, DELETE, COMMIT, ABORT |
| Table ID | 4 bytes | Which table was modified |
| Page ID | 4 bytes | Which page was modified |
| Offset | 2 bytes | Offset within page |
| Before Image | Variable | Old value (for UNDO) |
| After Image | Variable | New value (for REDO) |
| Checksum | 4 bytes | Detect corruption |
Total: ~100-500 bytes per log entry (depending on data size)
WAL Write Process
Key Insight: WAL writes are sequential (fast), data page writes are random (slow). WAL makes commits fast by deferring expensive random I/O.
Group Commit Optimization
fsync() is expensive (~5-10ms on SSD). Batching multiple transactions into one fsync improves throughput.
Transactions 1, 2, 3 all commit with a single fsync → 3x throughput improvement.
Checkpoint & WAL Truncation
WAL grows unbounded. Periodically, the database performs a checkpoint:
- Flush all dirty pages to disk
- Record checkpoint LSN in a special file
- Truncate WAL entries before checkpoint LSN (they're now redundant)
Recovery: Start from last checkpoint, replay WAL from checkpoint LSN to end.
WAL & Replication
WAL is also used for replication:
- PostgreSQL: Replicas stream WAL entries from primary
- MySQL: Binary log (similar to WAL) is shipped to replicas
- RocksDB: WAL can be tailed for change data capture (CDC)
WAL Performance Tuning
| Parameter | Default | Tuning | Impact |
|---|---|---|---|
| fsync Mode | fsync | fdatasync (skip metadata sync) | 10-20% faster commits |
| WAL Buffer Size | 16 MB | 64 MB (high write volume) | Reduce fsync frequency |
| Commit Delay | 0 ms | 10 ms (batch commits) | 5-10x throughput (higher latency) |
| Synchronous Commit | On | Off (async replication) | 2-3x throughput (risk data loss) |
Answer: (1) Sequential WAL writes are 10-100x faster than random page writes. (2) Transactions can modify multiple pages; WAL ensures atomicity (either all committed or none). (3) WAL enables group commit (batch fsync for multiple transactions). (4) WAL supports replication and PITR.
8. Page Cache & Buffer Pool
Disk I/O is 1000x slower than memory. Databases cache frequently accessed pages in memory to avoid disk reads.
InnoDB Buffer Pool
InnoDB's buffer pool is a large memory area (typically 50-80% of RAM) that caches data and index pages.
Buffer Pool Structure
| Component | Purpose | Size |
|---|---|---|
| Data Pages | Cached table rows | ~95% of buffer pool |
| Index Pages | Cached B-Tree index nodes | Included in 95% |
| Insert Buffer | Batch secondary index updates | ~1% of buffer pool |
| Adaptive Hash Index | In-memory hash index for hot pages | ~1% of buffer pool |
| Lock Info | Row locks, table locks | ~1% of buffer pool |
| Data Dictionary | Table metadata | ~1% of buffer pool |
LRU Eviction - Midpoint Insertion
Standard LRU has a problem: a full table scan evicts the entire cache. InnoDB uses midpoint insertion:
Algorithm:
- New pages inserted at midpoint (head of "old" sublist)
- If page accessed again within 1 second, promote to "young" sublist
- Evict from tail of "old" sublist
Benefit: Full table scan pages stay in "old" sublist and are quickly evicted, preserving hot pages in "young" sublist.
Dirty Page Flushing
Modified pages (dirty pages) must eventually be written to disk. InnoDB has multiple flushing mechanisms:
| Mechanism | Trigger | Urgency |
|---|---|---|
| Adaptive Flushing | Continuously flush to keep dirty page ratio low (e.g., <75%) | Low (background) |
| Checkpoint | Every N seconds (e.g., 60s), flush all dirty pages older than checkpoint age | Medium |
| LRU Flushing | When evicting a dirty page, flush it first | Medium |
| Forced Flushing | Dirty page ratio exceeds threshold (e.g., 90%) | High (can stall writes) |
RocksDB Block Cache
RocksDB uses a simpler block cache (similar to OS page cache):
- Uncompressed Block Cache: Stores decompressed data blocks (fast reads)
- Compressed Block Cache: Stores compressed blocks (save memory)
- Index & Filter Cache: Separate cache for index blocks and bloom filters
Eviction: Standard LRU with sharding (16-64 shards to reduce lock contention).
Cache Hit Rate & Performance
| Cache Hit Rate | Disk Reads/sec (100K queries/sec) | Avg Latency |
|---|---|---|
| 99% | 1,000 | ~1 ms |
| 95% | 5,000 | ~3 ms |
| 90% | 10,000 | ~5 ms |
| 80% | 20,000 | ~10 ms |
| 50% | 50,000 | ~30 ms |
Rule of Thumb: Every 1% increase in cache hit rate reduces latency by ~10%.
- Increasing buffer pool size
- Adding read replicas to distribute load
- Partitioning data to reduce working set
- Adding application-level caching (Redis, Memcached)
Buffer Pool Tuning
| Parameter | Default | Recommended | Impact |
|---|---|---|---|
| innodb_buffer_pool_size | 128 MB | 50-80% of RAM | Primary tuning knob |
| innodb_buffer_pool_instances | 1 (if <1GB) | 8-16 (reduce contention) | Improve concurrency |
| innodb_old_blocks_pct | 37% (3/8) | 37% (keep default) | Midpoint insertion ratio |
| innodb_old_blocks_time | 1000 ms | 1000-5000 ms | Delay before promoting to young |
| innodb_max_dirty_pages_pct | 90% | 75% (reduce write stalls) | Flush threshold |
Answer: Standard LRU has the "sequential scan problem": a full table scan reads millions of pages, evicting the entire cache. Midpoint insertion protects hot pages by inserting new pages in the "old" sublist. If they're accessed again (hot), promote to "young". If not (sequential scan), quickly evict. This keeps hot pages in cache despite large scans.
9. Compression
Compression reduces storage costs and can improve I/O throughput (fewer bytes to read/write). The tradeoff is CPU overhead.
Compression Levels
| Level | Description | Granularity |
|---|---|---|
| Page-Level | Compress individual B-Tree pages | 16 KB pages |
| Block-Level | Compress SSTable data blocks (LSM) | 4-64 KB blocks |
| Column-Level | Compress individual columns (columnar stores) | Per-column |
| Dictionary | Replace repeated values with short codes | Per-column or per-SSTable |
| Prefix | Store common prefixes once (keys in B-Tree) | Per-node |
Compression Algorithms
| Algorithm | Compression Ratio | Speed (MB/s) | CPU Cost | Use Case |
|---|---|---|---|---|
| LZ4 | 2-3x | Compress: 500 Decompress: 3000 |
Low | Default for RocksDB, fast decompression |
| Snappy | 2-3x | Compress: 300 Decompress: 1000 |
Low | Cassandra, Parquet, BigTable |
| Zstd | 3-5x | Compress: 200 Decompress: 800 |
Medium | Best ratio, Facebook default |
| Zlib (gzip) | 3-5x | Compress: 50 Decompress: 300 |
High | Legacy, avoid for hot path |
| LZMA | 5-10x | Compress: 10 Decompress: 100 |
Very High | Cold storage, archival |
InnoDB Transparent Page Compression
InnoDB compresses individual 16 KB pages:
- Compress page (e.g., 16 KB → 8 KB with zlib)
- Write compressed page to disk (saves 8 KB)
- On read, decompress page into buffer pool (16 KB in memory)
Tradeoff: 30-50% space savings, but 20-30% CPU overhead. Enable for cold tables only.
LSM-Tree Compression
RocksDB compresses entire data blocks (not individual keys):
100 key-value pairs] --> B[Compress with LZ4] B --> C[Compressed Block: 22 KB
3x compression] C --> D[Write to SSTable] E[Read Request] --> F[Read 22 KB from disk] F --> G[Decompress to 64 KB] G --> H[Binary search for key] style A fill:#ffd93d,color:#1a1a2e,stroke:#f5b800 style C fill:#6bcf7f,color:#1a1a2e,stroke:#37a850
Compression per Level: RocksDB allows different compression per level:
| Level | Compression | Reason |
|---|---|---|
| L0-L1 | None | Hot data, frequent compaction → skip CPU cost |
| L2-L4 | LZ4 | Warm data, balance CPU vs space |
| L5-L6 | Zstd | Cold data, maximize compression (infrequent access) |
Dictionary Compression (LSM)
For repeated strings (e.g., URLs, user agents), RocksDB can build a dictionary per SSTable:
- Training: Analyze sample keys/values, extract common substrings
- Encoding: Replace substrings with 2-byte codes
- Compression Boost: 5-10x for text-heavy data (vs 2-3x with LZ4 alone)
Prefix Compression (B-Tree)
B-Tree nodes store sorted keys. Consecutive keys often share prefixes:
user_12345
user_12346
user_12347"] --> B["After:
user_1234 (prefix)
+ 5
+ 6
+ 7"] style A fill:#ffd93d,color:#1a1a2e,stroke:#f5b800 style B fill:#6bcf7f,color:#1a1a2e,stroke:#37a850
Savings: Store "user_1234" once, then only store suffixes ("5", "6", "7"). For long keys (e.g., URLs), this saves 80-90% space.
Compression Impact on Performance
| Metric | No Compression | LZ4 | Zstd |
|---|---|---|---|
| Storage (100 GB dataset) | 100 GB | 35 GB (2.9x) | 25 GB (4x) |
| Write Throughput | 100K ops/sec | 120K ops/sec (+20%) | 90K ops/sec (-10%) |
| Read Throughput | 80K ops/sec | 100K ops/sec (+25%) | 70K ops/sec (-12%) |
| CPU Usage | 30% | 45% (+15%) | 60% (+30%) |
Answer: L0-L1 are frequently compacted (high CPU churn), so skip compression to save CPU. L5-L6 are cold data (rarely compacted, rarely read), so aggressive compression (Zstd) maximizes space savings with minimal performance impact. L2-L4 use LZ4 for balance.
10. Real Storage Engines
InnoDB (MySQL)
- Type: B+ Tree with clustered primary key
- Concurrency: MVCC (multi-version concurrency control), row-level locking
- Durability: WAL (redo log) + double-write buffer
- Strengths: ACID transactions, fast point lookups, efficient range scans
- Weaknesses: Write amplification (10-30x), page splits under heavy writes
- Use Case: General-purpose OLTP (e-commerce, SaaS apps)
RocksDB (Meta/Facebook)
- Type: LSM-Tree with leveled compaction
- Concurrency: Lock-free writes, reader-writer locks for compaction
- Durability: WAL + memtable snapshots
- Strengths: Extreme write throughput (500K ops/sec), tunable compaction, embeddable (C++ library)
- Weaknesses: Slower reads than B-Tree, compaction stalls
- Use Case: Write-heavy workloads (time-series, logging, stream processing)
- Used By: MySQL (MyRocks storage engine), CockroachDB, TiDB, Kafka Streams
WiredTiger (MongoDB)
- Type: B-Tree (default) or LSM-Tree (configurable)
- Concurrency: MVCC, document-level locking
- Durability: WAL (journal) + checkpoints
- Strengths: Hybrid approach, excellent compression (Zstd), fast snapshots
- Weaknesses: Complex tuning, checkpoints can cause latency spikes
- Use Case: MongoDB workloads (document store, mixed read/write)
LevelDB (Google)
- Type: LSM-Tree with leveled compaction
- Concurrency: Single-writer, multiple readers
- Durability: WAL
- Strengths: Simple API, fast writes, embeddable
- Weaknesses: Single-threaded writes, no replication, abandoned by Google (RocksDB is successor)
- Use Case: Embedded key-value store (Chrome IndexedDB, Bitcoin Core)
BadgerDB (Go)
- Type: LSM-Tree with value separation
- Concurrency: Multi-threaded writes via MVCC
- Durability: WAL (value log) + separate key log
- Strengths: Stores large values separately (reduces compaction overhead), written in Go (no CGO)
- Weaknesses: Immature compared to RocksDB, garbage collection overhead
- Use Case: Go applications needing embedded KV store (IPFS, Dgraph)
Comparison Table
| Engine | Type | Language | Write Throughput | Read Latency | Maturity |
|---|---|---|---|---|---|
| InnoDB | B-Tree | C++ | 50K ops/sec | 1-3 ms | 25+ years |
| RocksDB | LSM | C++ | 500K ops/sec | 3-10 ms | 10+ years |
| WiredTiger | B-Tree/LSM | C | 100K ops/sec | 2-5 ms | 10+ years |
| LevelDB | LSM | C++ | 100K ops/sec | 5-15 ms | 13+ years (unmaintained) |
| BadgerDB | LSM + Value Sep | Go | 200K ops/sec | 5-20 ms | 5+ years |
Pluggable Engines in Practice
MySQL Storage Engine Options:
- InnoDB: Default, ACID, row-level locking
- MyISAM: Table-level locking, fast reads, no transactions (deprecated)
- Memory: In-memory tables, fast but volatile
- MyRocks: RocksDB backend, 2-3x better compression, 5-10x write throughput
- TokuDB: Fractal tree, high compression (acquired by Percona, end-of-life)
Example: Facebook migrated MySQL from InnoDB to MyRocks (RocksDB), reducing storage by 50% and increasing write throughput by 3x.
11. Emerging Approaches
Learned Indexes (ML-Based)
Replace B-Tree nodes with machine learning models that predict the position of a key.
Idea: B-Tree is essentially a learned function: given key K, predict its position. Train a neural network to approximate this function.
Pros:
- Smaller index size (MB vs GB for B-Tree)
- Fewer cache misses (no pointer chasing)
- Theoretically faster lookups (O(1) vs O(log n))
Cons:
- Only works for read-only or append-only data (retraining cost)
- Requires predictable key distribution
- Complex implementation, unproven at scale
Status: Research stage (Google's 2018 paper), not production-ready.
Bw-Tree (Lock-Free B-Tree)
Microsoft's lock-free B-Tree for SQL Server Hekaton (in-memory OLTP).
Key Ideas:
- Delta Updates: Instead of modifying nodes in-place, append delta records
- Atomic CAS: Use compare-and-swap for lock-free updates
- Log-Structured: Periodically consolidate deltas into new base pages
Pros:
- High concurrency (no locks, no latches)
- Scales to 100+ cores
Cons:
- Complex implementation (1000s of lines vs B-Tree)
- Delta chains can cause read amplification
Status: Production in SQL Server Hekaton, not widely adopted elsewhere.
Fractal Trees (Toku Storage Engine)
A variant of B-Tree that buffers writes at internal nodes to reduce random I/O.
Key Ideas:
- Each internal node has a buffer (4-64 MB)
- Writes accumulate in buffers, flushed to children when full
- Converts random writes into sequential flushes
Pros:
- Better write throughput than B-Tree (10x for bulk inserts)
- Better compression than LSM (no duplicate keys across levels)
Cons:
- Complex implementation
- Slower point queries (must check buffers up the tree)
Status: TokuDB (Percona) reached end-of-life in 2021. Not widely adopted.
Append-Only B-Trees (CouchDB, Riak)
Instead of in-place updates, write new versions of pages (copy-on-write).
Pros:
- MVCC without row versioning
- Fast snapshots (root pointer = snapshot)
- Crash recovery via root pointer
Cons:
- High write amplification (entire root-to-leaf path rewritten)
- Requires compaction to reclaim space
Status: Used in CouchDB and Riak, niche use case.
Key-Value Separation (WiscKey, BadgerDB)
Store keys in LSM-Tree, but store large values in a separate log.
Pros:
- Reduces compaction overhead (don't rewrite large values)
- Lower write amplification for value-heavy workloads
Cons:
- Extra disk read for values (not in cache)
- Garbage collection complexity (value log grows unbounded)
Status: BadgerDB (production), WiscKey (research).
Chapter Summary
Key Takeaways
- B-Trees: Balanced tree, O(log n) reads/writes, in-place updates. Best for read-heavy OLTP.
- LSM-Trees: Append-only, sequential writes, background compaction. Best for write-heavy workloads.
- Write Amplification: 10-30x typical. Critical for SSD endurance.
- Compaction: Size-tiered (low WA, high space), leveled (low space, high WA), FIFO (minimal WA).
- WAL: Durability via sequential logging. Group commit for throughput.
- Caching: Buffer pool with midpoint insertion LRU. 99% cache hit rate is ideal.
- Compression: LZ4 for hot data (fast), Zstd for cold data (high ratio).
- Real Engines: InnoDB (B-Tree, OLTP), RocksDB (LSM, write-heavy), WiredTiger (hybrid).
Decision Matrix
| Workload | Recommended Engine | Rationale |
|---|---|---|
| OLTP (80%+ reads) | B-Tree (InnoDB, PostgreSQL) | Fast point lookups, efficient updates |
| Time-series, Logging (90%+ writes) | LSM (RocksDB) + FIFO compaction | Sequential writes, minimal WA |
| Mixed Read/Write | LSM (RocksDB) + Leveled compaction | Balance throughput and latency |
| Analytics (Range Scans) | B-Tree or Columnar (Parquet) | Sequential leaf scans or column pruning |
| Embedded KV Store | RocksDB (C++) or BadgerDB (Go) | Mature, high performance |
Interview Cheat Sheet
| Question | Answer |
|---|---|
| "B-Tree vs LSM?" | B-Tree: fast reads, in-place updates. LSM: fast writes, sequential I/O. Choose based on read/write ratio. |
| "Why WAL?" | Durability without expensive random writes. Sequential append + group commit = high throughput. |
| "Why compaction?" | Remove duplicate keys, apply tombstones, maintain sorted order. Tradeoff: write amp vs space amp vs read amp. |
| "Why bloom filters?" | Avoid disk reads for non-existent keys. 10 bits/key → 0.9% false positive → 99.1% disk read savings. |
| "Why LZ4 over Zstd?" | LZ4 decompresses at 3000 MB/s (faster than SSD), so hot data benefits. Zstd for cold data (better ratio). |
| "Why midpoint insertion LRU?" | Protect hot pages from sequential scans. New pages start in "old" sublist, promoted to "young" if re-accessed. |
Worked Examples: Storage Engine Math
B-Tree Page Split Cascade: Write Amplification Calculation
A 100-byte insert into a full 16KB page triggers a page split. Calculate the actual I/O cost:
| Operation | I/O Size | Explanation |
|---|---|---|
| 1. Read full page | 16KB read | Load page from disk to buffer pool |
| 2. Split page | 2 × 16KB write | Write left half (8KB data + padding) and right half (8KB data + padding) |
| 3. Update parent pointer | 16KB read + 16KB write | Parent internal node must add new pointer to right child |
| Total I/O | 1 read (16KB) + 3 writes (48KB) = 64KB | Write Amplification: 64KB / 100 bytes = 640x (worst case) |
Average Case Analysis:
- Fill Factor: B-Trees maintain 50-70% page occupancy on average
- Split Frequency: With 70% fill factor and 16KB pages storing ~115 rows (140 bytes each), a split occurs every ~115 inserts
- Amortized Cost: 16KB split cost / 115 inserts = 140 bytes per insert = 1.4x average write amplification
- WAL Overhead: Add ~200 bytes per insert for write-ahead log → total ~1.6x average write amp
LSM Write Amplification: Multi-Level Compaction
Calculate write amplification for leveled compaction (RocksDB default):
Leveled Compaction Write Path
| Stage | Action | Bytes Written | Write Amplification Factor |
|---|---|---|---|
| Level 0: Flush | Memtable (64MB) → L0 SSTable | 64MB | 1x (initial write) |
| L0 → L1 Compaction | Merge L0 (64MB) + L1 (640MB) → L1 (640MB) | 640MB written | 10x (read 704MB, write 640MB) |
| L1 → L2 Compaction | Merge L1 (10MB) + L2 (100MB) → L2 (100MB) | 100MB written | 10x per level |
| L2 → L3 Compaction | Merge L2 (10MB) + L3 (100MB) → L3 (100MB) | 100MB written | 10x per level |
| Total (6 levels) | Data rewritten at each level | ~10x per level × 5 compactions | 10-30x total write amplification |
Size-Tiered Compaction (Cassandra):
- Merge Strategy: Combine 4 SSTables of similar size → 1 larger SSTable
- Tiers: 10MB → 40MB → 160MB → 640MB (log4 growth)
- Write Amplification: Each SSTable compacted log4(N) times = ~5-10x (lower than leveled)
- Space Amplification: Up to 2x (multiple overlapping SSTables before compaction)
RocksDB Tuning Example: High Write Throughput
Production configuration for write-heavy workload (logging, time-series):
# RocksDB Configuration (C++ API or options file)
# Goal: Maximize write throughput, tolerate higher space/read amplification
# Memtable Configuration
write_buffer_size = 256MB # Larger memtable → fewer flushes
# Default: 64MB. Tradeoff: more memory, slower recovery
max_write_buffer_number = 4 # 4 memtables before write stall
# Total memory: 256MB × 4 = 1GB per column family
# L0 Compaction Tuning
level0_file_num_compaction_trigger = 8
# Start L0→L1 compaction when 8 SSTables in L0
# Higher value = more write throughput, worse reads
level0_slowdown_writes_trigger = 20
# Slow writes when L0 reaches 20 files
level0_stop_writes_trigger = 36
# Stop writes when L0 reaches 36 files (avoid this!)
# Compaction Threads
max_background_compactions = 4 # 4 parallel compaction threads
# Matches typical 4-core CPU, adjust based on hardware
max_background_flushes = 2 # 2 parallel memtable flush threads
# SSTable Configuration
target_file_size_base = 256MB # Larger SSTables → fewer files, less compaction overhead
# Default: 64MB. Tradeoff: slower individual compactions
max_bytes_for_level_base = 2GB # L1 size = 2GB (10x larger than default)
max_bytes_for_level_multiplier = 10
# Each level is 10x the previous (L2=20GB, L3=200GB, ...)
# Compression (reduces write amp via smaller files)
compression = "lz4" # Fast compression (10 GB/s encode)
# Alternatives: snappy (faster), zstd (better ratio)
bottommost_compression = "zstd" # Higher compression for oldest level (rarely read)
# Write Ahead Log (WAL)
wal_bytes_per_sync = 1MB # fsync WAL every 1MB (balance durability vs throughput)
# Default: 0 (fsync every write = slow)
# Bloom Filters (mitigate read amplification from high L0 files)
bloom_bits_per_key = 10 # 10 bits = 0.9% false positive rate
# Memory cost: 10 bits × 1M keys = 1.25MB per SSTable
Impact of Configuration
| Setting | Before (Default) | After (Tuned) | Impact |
|---|---|---|---|
| write_buffer_size | 64MB | 256MB | 4x fewer flushes → 4x fewer L0 compactions |
| target_file_size_base | 64MB | 256MB | 4x fewer SSTables → 4x fewer compaction jobs |
| Write Throughput | 50K ops/sec | 200K ops/sec | 4x improvement (reduced compaction overhead) |
| Memory Usage | 256MB (4 × 64MB) | 1GB (4 × 256MB) | 4x increase (acceptable for write-heavy workload) |
| Read Latency (p99) | 5ms | 12ms | 2.4x slower (more L0 files, tolerable for logging) |
Interview Callout: Demonstrating First-Principles Understanding
Interviewer asks: "Why are B-Trees read-optimized and LSM-Trees write-optimized?"
Strong answer (shows calculation from first principles):
"B-Trees do in-place updates: a 100-byte write modifies 1 page (16KB). Write amplification is page_size / write_size = 160x per page write, but most inserts don't cause splits, so amortized write amp is ~1.4x for inserts + 10-20x for WAL/page flushes = 15-30x total. Reads are O(log n) with single tree traversal = 3-4 disk seeks. LSM-Trees append to memtable (O(log n) in-memory), then batch-flush to L0. Every write is eventually rewritten through compaction: L0→L1 (10x), L1→L2 (10x), ... for 6 levels = 10-30x write amp. Reads must check multiple levels: memtable + L0 (10 files) + L1-L6 (1 file each with bloom filter) = up to 17 disk seeks worst case. Trade-off: LSM trades write amp for sequential I/O (500 MB/s) vs random I/O (50 MB/s) = 10x throughput gain. B-Tree trades higher write amp for single-lookup reads."
This demonstrates: (1) Quantitative reasoning, (2) Understanding of I/O characteristics, (3) Knowledge of real-world performance numbers, (4) Ability to articulate trade-offs.