B-Trees & B+ Trees: High-Fanout On-Disk Indexing and Page Architectures

In-memory self-balancing search trees (like AVL or Red-Black trees) maintain a binary branching factor of 2. For massive on-disk datasets spanning hundreds of gigabytes, a binary tree with millions of elements reaches a depth of 25 to 30 levels, requiring dozens of random disk I/O operations per point lookup.

Invented by Rudolf Bayer and Edward M. McCreight in 1970, the **B-Tree**—and its dominant variant, the **B+ Tree**—is a self-balancing, multi-way search tree optimized specifically for hardware block storage and page-oriented file systems. By packing hundreds or thousands of keys into fixed-size disk blocks (pages), B+ Trees achieve massive branching fanout, reducing tree height to typically 3 to 4 levels for billions of rows.

Structural Differences: B-Tree vs. B+ Tree

While both structures maintain sorted keys and balanced leaf depths, their internal anatomy differs fundamentally:

1. Standard B-Tree (Payloads in All Nodes)

  • Data Storage: Key-value data payloads (or tuple record pointers) are stored inside both internal routing nodes and leaf nodes.
  • Lookup Characteristics: If a queried key matches an entry in the root or an upper-level internal node, the search terminates immediately without descending to the leaves.
  • Trade-off: Because large values consume space in upper-level index pages, the branching fanout decreases significantly, increasing overall tree height.

2. Modern B+ Tree (Leaf-Only Data & Linked Leaf Chains)

  • Data Storage: Internal nodes store strictly routing keys and child page pointers (no record payloads). All actual data payloads (or row pointers) reside exclusively in leaf nodes.
  • Contiguous Leaf Chain: All leaf pages are interconnected via bidirectional horizontal pointers (`prev_leaf` and `next_leaf`), forming a continuous doubly-linked list.
  • High Fanout: Internal nodes fit many more keys per page, maximizing fanout (often 100 to 1,000+ children per node) and keeping tree height low ($h \le 4$).
  • Range Scan Performance: Range queries (`WHERE id BETWEEN 10 AND 500`) execute by descending to the first leaf node in $O(\log N)$ time, then scanning horizontally along leaf pointers with sequential page reads without revisiting upper-level index nodes.

On-Disk Page Layouts: The Slotted Page Architecture

Database engines manage storage in fixed-size blocks (typically 4 KB, 8 KB, or 16 KB) called **Pages**. Because row entries within a page can vary in size (due to variable-length types like `VARCHAR` or `TEXT`), engines organize page bytes using the **Slotted Page** layout:

  1. Page Header: Fixed-size metadata at the beginning of the page storing the LSN, transaction visibility flags, free space boundaries, and total slot count.
  2. Slot Array (Line Pointers): Grows downward from the header. Each slot is a small fixed-size integer array storing the exact byte offset and length of a tuple within that page.
  3. Tuple Storage Area: Grows upward from the bottom of the page, storing raw variable-length byte payloads.
  4. Free Space Gap: Located dynamically between the end of the slot array and the top of the tuple data, avoiding page defragmentation on record modifications.

Concurrency Control: Latch Crabbing (Coupling)

Multiple concurrent threads reading and writing to an on-disk B+ Tree must prevent race conditions and structural corruption during splits and merges without holding a global index lock. Systems use **Latch Crabbing (Coupling)**:

Read Traversal (Search)

Acquire shared (S) latch on parent -> Acquire S latch on child -> Release S latch on parent. Descend level by level until reaching the leaf.

Write Traversal (Insert / Delete)

  1. Acquire exclusive (X) latch on root.
  2. Descend to child and acquire X latch on child.
  3. Check if child is **safe** (an insert is safe if the child is not full; a delete is safe if the child is above minimum occupancy).
  4. If child is safe, release all X latches held on ancestor nodes (the crab moves forward).
  5. If child is unsafe (may trigger a split or merge), retain ancestor latches until modifications propagate upward.

C++ Conceptual Simulation Blueprint (B+ Tree Leaf & Internal Routing)

#include <iostream>
#include <vector>
#include <algorithm>

constexpr int ORDER = 4; // Max keys per node

struct BPlusNode {
    bool isLeaf;
    std::vector<int> keys;
    std::vector<BPlusNode*> children; // Pointers to children (internal) or null
    BPlusNode* nextLeaf = nullptr;    // Horizontal linked list pointer (leaves only)

    BPlusNode(bool leaf) : isLeaf(leaf) {}
};

class BPlusTreeIndex {
private:
    BPlusNode* root;

public:
    BPlusTreeIndex() {
        root = new BPlusNode(true);
    }

    bool search(int key) const {
        BPlusNode* curr = root;
        // 1. Descend through internal routing nodes
        while (!curr->isLeaf) {
            auto it = std::upper_bound(curr->keys.begin(), curr->keys.end(), key);
            int idx = std::distance(curr->keys.begin(), it);
            curr = curr->children[idx];
        }

        // 2. Binary search within leaf node
        return std::binary_search(curr->keys.begin(), curr->keys.end(), key);
    }

    std::vector<int> rangeScan(int startKey, int endKey) const {
        std::vector<int> results;
        BPlusNode* curr = root;

        // 1. Navigate to the first leaf
        while (!curr->isLeaf) {
            auto it = std::upper_bound(curr->keys.begin(), curr->keys.end(), startKey);
            int idx = std::distance(curr->keys.begin(), it);
            curr = curr->children[idx];
        }

        // 2. Scan horizontally along linked leaf pages
        while (curr) {
            for (int k : curr->keys) {
                if (k >= startKey && k <= endKey) {
                    results.push_back(k);
                } else if (k > endKey) {
                    return results;
                }
            }
            curr = curr->nextLeaf;
        }
        return results;
    }
};

Real-World Relational Engines & Indexing Systems

  1. MySQL InnoDB: Stores all table rows inside a primary clustered index structured as an on-disk B+ Tree (16 KB page size), where secondary indexes store secondary keys pointing to primary keys.
  2. PostgreSQL B-Tree (nbtree): Implements Lehman-Yao B+ Tree variants with high-concurrency right-sibling pointers, allowing concurrent lookups during concurrent page splits without locking parent nodes.
  3. SQLite Database Engine: Manages both table data (B+ Tree with 64-bit integer rowids) and secondary indexes (B-Tree mapping index keys to rowids) across database page files.
  4. Operating System File Systems (ext4, NTFS, APFS, XFS): Indexing file extents, inode allocations, and directory hierarchies using high-fanout B+ Tree disk structures.