B+ 树:从数据页说起
从 InnoDB 存储引擎的角度,用可视化方式理解 B+ 树如何用 16KB 的数据页 组织百万、千万甚至数十亿行数据。文章聚焦四个核心问题:数据页长什么样、B+ 树如何组织这些页、一次查询要读多少页、以及 B+ 树到底能有多高。
阅读前提:你不需要读过 MySQL 源码,知道「磁盘 IO 很慢」「索引能加速查询」就够了。本文将用图示代替公式,用交互代替推导。
1. 一切从「数据页」开始
InnoDB 并不按「行」来管理数据,而是按 数据页(Data Page) 来管理。数据页是 InnoDB 与磁盘交互的最小单位——每次从磁盘读取数据,至少读一整页;每次将数据写回磁盘,至少写一整页。
1.1 数据页的内部结构
一个 16KB 的页并不是全部用来存数据行的,它有严格的内部分区:
图 1:InnoDB 数据页(16KB)内部结构
关键点:
- 38 字节开销:File Header + File Trailer 用于校验完整性,确保页在写入磁盘时没有损坏。
- Page Directory(槽位数组):这是页内加速的关键。它不是指向每一行,而是将行分组(通常 4~8 行一组),每组一个槽位,指向该组的「最大行」。页内查找时先二分槽位,再在组内顺序扫描。
- Free Space:新插入的行从这里分配空间。当 Free Space 不足以容纳新行时,触发页分裂。
- User Records 是单向链表:行按主键(聚簇索引)或索引键(二级索引)排序,通过链表串联。但请注意——这个链表只在 同一个页内 有效。
这就是「数据页」的本质:它是一个 16KB 的容器,内部有结构化的头部信息、按主键排序的行链表、一个用于加速页内查找的槽位目录、以及剩余的空闲空间。InnoDB 中 一切 数据结构最终都落在这个 16KB 的单元上。
2. 数据页如何组成 B+ 树
一个数据页最多存几百行(视行宽而定),但要管理千万行,就需要 很多很多页。B+ 树的任务就是把这些页组织成一棵高效的多路搜索树。
2.1 B+ 树的两种「页」
| 页类型 | 存储内容 | 数量级 | 作用 |
| 叶子页(Leaf Page) |
完整的数据行(聚簇索引) 或索引键 + 主键值(二级索引) |
> 99.9% |
真正存数据的地方,页之间通过双向链表连接 |
| 内节点页(Internal Page) |
键值 + 子页指针 |
≪ 1% |
仅用于导航,告诉引擎「要找的键在哪个子页」 |
一张图看清楚:
图 2:B+ 树的逻辑结构 —— 内节点导航,叶子节点存数据,叶子之间双向链表连接
核心洞察:B+ 树的每一层在磁盘上都是 16KB 的页。根节点是一个 16KB 页,每个内节点也是一个 16KB 页,每个叶子节点还是 16KB 页。InnoDB 不想「树」的概念,它只知道页号。所谓「查找」就是:读一个页,从页里找到下一个页号,再读那个页,直到找到叶子页里的行。
2.2 一个页里能存多少「导航信息」?
内节点页不存整行数据,只存 键值 + 子页指针。InnoDB 中每个这样的条目约 14 字节(4 字节页号 + 键值大小),一个 16KB 页可以存约 1200 个 这样的导航条目。
也就是说,每个内节点页最多有约 1200 个孩子。这就是 B+ 树「多路」的由来——不像二叉树的 2 路,B+ 树每层可以分出上千个分支。
3. 一次查询的全过程
假设有张表 users(id INT PRIMARY KEY, name VARCHAR),要查 SELECT * FROM users WHERE id = 117。InnoDB 是这样工作的:
步骤 1/4
三个步骤,对应三次磁盘 IO:
- 从根页开始:读根页 → 在页内二分查找定位子页指针 → 得到下一个页号。
- 进入内节点页:读内节点页 → 同样的页内二分 → 得到叶子页号。
- 到达叶子页:读叶子页 → 页内二分槽位 + 组内顺序扫描 → 命中目标行。
为什么不是每层都读磁盘?InnoDB 的 Buffer Pool(缓冲池)会缓存热页。如果根页和内节点已经被缓存,实际磁盘 IO 可能只有 1 次(只读叶子页)。但最坏情况下,确实是一次 IO 一层。
4. 聚簇索引 vs 二级索引 —— 两棵 B+ 树
一张 InnoDB 表通常至少有两棵 B+ 树:一棵是 聚簇索引(Clustered Index),一棵是 二级索引(Secondary Index)。
图 3:一条表两棵 B+ 树 —— 聚簇索引叶子存整行,二级索引叶子只存键+主键
| 维度 | 聚簇索引 | 二级索引 |
| 建表时 | 自动以主键创建 | 手动 CREATE INDEX |
| 叶子存什么 | 完整行数据 | 索引键 + 主键值 |
| 主键查找 | 一次 B+ 树遍历即可 | — |
| 非主键查找 | — | 遍历二级索引 B+ 树 → 拿到主键 → 回表 查聚簇索引 |
| 每张表有几棵 | 恰好 1 棵 | 0 到多棵 |
回表(Bookmark Lookup):二级索引只存了键 + 主键,如果查询需要的列不在二级索引中,就必须拿着主键再去聚簇索引查一次。这就是「回表」。这也是为什么 SELECT * 往往比 SELECT id, name(覆盖索引)慢的原因——多读了一棵树的叶子页。
5. 页大小、树高与数据量
这是最核心的「工程数学」:知道页大小和每行的数据量,就能精确推算 B+ 树能装多少数据,以及树有多高。
5.1 计算模型
B+ 树高度的计算公式非常简单:
总叶子页数 = ⌈ 总行数 / (每页能存的行数) ⌉
B+ 树高度 ≈ logfanout(总叶子页数)+ 1
其中 fanout(扇出) = 每个内节点页能容纳的子指针数 ≈ ⌊ 16KB / (指针+键大小) ⌋
5.2 交互式推算器
调整下面参数,实时看看不同场景下 B+ 树的高度变化:
5.3 经典场景速查表
| 行大小 | 100 万行 | 1000 万行 | 1 亿行 | 10 亿行 |
| 100 B(小型) | 树高 3 ~6,250 叶子页 | 树高 3 ~62,500 叶子页 | 树高 4 ~625,000 叶子页 | 树高 4 ~6,250,000 页 |
| 200 B(中型) | 树高 3 ~12,500 叶子页 | 树高 3 ~125,000 叶子页 | 树高 4 ~1,250,000 页 | 树高 4 ~12,500,000 页 |
| 500 B(大型) | 树高 3 ~33,333 叶子页 | 树高 3 ~333,333 叶子页 | 树高 4 ~3,333,333 页 | 树高 5 ~33,333,333 页 |
| 1 KB(文本) | 树高 3 ~66,666 叶子页 | 树高 3 ~666,666 叶子页 | 树高 4 ~6,666,666 页 | 树高 5 ~66,666,666 页 |
为什么 B+ 树这么矮?核心就在于 扇出(fanout)极大。一个 16KB 内节点页能容纳约 1200 个子指针。这意味着:
— 1 个根页 → 指向 1200 个内节点
— 1200 个内节点 → 指向 1200 × 1200 ≈ 144 万 个叶子页
— 树高 3 已经能覆盖 144 万 × 几百行/页 = 数亿行 数据。
在实际工程中,绝大多数 OLTP 场景的 B+ 树高度在 3~4 层。这也意味着一次主键查询只需要 3~4 次磁盘 IO,如果 Buffer Pool 命中,这个数字会更低。
5.4 为什么 UUID 作主键更「高」?
把上面的键大小从 INT(4 字节)换成 UUID(16 字节),同一个内节点页能塞的指针数从 ~1200 降到 ~800。这意味着:
- 扇出变小:每层能覆盖的叶子页减少。
- 树更高:相同数据量下,可能需要多一层。
- IO 更多:每次主键查询多看一层 = 多一次可能的磁盘 IO。
- 内存占用更大:内节点页数量更多,占用更多 Buffer Pool。
所以推荐的 MySQL 实践是:尽量使用自增 BIGINT 作为主键,而不是 UUID。
6. 页的大小为什么是 16KB?
很多人问到这个问题,答案非常工程化:
太小(如 4KB)
- 每个页能存的行数少 → 叶子页数量暴增
- B+ 树层数可能从 3 层变成 4-5 层
- 页头开销占比高(38/4096 = 0.9%)
- 管理页的元数据开销增大
太大(如 64KB)
- 写一页到磁盘的时间变长
- Buffer Pool 中缓存粒度变大 → 浪费内存
- 更新小行却要写整页 → 写放大
- 页内记录越多,页内搜索越慢
- OS 页大小通常是 4KB,大页可能跨多个 OS 页
16KB 是一个「黄金平衡点」——对大多数工作负载来说,既能保持 B+ 树高度在 3-4 层,又不会带来过大的写放大和内存浪费。InnoDB 还支持 8KB、32KB、64KB 的页大小,但极少需要调整。
7. 页的加载:InnoDB Buffer Pool
最后回答一个常见疑问:
「我有一张 100GB 的表,MySQL 启动时要把 100GB 都加载到内存吗?」
不需要。
InnoDB 使用 Buffer Pool 管理内存中的页:
图 4:Buffer Pool 按需加载页,LRU 策略淘汰冷页
- 按需加载:只有当查询需要某个页时,才从磁盘加载到 Buffer Pool。
- LRU 淘汰:Buffer Pool 满了之后,最久未被访问的冷页被淘汰出内存,为新的页腾出空间。
- 根页和内节点几乎永驻:因为它们被访问的频率极高,几乎不会被 LRU 淘汰。
- 热数据的叶子页也留在内存:这就是为什么「热数据」查询比「冷数据」快。
innodb_buffer_pool_size 通常是 MySQL 调优的第一个参数。一般建议设置为物理内存的 60%~80%。Buffer Pool 越大,能缓存的页就越多,磁盘 IO 就越少——查询自然越快。
8. 总结
回到最初的问题,用一句话回答每个:
| 问题 | 答案 |
| 数据页是什么? | 16KB 的容器,有 File Header / Page Header / User Records / Page Directory / Free Space / File Trailer 六个区域。InnoDB 的最小 IO 单位。 |
| B+ 树如何组织页? | 内节点页存【键 + 子页指针】,叶子页存数据行。所有叶子的页号通过双向链表串联。 |
| 一次查询读多少页? | 等于 B+ 树的高度(最坏情况)。每层一页。通常 3~4 页(3~4 次 IO)。Buffer Pool 命中时更少。 |
| B+ 树能有多高? | 绝大多数 OLTP 场景 3~4 层。10 亿行也只要 4~5 层。多路扇出是 B+ 树「矮」的根本原因。 |
| 16KB 为什么合适? | 太小则树变高、IO 增多;太大则写放大、内存浪费。16KB 是数十年来工程实践的收敛值。 |
| 100GB 的表要全加载吗? | 不需要。Buffer Pool 按需加载热页,LRU 淘汰冷页。内存永远只存热数据的一个子集。 |
本文所有图示和计算基于 InnoDB 存储引擎的默认配置(16KB 页大小,COMPACT 行格式)。不同 MySQL 版本、不同配置可能略有差异,但核心原理不变。