B+ 树:从数据页说起

从 InnoDB 存储引擎的角度,用可视化方式理解 B+ 树如何用 16KB 的数据页 组织百万、千万甚至数十亿行数据。文章聚焦四个核心问题:数据页长什么样、B+ 树如何组织这些页、一次查询要读多少页、以及 B+ 树到底能有多高。

阅读前提:你不需要读过 MySQL 源码,知道「磁盘 IO 很慢」「索引能加速查询」就够了。本文将用图示代替公式,用交互代替推导。

1. 一切从「数据页」开始

InnoDB 并不按「行」来管理数据,而是按 数据页(Data Page) 来管理。数据页是 InnoDB 与磁盘交互的最小单位——每次从磁盘读取数据,至少读一整页;每次将数据写回磁盘,至少写一整页。

16 KB
页大小(默认)
≈ 2 ~ 200+
每页能存行数
16,384
每页字节数
38 字节
页头+页尾开销

1.1 数据页的内部结构

一个 16KB 的页并不是全部用来存数据行的,它有严格的内部分区:

InnoDB 数据页结构 16KB InnoDB data page internal layout showing File Header, Page Header, Infimum/Supremum, User Records, Free Space, Page Directory, and File Trailer File Header(38 字节)— 页类型、页号、校验和、LSN Page Header(56 字节)— 槽数量、记录数、空闲空间指针 Infimum + Supremum(26 字节) Page Directory(槽位数组) User Records(用户记录) Row 1 | 1 | Alice | alice@example.com | 2026-01-01 ... Row 2 | 2 | Bob | bob@example.com | 2026-01-02 ... Row 3 | 3 | Carol | carol@example.com | 2026-01-03 ... 主键排序的单向链表,组内二分查找定位行 Free Space(空闲空间)— 尚未使用的字节 File Trailer(8 字节)— 校验和 + LSN 低 4 字节(与 File Header 呼应) 0 字节 ~16 KB
图 1:InnoDB 数据页(16KB)内部结构

关键点:

这就是「数据页」的本质:它是一个 16KB 的容器,内部有结构化的头部信息、按主键排序的行链表、一个用于加速页内查找的槽位目录、以及剩余的空闲空间。InnoDB 中 一切 数据结构最终都落在这个 16KB 的单元上。

2. 数据页如何组成 B+ 树

一个数据页最多存几百行(视行宽而定),但要管理千万行,就需要 很多很多页。B+ 树的任务就是把这些页组织成一棵高效的多路搜索树。

2.1 B+ 树的两种「页」

页类型存储内容数量级作用
叶子页(Leaf Page) 完整的数据行(聚簇索引)
或索引键 + 主键值(二级索引)
> 99.9% 真正存数据的地方,页之间通过双向链表连接
内节点页(Internal Page) 键值 + 子页指针 ≪ 1% 仅用于导航,告诉引擎「要找的键在哪个子页」

一张图看清楚:

B+ 树整体结构 3-level B+ tree showing root internal page, internal node pages, and leaf pages with a bidirectional linked list 根节点(Root Page) [ 15 | 30 | 45 ] — 键值 + 子页指针 内节点(Internal) [ 5 | 10 ] 页 内节点(Internal) [ 20 | 25 ] 页 内节点(Internal) [ 35 | 40 ] 页 叶子页(Leaf Pages)— 所有页通过双向链表连接,支持范围查询 ← Page 4 ↔ Page 5 ↔ Page 6 ↔ Page 7 ↔ Page 8 ↔ Page 9 ... → 这就是一张 B+ 树(聚簇索引) 红色层 = 存数据的叶子;蓝色层 = 存导航信息的内节点;紫色 = 根 关键洞察:内节点页和叶子页在物理上是同一种「数据页」 它们只是页内存储的内容不同,页结构本身完全一样(都是 16KB,都有 File Header)
图 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:

  1. 从根页开始:读根页 → 在页内二分查找定位子页指针 → 得到下一个页号。
  2. 进入内节点页:读内节点页 → 同样的页内二分 → 得到叶子页号。
  3. 到达叶子页:读叶子页 → 页内二分槽位 + 组内顺序扫描 → 命中目标行。
为什么不是每层都读磁盘?InnoDB 的 Buffer Pool(缓冲池)会缓存热页。如果根页和内节点已经被缓存,实际磁盘 IO 可能只有 1 次(只读叶子页)。但最坏情况下,确实是一次 IO 一层。

4. 聚簇索引 vs 二级索引 —— 两棵 B+ 树

一张 InnoDB 表通常至少有两棵 B+ 树:一棵是 聚簇索引(Clustered Index),一棵是 二级索引(Secondary Index)

聚簇索引与二级索引对比 Comparison of clustered index (leaf stores full row) vs secondary index (leaf stores index key + PK, then lookup back to clustered) 聚簇索引(主键 B+ 树) 内节点:只存主键值 + 子页指针 叶子页:存储 完整的行数据(id, name, email, created_at ...) 二级索引(如 name 列索引) 内节点:存 name 值 + 子页指针 叶子页:存 name 值 + 对应的主键 id 拿到主键后 → 回表 → 再到聚簇索引里查完整行
图 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。这意味着:

所以推荐的 MySQL 实践是:尽量使用自增 BIGINT 作为主键,而不是 UUID。

6. 页的大小为什么是 16KB?

很多人问到这个问题,答案非常工程化:

太小(如 4KB)

太大(如 64KB)

16KB 是一个「黄金平衡点」——对大多数工作负载来说,既能保持 B+ 树高度在 3-4 层,又不会带来过大的写放大和内存浪费。InnoDB 还支持 8KB、32KB、64KB 的页大小,但极少需要调整。

7. 页的加载:InnoDB Buffer Pool

最后回答一个常见疑问:

「我有一张 100GB 的表,MySQL 启动时要把 100GB 都加载到内存吗?」
不需要。

InnoDB 使用 Buffer Pool 管理内存中的页:

Buffer Pool 页加载机制 Illustration of InnoDB Buffer Pool loading data pages from disk on demand via LRU eviction 磁盘(数据文件 .ibd) 100 GB - 数百万个 16KB 页 页 #1 页 #2 页 #3 ... 页 #N 按需加载 LRU 淘汰 Buffer Pool(内存) 通常几 GB到 几十 GB BP 页 #1(根页 — 长期驻留) BP 页 #2(内节点 — 高频命中) BP 页 #3(叶子 — 刚被加载) ... BP 页 #K(空闲页 — 等待新请求) 热数据的页自然留在 Buffer Pool
图 4: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 版本、不同配置可能略有差异,但核心原理不变。