索引不是魔法—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 做了两个关键修改:
- 内部节点只存 key,不存数据——每个节点能放更多 key,树更矮
- 所有数据都在叶节点,叶节点之间用双向链表相连——范围查询直接在链表上顺序扫,不用回到树里
这两个修改,把 B+ Tree 变成了数据库索引的"理想形态"。
| 结构 | 点查 | 范围查询 | 树高(500万行) | 磁盘友好性 |
|---|---|---|---|---|
| 哈希表 | O(1) | ❌ 不支持 | — | 差 |
| BST | O(log n) | ✅ | ~23 层 | 很差 |
| B Tree | O(log n) | ✅ 但低效 | 3-4 层 | 一般 |
| B+ Tree | O(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) ← 第 1 次 I/O(通常根节点常驻 Buffer Pool)
│ 找到下一层 page_id
▼
FetchPage(internal_page_id) ← 第 2 次 I/O
│ 找到叶节点 page_id
▼
FetchPage(leaf_page_id) ← 第 3 次 I/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 就是它的神经系统——我们下篇见。
🔖 系列导航
- ① 数据库的基本单位:一切从 Page 开始
- ② 数据库内核设计: Buffer Pool的实现与并发设计
- ③ 本篇:数据库内核设计: B+树实现以及保证线程安全
觉得有收获的话点个赞,让更多人看到 🙏