DSA DSA In Depth
0%
lesson b-treebplus-treestorage-engines

B-Trees & B+ Trees: Page Fanout, Proactive Splitting & Leaf Sibling Chains

7 min read Module 3.2: Self-Balancing, Augmented, Disk-Backed & Persistent Trees Updated Oct 23, 2026
📄 Cheat Sheet
B-Trees and B+ Trees — Disk Pages & Database Indexing
Abdul Bari B-Trees and B+ Trees — Disk Pages & Database Indexing Watch on YouTube ↗

Andy Pavlo explains why every relational database (PostgreSQL, InnoDB, SQLite) uses B+ Trees instead of original B-Trees, plus latch crabbing and page splits.

0:00 / --:--

CLRS Minimum Degree $t$ Invariants, Proactive Splitting & B-Tree vs B+ Tree

In CLRS notation, a B-Tree of minimum degree $t \ge 2$ (maximum branching factor $M = 2t$) satisfies:

  1. Every node except the root holds at least $t - 1$ keys ($t$ children); the root holds at least $1$ key ($2$ children) if non-empty.
  2. Every node holds at most $2t - 1$ keys ($2t$ children). A node with $2t - 1$ keys is full.
  3. All leaf nodes reside at the exact same depth $h$, bounded by $h \le \log_t \frac{N + 1}{2}$.

Proactive Top-Down Splitting (Single-Pass Insertion): In an on-disk B-Tree, if we waited until reaching a full leaf to split and propagate median keys back up, we would have to hold exclusive write locks (latches) on the entire root-to-leaf path or re-read ancestor pages from disk. Instead, CLRS splits every full node ($2t - 1$ keys) encountered on the way down into two nodes of $t - 1$ keys each, promoting the median key into the parent (which is guaranteed not to be full because we would have already split it on the step above!). This guarantees single-pass top-down insertion and enables latch crabbing (releasing parent locks as soon as the child is verified safe).

Feature / Operation Original B-Tree (Bayer-McCreight) B+ Tree (PostgreSQL / InnoDB / SQLite) CPU In-Memory B-Tree (rust BTreeMap)
Where Do Row Payloads Live? In BOTH internal and leaf nodes Exclusively in Leaf Nodes (Internal = routing keys) In all nodes ($B = 6$, max $11$ keys/node)
Internal Node Fanout ($16\text{ KB}$ Page) Low ($\approx 50\text{–}150$, bloated by payloads) High (≈ 500–1000 keys/page!) 12-way branching (fits 2–4 cache lines)
Point Lookup I/Os O(log_B N) O(log_B N) ($le 3 ext{–}4$ page reads; upper levels in RAM) O(log₂ N) cmps, O(log_B N) cache misses
Range Scan $[K_1, K_2]$ ($S$ items) Slow tree up/down traversal O(log_B N + S/B) Sequential Leaf Walk! Fast contiguous array slice iteration

B-Tree Split vs B+ Tree Split: Push-Up vs Copy-Up

When a full node with $2t - 1$ keys splits around its median key $k_{\text{mid}}$:

  • B-Tree Split (Push-Up Everywhere): Because every node stores actual data records, $k_{\text{mid}}$ is moved (pushed) up into the parent and removed from the child level, leaving two children of size $t - 1$.
  • B+ Tree Leaf Split (Copy-Up) vs Internal Split (Push-Up): In a B+ Tree, every key-value record must remain present in the leaf level so sequential scans work! Therefore, when a B+ Tree Leaf splits, the separator key $k_{\text{mid}}$ is copied up to the parent directory node while remaining in the right leaf! When a B+ Tree Internal Routing Node overflows, however, its median routing key is pushed up (not duplicated) just like a standard B-Tree.
B+ Tree Page Split, Leaf Sibling Scan & LSM-Tree SSTable Compaction
O(log_B N) I/Os vs LSM Sequential Writes

Trace how a full B+ Tree leaf page splits and copies its separator key up to the parent directory, or switch to LSM-Tree mode to inspect MemTable flushes, L0→L1 k-way merge compaction, and Bloom filter skips.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

Leaf P1 has 3/3 keys [10, 15, 20]. Inserting key = 25 triggers page overflow (4 > max 3).
🌳 Live 2D Hierarchical Tree Topology — B+ Tree Page Split, Leaf Sibling Scan & LSM-Tree SSTable Compaction Visited/Active Pivot/Root Resolved
root 30 [0] 10 [1] 15 [2] 20 [3] 30 [4] 40 [5] 50 [6]
Root Directory Page: [ 30 ]
Leaf P1 (FULL 3/3): [ 10 | 15 | 20 ]
→ Leaf P2: [ 30 | 40 | 50 ]
Tree Topology
1 Root + 2 Leaves
Disk Page I/O
2 Page Reads
Occupancy Invariant
⌈M/2⌉ ≤ keys ≤ M−1
[Step 1] Leaf P1 has 3/3 keys [10, 15, 20]. Inserting key = 25 triggers page overflow (4 > max 3).
Internal Directory Page (Pure Separator Keys + Child Page IDs → High Fanout B ≈ 1000): [ P₀ | K₁=20 | P₁ | K₂=50 | P₂ ] Leaf Pages (All Keys + Row Payloads/TIDs + Doubly-Linked Next/Prev Page Pointers): [ 5:v | 12:v | 18:v ] [ 20:v | 31:v | 44:v ] [ 50:v | 63:v | 89:v ] ╌╌ Leaf Sibling Chain: Range Query [12, 63] does 1 tree descent + sequential leaf scan!
flowchart TD Ins["insert(root, key)"] --> RootFull{"root.numKeys == 2t - 1 ?"} RootFull -- "Yes" --> NewRoot["s = new Node(leaf=false) s.children[0] = root splitChild(s, 0) insertNonFull(s, key)"] RootFull -- "No" --> NonFull["insertNonFull(root, key)"] NonFull --> Descend["Find child i for key If children[i] is full (2t-1 keys): splitChild(node, i) if key > node.keys[i]: i++ Recurse into children[i]"]

Hardware Reality: Why Rust's std::collections::BTreeMap Replaced Red-Black Trees in RAM

B-Trees are not just for disks! Standard C++ std::map allocates every single key in a separate 40-byte Red-Black Tree node on the heap, causing a $60\text{ ns}$ L3/DRAM cache miss on every comparison and wasting $70\%$ of memory on pointers. Rust's standard library (std::collections::BTreeMap) and Google's Abseil absl::btree_map use an in-memory B-Tree with $B = 6$ (up to $2B - 1 = 11$ keys per node). Scanning up to 11 contiguous keys inside a node sits in 1–2 CPU cache lines, runs at L1 speed with hardware prefetching and SIMD, reduces pointer overhead by $85\%$, and beats std::map by $2\times$ to $4\times$ on both lookups and iteration!

Implementation: In-Memory Cache-Aligned B-Tree Node Split (splitChild)

// C++20: Proactive B-Tree Node Split (CLRS Minimum Degree t, Max Keys = 2t - 1)
#include <vector>
#include <algorithm>

template <typename Key, int T = 4> // T=4 => max 7 keys, 8 children per cache-aligned node
struct BTreeNode {
    bool isLeaf = true;
    std::vector<Key> keys;
    std::vector<BTreeNode*> children;

    explicit BTreeNode(bool leaf) : isLeaf(leaf) {
        keys.reserve(2 * T - 1);
        if (!leaf) children.reserve(2 * T);
    }
};

// Splits full child y = parent->children[idx] (which has 2T - 1 keys) into two nodes of T - 1 keys
template <typename Key, int T>
void splitChild(BTreeNode<Key, T>* parent, int idx) {
    BTreeNode<Key, T>* y = parent->children[idx];
    auto* z = new BTreeNode<Key, T>(y->isLeaf);

    Key medianKey = y->keys[T - 1];
    // Move upper T - 1 keys [T .. 2T-2] from y to z
    z->keys.assign(y->keys.begin() + T, y->keys.end());
    y->keys.resize(T - 1);

    // If internal node, move upper T children [T .. 2T-1] from y to z
    if (!y->isLeaf) {
        z->children.assign(y->children.begin() + T, y->children.end());
        y->children.resize(T);
    }

    // Insert medianKey and new right sibling z into parent
    parent->keys.insert(parent->keys.begin() + idx, medianKey);
    parent->children.insert(parent->children.begin() + idx + 1, z);
}

Failure Modes & Adversarial Pitfalls

  • Random-Insertion Page Space Utilisation (~69% = $ln 2$) vs Right-Edge Sequential Inserts: Under random insertions, B+ Tree pages split into two $50\%$ full pages and average $\ln 2 \approx 69\%$ space utilization. If keys are inserted in strictly increasing order (e.g., AUTO_INCREMENT IDs) and pages split $50/50$, the left half is never touched again, wasting $50\%$ of disk space! Production engines (InnoDB, SQLite) detect right-edge monotonic inserts and perform fast $90/10$ or $100/0$ right-leaning splits to achieve $>95\%$ page packing.
  • Write Amplification on Small Random Updates: Updating a single 8-byte integer inside a $16\text{ KB}$ B+ Tree leaf page forces the storage engine to flush the entire $16\text{ KB}$ dirty page (plus WAL record) back to SSD—a $2000\times$ raw write amplification that motivates LSM-Trees!
Check yourself Question 1 of 4
Concept Check B-Tree vs B+ Tree Structural Difference recall

Database storage engines (PostgreSQL, MySQL InnoDB, SQLite table B-trees) use **B+ Trees** rather than classic Bayer-McCreight **B-Trees**. What are the two defining structural differences of a B+ Tree?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

How does a Lehman-Yao B-link tree allow the reader to recover without holding parent-to-child lock coupling?

Key Takeaways

B-Trees match node sizes to hardware storage units (4KB–16KB disk pages or 64B–256B CPU cache lines), increasing fanout B to 250–1000 so 10^9 keys require only 3–4 tree levels.
Proactive top-down splitting splits every full (2t - 1)-key node encountered on the way down, guaranteeing the parent always has room for a promoted median key in a single pass.
B+ Trees store row payloads exclusively in leaf pages and link leaves into a doubly-linked sibling chain, maximizing internal routing fanout and turning range queries into sequential scans.
When a B+ Tree leaf splits, the separator key is COPIED up to the parent (remaining in the right leaf); when an internal directory node splits, the median key is PUSHED up.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue