B+ 树是关系数据库索引的事实标准:MySQL InnoDB、PostgreSQL、SQLite 的主索引全是它。本文用一个 200 行的 Python 实现完整复现 B+ 树的插入、节点分裂、叶子链表范围查询,并用一次真实运行展示锁耦合(lock crabbing)并发协议如何把全路径锁优化成『最多持有两个节点闩锁』。

一、为什么是 B+ 树而不是 B 树或跳表

B+ 树相对 B 树的关键改动是:内部节点只存键不存数据,所有记录都挂在叶子层,且叶子间用链表串联。这带来两个工程优势:内部节点同样大小的页能容纳更多键(扇出更大、树更矮,磁盘 I/O 次数更少);范围查询只需定位起点后沿叶子链表顺序扫,完美匹配磁盘的顺序读特性。跳表(Redis 有序集合所用)实现简单且并发友好,但页局部性差——每个节点一次随机 I/O,在磁盘时代输在了起跑线上。LSM-Tree(本专栏另有专文)则以顺序写换取写吞吐,是另一个极端。

二、核心机制:插入、分裂与叶子链表

三、完整可运行实现(Python 3,约 200 行)

#!/usr/bin/env python3
"""B+Tree 模拟:插入/分裂、叶子链表范围查询、锁耦合并发协议"""

ORDER = 4   # 4 阶 B+树,每个节点最多 3 个键

class Node:
    def __init__(self, leaf=False):
        self.keys = []
        self.children = []   # 内部节点:子节点;叶子:值
        self.leaf = leaf
        self.next = None     # 叶子链表指针
        self.latch_count = 0 # 并发演示:当前持有的闩锁数

class BPlusTree:
    def __init__(self):
        self.root = Node(leaf=True)
        self.splits = 0

    def insert(self, key, val):
        path = []
        node = self.root
        while not node.leaf:
            path.append(node)
            i = 0
            while i < len(node.keys) and key >= node.keys[i]:
                i += 1
            node = node.children[i]
        i = 0
        while i < len(node.keys) and key > node.keys[i]:
            i += 1
        node.keys.insert(i, key)
        node.children.insert(i, val)
        if len(node.keys) >= ORDER:
            self._split_leaf(node, path)

    def _split_leaf(self, leaf, path):
        self.splits += 1
        mid = len(leaf.keys) // 2
        new_leaf = Node(leaf=True)
        new_leaf.keys = leaf.keys[mid:]
        new_leaf.children = leaf.children[mid:]
        leaf.keys = leaf.keys[:mid]
        leaf.children = leaf.children[:mid]
        new_leaf.next = leaf.next     # 维护叶子链表
        leaf.next = new_leaf
        up_key = new_leaf.keys[0]
        self._propagate(up_key, new_leaf, path)

    def _propagate(self, key, new_node, path):
        if not path:
            new_root = Node()
            new_root.keys = [key]
            new_root.children = [self.root, new_node]
            self.root = new_root
            return
        parent = path.pop()
        i = 0
        while i < len(parent.keys) and key > parent.keys[i]:
            i += 1
        parent.keys.insert(i, key)
        parent.children.insert(i + 1, new_node)
        if len(parent.keys) >= ORDER:
            self._split_internal(parent, path)

    def _split_internal(self, node, path):
        self.splits += 1
        mid = len(node.keys) // 2
        up_key = node.keys[mid]
        new_node = Node()
        new_node.keys = node.keys[mid + 1:]
        new_node.children = node.children[mid + 1:]
        node.keys = node.keys[:mid]
        node.children = node.children[:mid + 1]
        self._propagate(up_key, new_node, path)

    def range_query(self, lo, hi):
        """定位 lo 所在叶子,沿叶子链表扫到 hi"""
        node = self.root
        while not node.leaf:
            i = 0
            while i < len(node.keys) and lo >= node.keys[i]:
                i += 1
            node = node.children[i]
        out = []
        scanned = 0
        while node:
            for i, k in enumerate(node.keys):
                scanned += 1
                if lo <= k <= hi:
                    out.append(k)
                if k > hi:
                    return out, scanned
            node = node.next
        return out, scanned

    def height(self):
        h = 1
        n = self.root
        while not n.leaf:
            n = n.children[0]
            h += 1
        return h

print('=== B+Tree 模拟(4 阶) ===')
t = BPlusTree()
import random
random.seed(7)
keys = random.sample(range(1, 100), 20)
print('插入 20 个随机键:', keys[:8], '...')
for k in keys:
    t.insert(k, 'v%d' % k)
print('插入完成: 树高=%d, 根节点键=%s, 总分裂次数=%d'
      % (t.height(), t.root.keys, t.splits))

node = t.root
while not node.leaf:
    node = node.children[0]
chain = []
while node:
    chain.extend(node.keys)
    node = node.next
print('叶子链表(%d 键): %s' % (len(chain), chain))
print('链表全局有序: %s' % (chain == sorted(chain)))

res, scanned = t.range_query(30, 55)
print('范围查询 [30,55]: 命中 %s, 仅扫描 %d 个键(叶子链表顺序读)'
      % (res, scanned))

# ---------- 锁耦合(crabbing)演示 ----------
print()
print('--- 锁耦合(crabbing)查找 key=42 的加锁序列 ---')
t2 = BPlusTree()
for k in sorted(keys):
    t2.insert(k, 'v')

def crab_search(root, key):
    held = []
    node = root
    def latch(n, tag):
        n.latch_count += 1
        held.append(n)
        print('  加闩 %s' % tag)
    def unlatch(n, tag):
        n.latch_count -= 1
        held.remove(n)
        print('  释放 %s' % tag)
    latch(root, 'root(read)')
    while not node.leaf:
        i = 0
        while i < len(node.keys) and key >= node.keys[i]:
            i += 1
        child = node.children[i]
        latch(child, 'child(read)')          # 先加子节点
        if len(child.keys) < ORDER - 1:
            unlatch(node, 'parent(read)')    # 子节点安全即放父
        node = child
    print('  到达叶子, 持有闩锁数=%d(无需全路径持有)' % len(held))

crab_search(t2.root, 42)
print('说明: 父闩在确认子节点安全后立即释放——'
      '并发度远高于全路径锁')

四、真实运行输出与解读

以上代码在 Python 3 下原样运行(仅依赖 random),输出如下:

=== B+Tree 模拟(4 阶) ===
插入 20 个随机键: [42, 20, 51, 84, 7, 10, 69, 13] ...
插入完成: 树高=3, 根节点键=[20, 51], 总分裂次数=9
叶子链表(20 键): [5, 7, 8, 9, 10, 12, 13, 20, 28, 31, 42,
                 47, 51, 54, 56, 65, 69, 71, 75, 84]
链表全局有序: True
范围查询 [30,55]: 命中 [31, 42, 47, 51, 54], 仅扫描 8 个键(叶子链表顺序读)

--- 锁耦合(crabbing)查找 key=42 的加锁序列 ---
  加闩 root(read)
  加闩 child(read)
  释放 parent(read)
  加闩 child(read)
  释放 parent(read)
  到达叶子, 持有闩锁数=1(无需全路径持有)
说明: 父闩在确认子节点安全后立即释放——并发度远高于全路径锁

五、理论分析

5.1 闩锁与锁的区分

数据库内核严格区分 latch 与 lock:lock 保护事务逻辑一致性(行锁、表锁,由锁管理器维护,随事务持有),latch 保护物理数据结构(B+ 树节点、缓冲页,只持有关键区几微秒)。锁耦合操作的是 latch。混淆两者会导致设计错误:用事务锁保护树结构会让点查退化成串行。

5.2 乐观协议:OLC 与 Blink 树

锁耦合仍要每级取闩。更激进的方案有两支:Blink 树(Lehman-Yao 1981)给每层加右链与 upper-fence key,查找全程无闩,分裂中的节点通过右链绕行;乐观锁耦合 OLC(Masstree、ART 所用)给每个节点配版本号,读路径零闩,只在结构变更时校验版本重来。现代内存索引几乎全部采用 OLC 变体。

5.3 缓冲池的协同

B+ 树节点即缓冲池中的页,闩锁与缓冲池的 fix/unfix 机制绑定:fix 一页即持有其闩,unfix 即释放。页替换(LRU/Clock)只考虑未 fix 的页。这层耦合解释了为什么高并发下『缓冲池命中率』与『闩锁竞争』必须一起看——热点根页既是缓存热点也是闩锁热点。

六、工程应用

七、常见误区

八、参考文献

  1. D. Comer (1979). The Ubiquitous B-Tree. ACM Computing Surveys.(B 树家族的经典综述)
  2. P. Lehman, S. Yao (1981). Efficient Locking for Concurrent Operations on B-Trees. ACM TODS.(Blink 树原始论文)
  3. G. Graefe (2010). A Survey of B-Tree Locking Techniques. ACM TODS.(锁耦合到 OLC 的完整谱系)
  4. V. Leis et al. (2013). The Adaptive Radix Tree: ARTful Indexing for Main-Memory Databases. ICDE.(OLC 的现代应用)
  5. MySQL Documentation: InnoDB Row Formats and Page Structure 与 INNODB_METRICS 表说明。
← 返回文章列表