数据库内核设计: B+树实现以及并发策略

0 阅读11分钟

索引不是魔法—B+ Tree 凭什么让查询快了 10 万倍?

最近在学习 CMU 15-445《数据库系统导论》,这门课是卡内基梅隆大学的经典数据库课程,Andy Pavlo 讲得相当硬核。

学习过程中发现一个问题——网上几乎找不到对应这门课的中文讨论。

于是我决定:边学边写,把自己的理解记录下来。 不保证完全正确,但保证是我真正消化过的东西。如果你也在学这门课,或者只是对数据库底层感兴趣,希望这些文章能帮到你。

如果有错误,欢迎在评论区锤我 🔨


先想象一个没有索引的世界

你有一张 orders 表,500 万行数据。

SELECT * FROM orders WHERE user_id = 42;

没有索引,数据库怎么找?

从第一行扫到最后一行。 500 万行,每行都得过一遍。

还记得上篇讲的 Buffer Pool 吗?全表扫描会把 LRU List 里所有的热点 Page 全挤走——扫完之后这些 Page 再也不会被访问,留下一地狼藉。

这还只是"慢"的问题。更要命的是:500 万行分布在几十万个 Page 上,每个 Page 16KB,光是把数据装进 Buffer Pool 就得发起几十万次磁盘 I/O。

一次随机磁盘 I/O ≈ 10ms。你自己算算。

所以你需要索引。而几乎所有主流数据库的默认索引结构,都是 B+ Tree

为什么偏偏是它?不着急,我们从头推。


为什么不用别的?——一场残酷的淘汰赛

哈希表:快是快,但只能点查

哈希表的查找是 O(1),听起来完美。

hash(user_id=42) → bucket → 直接拿数据

但你一旦想做范围查询:

SELECT * FROM orders WHERE user_id BETWEEN 40 AND 50;

哈希表完全无能为力——哈希值之间没有任何顺序关系,你只能把 40 到 50 每个值都查一遍。

淘汰。

二叉搜索树(BST):有序,但太高了

BST 支持范围查询,查找是 O(log n),看起来不错。

问题在于:O(log n) 里的每一层,都是一次节点访问。节点在磁盘上,每次访问 = 一次 I/O。

500 万行数据,BST 树高大概是 23 层。也就是说,找一行数据最多要 23 次磁盘 I/O。

还没完。BST 可能退化成链表(比如你按顺序插入),树高直接变成 O(n)。

淘汰。

B Tree:好多了,但叶节点不连续

B Tree(注意:不是 B- Tree,就叫 B Tree)把 BST 的"每个节点一个 key"升级成"每个节点放 N 个 key",大大压缩了树高。

500 万行数据,B Tree 树高大概只有 3~4 层

但 B Tree 有个缺陷:数据既存在内部节点,也存在叶节点

做范围查询时,你必须在树里上上下下跳来跳去,才能按顺序访问所有满足条件的数据。

差一点,但还不够好。

B+ Tree:集齐所有优点

B+ Tree 相比 B Tree 做了两个关键修改:

  1. 内部节点只存 key,不存数据——每个节点能放更多 key,树更矮
  2. 所有数据都在叶节点,叶节点之间用双向链表相连——范围查询直接在链表上顺序扫,不用回到树里

btree_vs_bplus_tree.svg

这两个修改,把 B+ Tree 变成了数据库索引的"理想形态"。

结构点查范围查询树高(500万行)磁盘友好性
哈希表O(1)❌ 不支持
BSTO(log n)~23 层很差
B TreeO(log n)✅ 但低效3-4 层一般
B+ TreeO(log n)✅ 高效3-4 层极好

B+ Tree 长什么样?——解剖一棵真实的树

一棵 B+ Tree 由两种节点组成:

Internal Node(内部节点):只存 key,不存数据,负责导航

┌─────────────────────────────────┐
│  Internal Node  (order = 3)     │
│                                 │
│  P0 │ 10 │ P1 │ 30 │ P2        │
│  ↓        ↓        ↓           │
│ <10      10-30     ≥30          │
└─────────────────────────────────┘
P = 指向子节点的指针

Leaf Node(叶节点):存 key + data(或 RID),叶节点之间双向链表相连

┌──────────────────┐     ┌──────────────────┐     ┌──────────────────┐
│   Leaf Node      │ ←→  │   Leaf Node      │ ←→  │   Leaf Node      │
│                  │     │                  │     │                  │
│ 1→row1  3→row3   │     │ 10→row10 15→row15│     │ 30→row30 42→row42│
└──────────────────┘     └──────────────────┘     └──────────────────┘

一棵完整的树长这样:

                        [ 10 | 30 ]
                       /     |     \
              [1|3|5]   [10|15|20]   [30|35|42]
                ↕           ↕             ↕
              叶节点 ←→ 叶节点 ←→ 叶节点(双向链表)

几个关键参数:

参数含义典型值
Order (n)每个节点最多 n 个 key取决于 Page 大小
最少 key 数⌈n/2⌉ - 1(根节点除外)防止节点过稀
树高 h≈ log_n(数据量)InnoDB 通常 3-4 层

敲黑板:InnoDB 默认 Page 16KB,一个 Internal Node 大概能放 1200 个 key。这意味着三层的 B+ Tree 可以索引 1200 × 1200 × 叶节点数据量,覆盖几千万行完全没压力。这就是为什么树高能压到 3-4 层。


查找、插入、删除——三个操作,一套逻辑

查找:最简单,从根走到叶

Search(key=42):
  node = root
  while node is not leaf:
      i = findFirstKeyGreaterThan(node.keys, key)
      node = node.children[i]
  return node.find(key)

每走一层 = 一次 FetchPage(从 Buffer Pool 取一个 Page)。树高 4 层 = 最多 4 次 I/O。

对比全表扫描的几十万次,差距就是这么来的。


插入:找到位置,处理分裂

插入的难点在于:叶节点满了怎么办?

答:分裂(Split)。

func insert(tree *BTree, key int, value interface{}) {
    // Step 1: 找到应该插入的叶节点
    leaf := findLeaf(tree.Root, key)
    // Step 2: 插入到叶节点
    leaf.insert(key, value)
    // Step 3: 如果叶节点溢出,分裂
    if leaf.isOverflow() {
        splitLeaf(leaf)
    }
}

func splitLeaf(leaf *LeafNode) {
    // 把叶节点从中间一分为二
    mid := len(leaf.Keys) / 2
    newLeaf := &LeafNode{
        Keys:   append([]int{}, leaf.Keys[mid:]...),
        Values: append([]interface{}{}, leaf.Values[mid:]...),
    }
    leaf.Keys = leaf.Keys[:mid]
    leaf.Values = leaf.Values[:mid]

    // 维护双向链表
    newLeaf.Next = leaf.Next
    if leaf.Next != nil {
        leaf.Next.Prev = newLeaf
    }
    newLeaf.Prev = leaf
    leaf.Next = newLeaf

    // 把分裂点的 key 推给父节点
    pushUpToParent(leaf.Parent, newLeaf.Keys[0], newLeaf)

    // 父节点也可能溢出,递归处理
    if leaf.Parent.isOverflow() {
        splitInternal(leaf.Parent)
    }
}

一个完整的分裂示例:

插入 key=25,叶节点 [20|22|24] 已满(order=3):

分裂前:
         [ 10 | 30 ]
        /      |     \
  [1|3]   [20|22|24]   [30|35]

分裂后(24 推上去):
         [ 10 | 24 | 30 ]
        /    |      |    \
  [1|3] [20|22] [24|25] [30|35]

分裂可能向上传播,最极端的情况是根节点也分裂——这时树高 +1,这是 B+ Tree 唯一长高的方式。


删除:找到位置,处理合并或借用

删除比插入更烦一点,因为删了之后节点可能"太空"(key 数量低于 ⌈n/2⌉ - 1)。

func delete(tree *BTree, key int) {
    // Step 1: 找到叶节点并删除
    leaf := findLeaf(tree.Root, key)
    leaf.delete(key)
    // Step 2: 检查是否下溢
    if leaf.isUnderflow() {
        fixUnderflow(leaf)
    }
}

func fixUnderflow(node *Node) {
    sibling := getSibling(node)
    // 情况 A:兄弟节点有富余,借一个 key
    if sibling.canLend() {
        redistribute(node, sibling)
    // 情况 B:兄弟节点也快空了,合并
    } else {
        merge(node, sibling)
        // 合并会删掉父节点里的一个 key,可能触发父节点下溢
        if node.Parent.isUnderflow() {
            fixUnderflow(node.Parent)
        }
    }
}
情况操作触发条件
兄弟节点有富余Redistribute(借 key)兄弟 key 数 > ⌈n/2⌉ - 1
兄弟节点也快空Merge(合并节点)兄弟 key 数 = ⌈n/2⌉ - 1
根节点只剩 1 个 key 且子树合并树高 -1根节点变空

和 Buffer Pool 怎么配合?——每走一层都是一次 FetchPage

现在把 B+ Tree 和 Buffer Pool 放在一起看:

查询 user_id = 42
       │
       ▼
FetchPage(root_page_id)        ← 第 1I/O(通常根节点常驻 Buffer Pool)
       │ 找到下一层 page_id
       ▼
FetchPage(internal_page_id)    ← 第 2I/O
       │ 找到叶节点 page_id
       ▼
FetchPage(leaf_page_id)        ← 第 3I/O
       │ 在叶节点内找到 row
       ▼
UnpinPage × 3                  ← 用完释放,别忘了!

有一个重要的优化:根节点几乎永远被 pin 在 Buffer Pool 里

根节点是所有查询的入口,访问频率极高。数据库启动时会把根节点 pin 住(pin_count 一直 > 0),这样第 1 次 I/O 永远命中缓存,实际树遍历只需要 2-3 次真正的磁盘 I/O。

这就是为什么哪怕数据库有几百 GB 数据,单点查询依然可以做到 1ms 以内。


并发怎么办?——Latch Crabbing,螃蟹走路法

多个线程同时读写 B+ Tree,会出什么问题?

想象线程 A 正在分裂一个节点,线程 B 同时在这个节点上查找——B 可能读到一半分裂的节点,数据完全乱掉。

最简单的解法:给整棵树加一把大锁。

这能用吗?当然不能。 整棵树一把锁,并发性等于零。

正确的方案是 Latch Crabbing(螃蟹加锁法):从根节点往下走,像螃蟹一样,拿到子节点的 latch 之后,才放开父节点的 latch

读操作(共享锁 R-Latch):
  lock(root, R)
  lock(child, R)
  unlock(root)          ← 子节点拿到后立刻释放父节点
  lock(grandchild, R)
  unlock(child)
  ...直到叶节点

写操作(排他锁 W-Latch):
  lock(root, W)
  lock(child, W)
  if child is "safe":   ← safe = 插入时未满 / 删除时不会下溢
      unlock(root)      ← 确认不会影响父节点,才释放父节点
  lock(grandchild, W)
  if grandchild is "safe":
      unlock all ancestors
  ...

"Safe" 节点的判断

操作Safe 条件
插入节点 key 数 < 最大值(不会分裂)
删除节点 key 数 > ⌈n/2⌉ - 1(不会合并/借用)

一个优化:乐观锁(Optimistic Locking)

写操作大多数情况下不会触发分裂或合并(毕竟分裂/合并是小概率事件)。所以可以先用读锁一路走到叶节点,到叶节点再换写锁;如果真的需要分裂,再回头用写锁重新走一遍。

这在高并发写场景下,能大幅减少写锁持有时间,提升吞吐量。


InnoDB 怎么用 B+ Tree?——聚簇索引 vs 二级索引

理论讲完,来看 InnoDB 的实际实现。InnoDB 里有两种 B+ Tree:

聚簇索引(Clustered Index)

叶节点直接存完整的行数据。

聚簇索引(主键 id)的叶节点:

┌─────────────────────────────────────────┐
│  key=42  │  整行数据(所有列的值)         │
│  key=43  │  整行数据                      │
│  key=44  │  整行数据                      │
└─────────────────────────────────────────┘

每张 InnoDB 表有且只有一棵聚簇索引树,数据本身就按主键顺序存在树的叶节点里。这就是为什么 InnoDB 必须有主键——没有主键,数据没法组织。

二级索引(Secondary Index)

叶节点存的不是完整行,而是 key + 主键值。

-- 假设你在 user_id 上建了索引
CREATE INDEX idx_user_id ON orders(user_id);
二级索引(user_id)的叶节点:

┌─────────────────────────────┐
│  user_id=42  │  主键 id=100  │
│  user_id=42  │  主键 id=205  │
│  user_id=43  │  主键 id=88   │
└─────────────────────────────┘

用二级索引查数据,完整流程是:

SELECT * FROM orders WHERE user_id = 42;

Step 1: 在二级索引树上找 user_id=42
        → 拿到主键 id = [100, 205]

Step 2: 拿着主键去聚簇索引树上查完整数据(回表)
        → 两次 B+ Tree 遍历

这就是"回表"(Index Lookup / Double Lookup)。

索引类型叶节点存什么查询流程何时不需要回表
聚簇索引完整行数据一次 B+ Tree 遍历
二级索引key + 主键二级索引 → 回表查聚簇索引覆盖索引(索引覆盖所有查询列)

覆盖索引(Covering Index):如果你查的列全都在二级索引里,就不用回表了。

-- 覆盖索引:user_id 和 status 都在索引里,不需要回表
SELECT user_id, status FROM orders WHERE user_id = 42;
-- 前提是建了 INDEX (user_id, status)

这也是为什么面试官问"如何优化这条 SQL",有时候答案就是"加一个覆盖索引"——少一次 B+ Tree 遍历,对高频查询影响很大。


总结:一张表收尾

概念一句话关键细节
B+ Tree数据库索引的标准结构内部节点只存 key,叶节点存数据,叶节点双向链表
树高决定查询的 I/O 次数InnoDB 通常 3-4 层,可索引千万行
Split(分裂)节点满时向上传播唯一使树长高的方式
Merge/Redistribute节点空时向上传播优先借,不能借再合并
Latch Crabbing并发安全的遍历方式拿到子节点 latch 再释放父节点
聚簇索引叶节点存完整行每表只有一棵,数据按主键顺序存储
二级索引叶节点存 key + 主键查完整行需要回表
覆盖索引索引包含所有查询列避免回表,高频查询的优化利器

B+ Tree 是数据库里"把磁盘 I/O 控制到极致"这个目标下生长出来的结构——矮、宽、叶节点连续,每一个设计决策背后都是对磁盘特性的精准利用。

但 B+ Tree 只解决了"怎么快速找到数据"的问题。下一个问题是:多个事务同时读写,怎么保证数据的一致性? 这就是 MVCC 和事务隔离级别的战场了。

如果说 B+ Tree 是数据库的骨架,那 MVCC 就是它的神经系统——我们下篇见。


🔖 系列导航


觉得有收获的话点个赞,让更多人看到 🙏