B+ 树深度解析
一、什么是 B+ 树
B+ 树是一种多路平衡查找树,是 B 树的变体。所有数据都存储在叶子节点,内部节点只存索引(键值),叶子节点通过链表串联。
核心思想 :降低树高,减少磁盘 I/O 次数。一棵 3 层 B+ 树(每页 1000 个键)可索引 10 亿 条记录。
二、B+ 树 vs B 树
| 特性 | B 树 | B+ 树 |
|---|---|---|
| 数据存储位置 | 所有节点都存数据 | 只有叶子节点存数据 |
| 叶子节点链表 | 无 | 有(双向链表) |
| 查找效率 | 不稳定(可能在非叶节点命中) | 稳定(必须到叶子节点) |
| 范围查询 | 需要中序遍历 | 顺着链表直接扫描 |
| 内部节点空间 | 存数据,索引少 | 只存索引,能容纳更多键 |
B+ 树结构(3 阶示例):
[30 | 70] ← 内部节点(只存索引)
/ | \
[10,20] [30,50] [70,80,90] ← 叶子节点(存数据)
↔ ↔ ↔ ← 双向链表连接
三、B+ 树核心参数
| 参数 | MySQL InnoDB 默认值 | 说明 |
|---|---|---|
| 页大小 | 16 KB | 磁盘读写的基本单位 |
| 阶数(m) | ~1200 | 非叶节点最多 m 个子节点 |
| 键数量 | 非叶节点 m-1 个键 | 叶子节点存实际数据 |
| 最小度数(t) | t ≥ 2 | 每个节点至少 t-1 个键 |
树高与容量的关系(假设每个键+指针占 12 字节):
一页 16KB = 16384 字节
非叶节点可存: 16384 / 12 ≈ 1365 个键
叶子节点(存数据,假设每条 1KB): 16 条
层数 可索引记录数
1层: 16
2层: 1365 × 16 ≈ 2 万
3层: 1365 × 1365 × 16 ≈ 3000 万
4层: 1365³ × 16 ≈ 400 亿
MySQL 为什么用 B+ 树? 3~4 层即可索引海量数据,每次查找只需 3~4 次磁盘 I/O。
四、核心操作
4.1 查找
查找 key = 50:
1. 根节点 [30 | 70]: 50 ∈ [30, 70) → 走中间指针
2. 中间节点 [30 | 50]: 50 = 50 → 走右边
3. 叶子节点 [40, 50, 60]: 找到 50 ✓
等值查找: O(log_m N),m 为阶数
4.2 范围查询
查询范围 [30, 70):
1. 先查找 30 → 定位到叶子节点
2. 沿链表向右扫描: 30 → 40 → 50 → 60 → 停(70 不含)
3. 只需一次树查找 + 顺序扫描
这就是 B+ 树的优势:范围查询极快
4.3 插入
规则:节点满了(> m-1 个键)就分裂
插入 25(假设 3 阶 B+ 树,每个节点最多 2 个键):
1. 找到叶子节点 [20, 30],插入 25 → [20, 25, 30] 溢出!
2. 分裂: [20] | [25, 30],把 25 上提到父节点
3. 父节点加入 25 → 可能继续分裂(级联)
分裂过程:
Before: [20 | 30]
Insert 25: [20 | 25 | 30] (溢出)
Split: [20] ←25→ [25 | 30]
4.4 删除
规则:节点太空(< ⌈m/2⌉ - 1 个键)就合并或借位
删除 25:
1. 找到叶子节点 [25, 30],删除 25 → [30]
2. 检查是否下溢: 如果只有一个键,满足最小度数则结束
3. 不满足: 尝试从兄弟借位,或与兄弟合并
借位:
兄弟 [15, 20] 借 20 → [20] | [30]
合并:
兄弟也无力借位 → 合并为 [20, 30],删除父节点中的分隔键
五、MySQL InnoDB 中的 B+ 树
5.1 聚簇索引(主键索引)
叶子节点存储完整行数据
[10 | 20 | 30] ← 非叶节点(主键索引)
/ | | \
[1,行数据] [10,行数据] [20,行数据] [30,行数据] ← 叶子节点
聚簇索引就是数据本身,一张表只有一个。
5.2 二级索引(非主键索引)
叶子节点存储主键值
['a' | 'm'] ← 非叶节点(name 索引)
/ \
['a'→5, 'b'→10] ['m'→3, 'z'→20] ← 叶子节点存主键
回表查询: 通过二级索引找到主键 → 再用主键查聚簇索引获取完整行。
5.3 覆盖索引
sql
-- 覆盖索引:查询的列全在索引中,无需回表
SELECT name, id FROM user WHERE name = 'Alice';
-- name 索引的叶子已经有 id,不需要回表
-- 非覆盖:需要回表
SELECT name, age FROM user WHERE name = 'Alice';
-- age 不在 name 索引中,需要回表查聚簇索引
5.4 页分裂与页合并
页分裂(插入导致):
Page1: [1,2,3,4,5] → 插入 3.5 溢出
分裂为:
Page1: [1,2,3] Page2: [3.5,4,5]
性能影响: 涉及数据拷贝,产生碎片
页合并(删除导致):
Page1: [1,2] Page2: [5] → Page2 太空
合并为: Page1: [1,2,5]
建议: 使用自增主键避免频繁页分裂
六、B+ 树 vs 其他索引结构
| 结构 | 查找 | 范围查询 | 插入 | 磁盘友好 | 适用场景 |
|---|---|---|---|---|---|
| B+ 树 | O(log N) | 优秀 | O(log N) | 极好 | 关系型数据库 |
| Hash 索引 | O(1) | 不支持 | O(1) | 差 | 等值查询(Memory 引擎) |
| 红黑树 | O(log N) | 中序遍历 | O(log N) | 差 | 内存数据结构 |
| LSM 树 | O(log N) | 需合并 | O(1) | 好 | 写密集(RocksDB) |
| 跳表 | O(log N) | 支持 | O(log N) | 差 | 内存索引(Redis ZSet) |
七、Java 中 B+ 树的应用
虽然 Java 标准库没有直接暴露 B+ 树实现,但以下场景涉及 B+ 树思想:
7.1 数据库索引
java
// JDBC 操作,底层走 B+ 树索引
// 建立索引 → MySQL 创建 B+ 树
String sql = "CREATE INDEX idx_user_name ON user(name)";
// 范围查询走 B+ 树叶子链表扫描
String query = "SELECT * FROM user WHERE age BETWEEN 20 AND 30";
// EXPLAIN 查看是否命中索引
String explain = "EXPLAIN SELECT * FROM user WHERE name = 'Alice'";
// type = ref 表示走了索引
7.2 文件系统
NTFS、Ext4 等文件系统使用 B+ 树变体管理目录和文件映射。
文件系统 B+ 树:
内部节点: 目录名 → 子目录/文件的磁盘位置
叶子节点: 文件数据块的磁盘位置列表
八、面试高频题
Q1: 为什么 MySQL 用 B+ 树而不是 B 树?
难度:⭐⭐⭐⭐
答案:三个原因:(1) B+ 树内部节点不存数据,同样页大小能存更多索引键,树更矮,I/O 更少;(2) 叶子节点链表连接,范围查询只需顺序扫描;(3) 查询性能稳定,每次都要到叶子节点,路径长度一致。
Q2: 为什么不用红黑树做数据库索引?
难度:⭐⭐⭐⭐
答案:红黑树是二叉树,树高 = O(log₂ N)。百万数据约 20 层,即 20 次磁盘 I/O。B+ 树是多路树,百万数据只需 3 层(3 次 I/O)。磁盘 I/O 是瓶颈,减少 I/O 次数比减少内存比较次数重要得多。
Q3: 为什么推荐自增主键?
难度:⭐⭐⭐
答案:自增主键保证插入顺序递增,新数据总是追加在 B+ 树最右侧,避免页分裂。随机主键(如 UUID)会导致频繁页分裂,产生大量随机 I/O 和碎片。
Q4: B+ 树的页大小为什么是 16KB?
难度:⭐⭐⭐⭐
答案:操作系统的页大小通常为 4KB,InnoDB 选择 16KB(4 个 OS 页)是为了减少磁盘 I/O 次数。16KB 在顺序读写和随机读写之间取得平衡,既能容纳足够多的索引键降低树高,又不会太大导致浪费。
Q5: 什么是回表?如何避免?
难度:⭐⭐⭐
答案:二级索引的叶子节点只存主键值,查询非索引列需要用主键再查一次聚簇索引,称为回表(两次 B+ 树查找)。避免方法:使用覆盖索引(查询列都在索引中)、联合索引、或直接查主键。