MySQL 二级索引的非叶子节点存了什么:索引值、主键值和子页号指针

发布时间:2026/10/27 10:34:14
MySQL 二级索引的非叶子节点存了什么:索引值、主键值和子页号指针

MySQL 二级索引的非叶子节点存了什么:索引值、主键值和子页号指针

二级索引的查找要先用索引键定位,再拿主键回表。这篇从一条二级索引查询出发,拆开非叶子节点的记录结构,说明索引值、主键值和子页号各自负责什么,并给出可复核的官方文档和验证入口。

一、使用场景

二级索引列的值重复度很高时,这个结构问题才会真正暴露。比如订单表在 status 上建索引,几十万行都是 paid,仅靠 status 无法把记录排成唯一顺序,也无法决定查询应该往哪个子页走。这时主键必须参与比较,二级索引的键实际变成索引列加主键的组合。

主键变长也有同样的影响。InnoDB 会把主键追加到每个二级索引,主键越长,二级索引占用的空间越大,非叶子页能容纳的分隔键越少,树的层数也更容易升高。手册 17.6.2.1 对此有一句直接说明:主键较长时二级索引会占用更多空间,因此短主键更有优势。

二、核心概念

InnoDB 的表数据按 B+ 树组织。聚簇索引的叶子页存放完整行,二级索引的叶子页存放索引列和主键列;位于两者之上的非叶子页只负责导航。页头的 PAGE_LEVEL 记录当前页在树中的层级,0 表示叶子页。手册 17.11.2 还说明每个索引会分配两个 segment,一个给非叶子节点,一个给叶子节点。

官方手册对二级索引记录的组成写得很明确:

In InnoDB, each record in a secondary index contains the primary key columns for the row, as well as the columns specified for the secondary index. InnoDB uses this primary key value to search for the row in the clustered index.

这句话覆盖的是每条二级索引记录。再结合 10.3.10 的索引延伸规则:InnoDB 会自动把主键列追加到每个二级索引,声明为 k_d(d) 的索引在内部按 (d, i1, i2) 处理。二级索引的完整键是索引列在前、主键列在后,键长和排序位置都按这个组合计算。

非叶子页的每条记录由三部分组成:

  • 索引值:分隔键的头部,决定第一轮比较。
  • 主键值:索引值相同时继续比较,让组合键在树中有唯一位置。
  • 子页号:下一层页面的编号,沿着它继续下降。

用两层树表示这个结构:

根页 / 中间页(PAGE_LEVEL > 0) (d='2000-01-01', i1=1, i2=2) -> 子页号 12 (d='2000-01-02', i1=1, i2=1) -> 子页号 27 │ ▼ 叶子页(PAGE_LEVEL = 0) (d='2000-01-01', i1=1, i2=2) -> 聚簇索引中的 (1, 2) 行

叶子记录与非叶子记录的差别在尾部:叶子记录带完整键,并用主键指向聚簇索引中的行;非叶子记录在键后面接子页号。MySQL Server 源码文档对 btr_node_ptr_get_child_page_no 的说明是“Gets the child node file address in a node pointer”,即 node pointer 中保存的是子页地址;btr_page_get_level 的注释则明确“leaf level == 0”,可以据此区分层级。

需要区分的是,子页号指向 B+ 树中的页面,并不指向数据行,也不等同于聚簇索引的页号。二级索引只保存主键值,回表时再由主键到聚簇索引里定位整行。

三、最小代码

用一张复合主键加单列二级索引的表就能看到这个结构:

createtablet1(i1intnotnulldefault0,i2intnotnulldefault0,ddatedefaultnull,primarykey(i1,i2),keyk_d(d))engine=innodb;insertintot1values(1,1,'2000-01-01'),(1,2,'2000-01-01'),(2,1,'2000-01-01'),(2,2,'2000-01-01'),(3,1,'2000-01-01'),(3,2,'2000-01-01'),(4,1,'2000-01-01'),(4,2,'2000-01-01');

主键是 (i1, i2),二级索引只声明了 d。八条记录的 d 完全相同,仅看 d 无法排序;InnoDB 把主键追加到 k_d 之后,索引键变成 (d, i1, i2),记录才能按 (d, 1, 1)、(d, 1, 2)、(d, 2, 1) 这样的顺序排开。非叶子页保存的分隔键也必须是这个组合,否则上层无法判断一条新记录该落到哪个子页。

四、执行流程

以select count(*) from t1 where i1 = 3 and d = '2000-01-01'为例,查找沿下面这条路径推进:

  1. 优化器选择 k_d,按扩展后的 (d, i1, i2) 把 d 和 i1 当作键的左侧前缀。
  2. 从根页开始,用目标键与每条分隔键比较:先比 d,相同再比 i1;i2 没有出现在条件里,用来在相同前缀内确定扫描起点。
  3. 找到合适的子页号后进入下一层,中间页重复同样的比较,直到到达叶子页。
  4. 叶子页内做二分,命中记录后取出主键 (i1, i2)。
  5. 查询列被索引覆盖时直接返回;需要其他列时,拿主键回聚簇索引取整行。
  6. 插入导致页分裂时,新页的首条键会作为 node pointer 写入上层;上层也满时继续向上分裂,根页分裂后树高增加一层。

这套流程里,非叶子页承担的职责很单一:提供分隔键和下一跳页号。它不保存行数据,也不保存聚簇索引的页号,所以页分裂时只需要调整分隔键和子页号,不必移动行。

五、常见坑

只记住“二级索引存索引列”,漏掉主键参与排序。遇到重复值多的索引,就无法解释定位过程,也容易把索引延伸理解成优化器的可选行为。

把子页号当成行地址或聚簇索引页号。二级索引叶子记录里只有主键值,回表是另外一跳,页号只服务于 B+ 树内部的下降。

用 SHOW INDEX 判断索引的真实键。SHOW INDEX 只显示声明的 d,追加的主键列不会出现;key_len 和查询计划才能反映扩展后的前缀。

低估长主键的代价。主键列会出现在每个二级索引的每一层,字符型主键尤其明显,索引体积和树高都会随之增加。

把唯一二级索引当成例外。唯一索引的分隔键本身已经唯一,路由时不需要再靠主键补足;叶子记录仍然带主键用于回表,索引延伸带来的空间开销不变。

六、验证方式

本文没有在本机 MySQL 实例上执行这些语句,下面给的是可复现路径,预期值来自手册 10.3.10 的示例输出。

建表插数据后先清空表缓存和状态计数器:

flushtables;flushstatus;

再看执行计划:

explainselectcount(*)fromt1wherei1=3andd='2000-01-01';

手册示例中,优化器不考虑索引延伸时 key_len 是 4、ref 是 const、rows 是 5;考虑延伸后 key_len 变成 8、ref 变成 const,const、rows 变成 1。key_len 从 4 增加到 8,说明查询用上了 d 后面的 i1,二级索引记录确实带主键列。当前数据里 i1=3 有两行,rows 会与手册不同,key_len 和 ref 这两个值仍然能说明问题。

扫描行数可以进一步确认:

showstatuslike'handler_read%';

手册示例中 Handler_read_next 从 5 降到 1。在当前数据下,这个值应当接近匹配的记录数,而不是按 d 值全扫。

索引声明本身看不到扩展部分:

showindexfromt1;

结果只列出 PRIMARY(i1, i2) 和 k_d(d),追加的主键列不会显示出来。这里正是“声明键”和“物理键”需要分开看的地方。

需要直接观察页内记录时,可以用 innodb_ruby 提供的 innodb_space 按页转储:

innodb_space-ft1.ibd-p3page-dump

按该工具的说明,非叶子页会列出 node pointer 的键值和子页号,叶子页会列出完整记录。这个工具不属于 MySQL 自带命令,适合本地观察页结构,生产环境不要依赖它。

七、总结

二级索引的非叶子节点承担路由,不承担数据。它保存的键是索引列加主键的组合,尾部接子页号;叶子记录才带完整键,并用主键回表。判断一条二级索引查询的成本时,先看键的完整组成,再看查询列是否被索引覆盖,就能判断会不会多一次回表。

参考资料

  • MySQL 8.4 Reference Manual 17.6.2.1 Clustered and Secondary Indexes
  • MySQL 8.4 Reference Manual 10.3.10 Use of Index Extensions
  • MySQL 8.4 Reference Manual 17.10 InnoDB Row Formats
  • MySQL 8.4 Reference Manual 17.11.2 File Space Management
  • MySQL Internals: InnoDB Page Structure
  • MySQL Internals: Page Header
  • MySQL Server Source Documentation: btr0btr.h