B+ 树索引:从磁盘到内存
几乎每个数据库入门者都问过同一个问题:**为什么索引是 B+ 树而不是红黑树?**答案藏在磁盘的物理特性里。
核心约束:一次 I/O 读一个页
磁盘随机读的延迟(约 10ms 级)比内存访问(纳秒级)慢 6~7 个数量级。数据库把数据组织成页,一次磁盘 I/O 只能读入一页。因此,索引的目标从「比较次数少」变成了「树的高度矮」——每多一层,就多一次磁盘 I/O。
高度为 3 的 B+ 树,存储 10 亿条记录:
层0: 1 个根节点(页)
层1: ~1000 个内部节点(页)
层2: ~1000*1000 个叶子节点(页)
→ 任意点查询最多 3 次磁盘 I/O
为什么能做到这么矮?因为一个页可以容纳几百个键(假设键 8 字节 + 指针 8 字节,8KB 页能装约 500 个),B+ 树的扇出(fan-out)远超二叉树。
B+ 树 vs 其他结构
| 结构 | 树高(1e9 条) | 范围查询 | 写放大 | 用途 |
|---|---|---|---|---|
| 二叉搜索树/红黑树 | ~30 | 差 | 无 | 内存结构 |
| B 树 | 3~4 | 中 | 低 | 老系统(部分) |
| B+ 树 | 3~4 | 优 | 低 | 主流关系库 |
| LSM-Tree | 3~5 | 中 | 高 | 写密集系统 |
B+ 树的两个关键设计:
- 数据只在叶子:内部节点只存键与指针,扇出更大
- 叶子链表串联:范围查询从起点顺着链表扫,无需回溯
节点分裂:插入的代价
B+ 树插入时,节点满了就分裂成两个,并把中间键上提到父节点:
插入前 [10 20 30 40](满,容量 4)
插入 25 → 节点溢出
分裂为 [10 20] 和 [30 40 25]...
↓
父节点获得新键 30
分裂是 B+ 树唯一比较昂贵的操作,但分摊下来每个插入的代价仍是 O(log n)。
页缓存与预读
光有 B+ 树还不够,数据库用缓冲池让热节点留在内存:
- 根节点和上层内部节点几乎总是热的 → 实际 I/O 往往只有叶子层一次
- 顺序扫描时预读(Read Ahead)把相邻页批量载入 → 把随机 I/O 变顺序 I/O
💡 经验: 索引设计要「小而热」。宽索引(多列、长字符串)会降低扇出、抬高树高、撑大缓冲池占用——这是慢查询最常见的原因之一。
聚簇索引:数据就是叶子
MySQL InnoDB 的主键索引是聚簇索引:叶子节点直接存放整行数据。这意味着:
- 按主键查询:索引即数据,一次 I/O 拿到行
- 二级索引:叶子存放主键值,查询需要「回表」再走一次主键索引
聚簇索引叶子: [主键 | 完整行数据]
二级索引叶子: [索引列 | 主键值] → 回表
设计主键时优先选择单调递增的短主键:避免页分裂、缩小二级索引体积。
小结
B+ 树不是最聪明的数据结构,却是最懂磁盘的数据结构:用高扇出换树高,用叶子链表换范围查询,用缓冲池换热路径。理解了「页」这个单位,B+ 树的一切设计都顺理成章。