Advanced Database Internals: LSM-Trees, B-Trees, and Storage Engine Tradeoffs

Meta Description: Master advanced database internals. Compare B-Trees and LSM-Trees, explore write amplification, SSTables, WAL, and storage engine tradeoffs.

┌────────────────────────────────────────────────────────────────────────┐

│                      B-TREE VS. LSM-TREE STORAGE                       │

│                                                                        │

│   B-TREE (In-Place Updates – Read-Optimized)                           │

│   ┌────────────────────────────────────────────────────────────────┐   │

│   │ Dynamic Page Tree (e.g., 8KB Pages)                            │   │

│   │ [ Root Node ] ──> [ Internal Node ] ──> [ Leaf Page (Overwritten) ]│   │

│   └────────────────────────────────────────────────────────────────┘   │

│                                                                        │

│   LSM-TREE (Append-Only Sequential Writes – Write-Optimized)           │

│   ┌────────────────────────────────────────────────────────────────┐   │

│   │ RAM:   [ MemTable (SkipList) ]  ──>  [ Write-Ahead Log (WAL) ]   │

│   │ DISK:  [ SSTable L0 ] ──> [ SSTable L1 ] ──> [ Compaction ]      │   │

│   └────────────────────────────────────────────────────────────────┘   │

└────────────────────────────────────────────────────────────────────────┘

At the core of every relational or NoSQL database sits a crucial component: the Storage Engine. The storage engine manages how data structures are mapped between memory (RAM) and non-volatile storage media (SSDs/NVMe disks).

When designing high-throughput data systems, engineers often evaluate databases based on query languages or feature sets. However, long-term system performance—including read latency, write throughput, storage footprint, and hardware lifespan—is determined by the storage engine’s underlying data structure.

The modern storage engine landscape is divided into two primary design models: B-Trees (and their $B^+$-Tree variants), which favor in-place updates optimized for fast reads, and Log-Structured Merge-Trees (LSM-Trees), which leverage append-only sequential writes optimized for massive write ingest.

💡 Key Takeaways

  • In-Place vs. Append-Only: B-Trees update fixed-size disk pages in place, while LSM-Trees buffer mutations in memory and write immutable sorted files sequentially.
  • Hardware I/O Dynamics: LSM-Trees maximize SSD throughput by converting random write workloads into efficient sequential disk writes.
  • The RUM Conjecture: Storage engine designs trade off Read Overhead, Update Overhead, and Memory Overhead—you cannot optimize all three simultaneously.
  • Compaction Mitigates Space Amplification: LSM-Trees rely on background merging (compaction) to remove duplicate, stale, or deleted records from disk.

B-Trees: The Benchmark for Read-Heavy Workloads

Invented in 1970, the B-Tree (and the predominant $B^+$-Tree) remains the standard storage architecture for general-purpose relational database systems like MySQL (InnoDB), PostgreSQL, and SQLite.

┌────────────────────────────────────────────────────────────────────────┐

│                        B+ TREE NODE LAYOUT                             │

│                                                                        │

│                       [ Root Node: Keys 50, 100 ]                      │

│                              ╱     │     ╲                             │

│                             ╱      │      ╲                            │

│           [ Key < 50 ] ────┘       │       └─── [ Key >= 100 ]         │

│                                    ▼                                   │

│                        [ Key 50 <= K < 100 ]                           │

│                        [ Leaf Page: 8KB ]                              │

│                        ┌──────────────────┐                            │

│                        │ K:52 | V: “Alice”│                            │

│                        │ K:78 | V: “Bob”  ├─ Pointer ─> [ Next Leaf ]  │

│                        └──────────────────┘                            │

└────────────────────────────────────────────────────────────────────────┘

B-Tree Design Principles

  1. Fixed-Size Pages: B-Trees divide databases into fixed-size blocks (typically 4KB to 16KB pages). These pages map directly to hardware disk blocks.
  2. Balanced Tree Structure: A $B^+$-Tree guarantees that all leaf pages sit at equal depth, delivering deterministic logarithmic lookup complexity $O(\log N)$.
  3. In-Place Updates: When a record is updated or inserted, the storage engine locates the target 8KB leaf page on disk and overwrites its contents in place.

The Problem: Random I/O and Write Amplification

In-place updates introduce significant performance costs for write-heavy workloads:

  • Random Disk I/O: Modifying small records scattered across different pages forces random I/O operations, which causes latency spikes on storage drives.
  • Write Amplification Factor (WAF): Updating a single 10-byte field requires rewriting the entire 8KB page back to disk. This causes a high Write Amplification Factor ($\text{WAF} = \frac{\text{Bytes Written to Disk}}{\text{Bytes Requested by Application}}$), which degrades write performance and shortens SSD lifespans over time.

Log-Structured Merge-Trees (LSM-Trees): Built for Write Scale

To handle high-throughput write workloads, storage engines like RocksDB, LevelDB, Apache Cassandra, and ScyllaDB use Log-Structured Merge-Trees (LSM-Trees).

┌────────────────────────────────────────────────────────────────────────┐

│                         LSM-TREE WRITE PATHWAY                         │

│                                                                        │

│   Write Request                                                        │

│        │                                                               │

│        ├───( Sequential Append )───> [ Write-Ahead Log (WAL) ]         │

│        │                                                               │

│        └───( Sorted In-Memory )────> [ MemTable (SkipList) ]           │

│                                                │                       │

│                                       ( Flushes when full )            │

│                                                ▼                       │

│   Disk Storage                        [ SSTable Level 0 ]              │

│                                       [ Sorted Immutable File ]        │

│                                                │                       │

│                                       ( Background Compaction )        │

│                                                ▼                       │

│                                       [ SSTable Level 1 ]              │

└────────────────────────────────────────────────────────────────────────┘

LSM-Tree Write Workflow

  1. Write-Ahead Log (WAL): When a write arrives, it is appended to an on-disk Write-Ahead Log (WAL) sequentially. This guarantees durability in the event of power loss or process crashes.
  2. MemTable: Simultaneously, the record is inserted into an in-memory sorted structure called the MemTable (typically implemented as a SkipList or Red-Black Tree). This memory write runs with $O(\log N)$ latency.
  3. SSTable Flushing: When the MemTable fills up (e.g., reaches 64MB), it converts into an immutable structure and flushes sequentially to disk as a Sorted String Table (SSTable).
  4. SSTable Compaction: Because SSTables are immutable, updating a key simply writes a new entry to a new SSTable (or writes a “tombstone” marker for deletions). Background compaction processes merge overlapping SSTables to prune duplicate versions and re-establish sorted key ranges.

Hands-On Implementation: Building an In-Memory MemTable and SSTable Flush Engine in Python

Let’s implement a core component of an LSM-Tree storage engine: a sorted MemTable that flushes to disk as an immutable SSTable file with a sparse memory index.

Python

import os

import json

from typing import Dict, Optional, List

class SSTable:

    “””Represents an immutable Sorted String Table (SSTable) file on disk.”””

    def __init__(self, filepath: str, sparse_index: Dict[str, int]):

        self.filepath = filepath

        self.sparse_index = sparse_index  # Maps sample keys to byte offsets for fast seek

    def get(self, key: str) -> Optional[str]:

        “””Performs index-assisted sparse search to locate a key on disk.”””

        if not os.path.exists(self.filepath):

            return None

        # Determine binary file offset range using sparse index

        keys = list(self.sparse_index.keys())

        target_offset = 0

        for k in keys:

            if k <= key:

                target_offset = self.sparse_index[k]

            else:

                break

        # Seek directly into disk file to minimize I/O overhead

        with open(self.filepath, ‘r’) as f:

            f.seek(target_offset)

            for line in f:

                if not line.strip():

                    continue

                record = json.loads(line)

                if record[‘key’] == key:

                    return record[‘val’]

                if record[‘key’] > key:

                    break # Sorted order guarantees key does not exist past this point

        return None

class LSMMemTable:

    “””In-memory buffer (MemTable) that flushes sorted key-value pairs to disk as SSTables.”””

    def __init__(self, capacity_threshold: int = 3):

        self.capacity_threshold = capacity_threshold

        self.buffer: Dict[str, str] = {}

        self.sstables: List[SSTable] = []

        self.flush_count = 0

    def put(self, key: str, val: str):

        “””Inserts a key-value pair into the sorted MemTable, flushing if capacity is reached.”””

        self.buffer[key] = val

        print(f”[MEMTABLE INSERT] Key: ‘{key}’ -> Val: ‘{val}'”)

        if len(self.buffer) >= self.capacity_threshold:

            self._flush_to_sstable()

    def _flush_to_sstable(self):

        “””Flushes in-memory contents to disk in sorted order as an immutable SSTable.”””

        self.flush_count += 1

        filepath = f”sstable_level0_{self.flush_count}.db”

        print(f”\n[FLUSHING MEMTABLE] Capacity reached. Writing immutable ‘{filepath}’ to disk…”)

        # Sort keys to maintain strict SSTable ordering invariants

        sorted_keys = sorted(self.buffer.keys())

        sparse_index: Dict[str, int] = {}

        with open(filepath, ‘w’) as f:

            for idx, key in enumerate(sorted_keys):

                byte_offset = f.tell()

                # Create sparse index entry every 2 keys

                if idx % 2 == 0:

                    sparse_index[key] = byte_offset

                payload = json.dumps({“key”: key, “val”: self.buffer[key]})

                f.write(payload + “\n”)

        self.sstables.insert(0, SSTable(filepath, sparse_index))

        self.buffer.clear()

        print(f”[FLUSH COMPLETE] Created sparse index: {sparse_index}\n”)

    def get(self, key: str) -> Optional[str]:

        “””Reads key across storage layers (MemTable -> Recent SSTables -> Older SSTables).”””

        # 1. Search in-memory MemTable first (Zero Latency)

        if key in self.buffer:

            print(f”[READ HIT] Found ‘{key}’ inside in-memory MemTable.”)

            return self.buffer[key]

        # 2. Search SSTables sequentially from newest to oldest

        for index, sstable in enumerate(self.sstables):

            val = sstable.get(key)

            if val is not None:

                print(f”[READ HIT] Found ‘{key}’ inside SSTable Level 0 (File #{len(self.sstables) – index}).”)

                return val

        print(f”[READ MISS] Key ‘{key}’ not found across any storage engine layer.”)

        return None

if __name__ == “__main__”:

    engine = LSMMemTable(capacity_threshold=3)

    # Write operations trigger automated MemTable flushes

    engine.put(“user_101”, “Alice”)

    engine.put(“user_102”, “Bob”)

    engine.put(“user_103”, “Charlie”) # Triggers Flush #1

    engine.put(“user_104”, “David”)

    engine.put(“user_105”, “Eve”)

    engine.put(“user_101”, “Alice_Updated”) # Overwrites key in new SSTable; triggers Flush #2

    # Query key across multi-layered storage engine

    print(“\n— QUERYING STORAGE ENGINE —“)

    res = engine.get(“user_101”)

    print(f”Final Query Result for ‘user_101’: {res}”)

    # Clean up generated SSTable files

    for ss in engine.sstables:

        if os.path.exists(ss.filepath):

            os.remove(ss.filepath)

Analyzing Storage Engine Tradeoffs: The RUM Conjecture

When picking or tuning a storage engine, the RUM Conjecture helps visualize fundamental trade-offs:

┌────────────────────────────────────────────────────────────────────────┐

│                          THE RUM CONJECTURE                            │

│                                                                        │

│                            Read Overhead                               │

│                                 /\                                     │

│                                /  \                                    │

│                               /    \                                   │

│                              /  *   \                                  │

│                             /  B-Tree\                                 │

│                            /          \                                │

│                           /     *      \                               │

│                          /   LSM-Tree   \                              │

│                         /________________\                             │

│       Update Overhead                      Memory / Space Overhead     │

└────────────────────────────────────────────────────────────────────────┘

The RUM Conjecture states that a storage engine can optimize for two of three overhead costs at most, leaving the third degraded:

  1. Read Overhead (RO): Time required to retrieve records.
  2. Update Overhead (UO): Time and writes required to insert, modify, or delete records.
  3. Memory / Space Overhead (MO): Disk footprint and RAM required to store indices and data.

B-Tree vs. LSM-Tree Metric Comparison

Engine MetricB-Tree Engine (MySQL InnoDB)LSM-Tree Engine (RocksDB / Cassandra)
Primary Write ModeIn-place random writesAppend-only sequential writes
Write ThroughputLower (Constrained by page random I/O)Extremely High (Sequential I/O)
Point Read LatencyExtremely Fast ($O(\log N)$ single lookup)Slower (May search multiple SSTables)
Write AmplificationHigh (Rewrites full 8KB/16KB pages)Low to Medium (Optimized via compaction)
Space AmplificationLow (Minimal obsolete versions retained)Higher (Requires extra disk for compaction)
Bloom Filter NeedLowEssential (Skips unnecessary SSTable disk reads)

Frequently Asked Questions (FAQ)

What role do Bloom Filters play in LSM-Tree storage engines?

Because an LSM-Tree spreads keys across multiple immutable SSTable files, checking for a non-existent key could force disk reads across every SSTable layer. Storage engines attach an in-memory Bloom Filter to each SSTable. This probabilistic data structure tells the engine instantly if a key is definitely not present in an SSTable, skipping unnecessary disk I/O operations.

What is the difference between Size-Tiered and Leveled Compaction?

  • Size-Tiered Compaction: Merges SSTables of similar sizes once a layer accumulates a threshold number of files. This strategy minimizes write amplification, making it ideal for heavy write workloads.
  • Leveled Compaction: Organizes SSTables into discrete size levels ($L_1, L_2, \dots$) where key ranges within a level do not overlap. This approach reduces space amplification and speeds up read queries at the cost of higher write amplification during background compaction.

Why do B-Trees still dominate traditional relational databases?

Relational databases rely on transactional guarantees (ACID), point queries, range scans, and multi-column indexing. B-Trees offer predictable point reads, efficient range scans (by traversing leaf-node pointers directly), and simple concurrency control algorithms (page locking) that fit standard OLTP applications well.

Conclusion & Action Steps

Understanding database internals helps engineers choose the right storage engine for their application workloads. Choose B-Tree engines when your system demands low read latencies and predictable transactional consistency. Choose LSM-Tree engines when your workloads process heavy write streams, event logs, or time-series data.

Next Steps for Systems Architects:

  1. Profile your database workload to measure its Read-to-Write ratio.
  2. Benchmark your database’s Write Amplification Factor (WAF) under sustained write loads.
  3. Experiment with embedded engines like RocksDB or SQLite to evaluate how parameter changes affect memory use, write speed, and compaction performance.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *