MySQL / InnoDB 存储引擎系列 · 第一篇

为什么一次索引查找,最多只需要访问 3、4 个页面?

InnoDB 把每张表的索引组织成一棵 B+树。这篇文章会拆开这棵树的每一层:页(page)是什么、叶子节点存了什么、树里的查找是怎么一步步走下去的,以及树高为什么在千万行数据下也只有 3~4 层。

所有结构和数字不是背出来的——文中的树高、扇出、页内查找算法,都直接在一台本机运行的真实 MySQL 9.7.1 上建表、插入 50 万行、再逐字节解析真实的 .ibd 文件验证过。

16 KB
InnoDB 页大小(本机 innodb_page_size 实测值)
497,016
测试表真实行数
3 层
实测 B+树高度(根 + 内部 + 叶子)

三种页,一棵树

InnoDB 里没有"表"这种物理存在,只有一棵棵由固定大小的页拼成的 B+树。

根 / 内部节点

存的是"路标",不是数据

每条记录是 (key, 子页页号) 这样一个指针,不存整行。相当于一本书目录里"第 3 章 → 第 88 页",不是章节正文本身。

叶子节点

真正的数据在这一层

聚簇索引(主键)的叶子存完整的一行;二级索引的叶子只存索引列 + 主键值。这个区别是下一篇"回表"的根源。

页 / Page

磁盘 I/O 的最小单位

不管是根、内部节点还是叶子,物理上都是一个 16KB 的定长块。CPU 读写内存以字节为单位,InnoDB 读写磁盘以"页"为单位——这就是 B+树这种"矮胖"结构存在的全部理由。

为什么不用普通二叉树?二叉树"瘦高",千万行数据要 20+ 层,对应 20+ 次磁盘 I/O。B+树把每个节点撑到一页能装下的最大指针数(扇出),用"矮胖"换来"树高低",而树高直接等于最坏情况下的页面访问次数。

页内怎么查?——页目录 + 二分查找

"从根走到叶子"只是故事的一半。走到某一页之后,页里几百条记录又是怎么被快速定位的?

InnoDB 不会在一页几百条记录里从头扫到尾。每页维护一个页目录(page directory)——一个稀疏的"分组索引":每 4~8 条记录设一个目录槽(slot),槽里存着这组记录里"最大的那条"的地址。查找时先在目录槽上做二分查找,缩小到某一组,再在组内(最多 8 条)线性扫一遍。

storage/innobase/page/page0cur.cc · mysql-server @ mysql-9.7.1, L420 /* Perform binary search. First the search is done through the page directory, after that as a linear search in the list of records owned by the upper limit directory slot. */

目录槽的分组大小是有硬编码上下限的——同样能在源码里核对到(PAGE_DIR_SLOT_MAX_N_OWNED = 8,PAGE_DIR_SLOT_MIN_N_OWNED = 4,page0types.h),这也是"页内查找"复杂度接近 O(log n) 而不是 O(n) 的原因——它本质上和"从根到叶子"是同一个套路:二分缩小范围,套了两层。

演示:一棵 B+树是怎么长起来的

真实 InnoDB 页能装下几百条记录,没法在屏幕上画清楚。这里用一棵每页最多 3 个键(阶为 4)的"玩具"B+树做演示——结构规则和真实页完全一样,只是把"能装多少"从几百缩小到 3,方便肉眼看清每一步分裂。

插入序列:

关键规则,和真实 InnoDB 完全一致,只是尺度不同:

演示:一次查找,命中与未命中

用上面长成的树,分别查一个存在的 key(27)和一个不存在的 key(26),看查找路径。

选择要查找的 key
两次查找路径长度都是 3——树高是几,最坏情况下查找就要走几步,不管 key 存不存在。这正是"树高即 I/O 次数上限"这句话的字面意思。

回到真实数据:树高为什么只有 3 层

玩具树的阶是 4,真实 InnoDB 页是 16KB,一页能塞下的指针数(扇出)是几百甚至近千。下面是本机真实建表实测的数字。

索引层数(实测)根页指针数中间层页数叶子页数叶子平均记录数
PRIMARY(聚簇索引)3443,186156.9
idx_user_id(二级索引)322663754.1
idx_user_status(二级索引)322693721.5

测试表 orders(id BIGINT 主键 + user_id/status/amount/created_at/note,单行约 106 字节),497,016 行。以上数字来自直接按 InnoDB 页格式解析 orders.ibd 原始字节得到——不是估算。

直接解析 .ibd 文件用到的页头偏移量,均在以下文件核对过(commit 与本机 MySQL 9.7.1 完全一致): storage/innobase/include/fil0types.h FIL_PAGE_TYPE = 24 (页类型,INDEX 页的值是 17855) FIL_PAGE_DATA = 38 (页头起始,即下面 PAGE_HEADER) storage/innobase/include/page0types.h (相对 PAGE_HEADER=38 的偏移) PAGE_N_RECS = 16 → 绝对偏移 54 本页记录数 PAGE_LEVEL = 26 → 绝对偏移 64 层号,0 = 叶子 PAGE_INDEX_ID = 28 → 绝对偏移 66 属于哪个索引

能看出两件事:

聚簇索引叶子平均只装 157 行,二级索引叶子却能装 700 多条——因为聚簇索引叶子存整行(106 字节/行),二级索引叶子只存"索引列 + 主键"(窄得多),同样 16KB 能装下更多条目。这就是"瘦索引扇出更大、树更矮"的直接证据。

根节点只有 2~4 个指针,却能扇出到几千个叶子页——中间层的一个内部页平均能装下 331~797 个指针。3 层树的容量粗略是 根扇出 × 中间层扇出 × 叶子平均行数,随便代入这组实测数字,轻松覆盖千万级行数,这也是"InnoDB 的 B+树通常 3~4 层就够存几千万行"这个说法的来源——不是经验之谈,是扇出数字算出来的。

树高的数学:为什么加一层能覆盖的行数是指数级增长

设内部节点平均扇出为 F,叶子平均行数为 R,树高为 h(叶子算 1 层),能覆盖的总行数大约是:

capacity(h) ≈ F^(h-1) × R

拿 PRIMARY 索引实测的 F≈797(中间层平均扇出)、R≈157 代入:

树高 hcapacity(h)对应页访问次数
1(只有叶子)≈ 1571
2(根直接指叶子)≈ 797 × 157 ≈ 12.5 万2
3(我们实测的情况)≈ 797² × 157 ≈ 1 亿3
4≈ 797³ × 157 ≈ 800 亿4

这就是为什么"树高只加一层"能让容量跳一个数量级以上——扇出是底数,树高是指数。反过来说,只要不是极端窄行(比如整行几 KB 的大字段表),千万甚至上亿行数据落在 3~4 层是常态。这也是为什么讨论 MySQL 性能时,"树高"和"页面访问次数"经常被当成同一件事在说。

一次索引查找的磁盘 I/O 上限 ≈ 树高。根页几乎总是常驻内存(所有查询都要经过它,被 InnoDB Buffer Pool 优先保留),所以实际需要"临时"读盘的往往是 h-1 次甚至更少——这背后 Buffer Pool 具体怎么决定"谁该留、谁该被挤走",是系列第三篇"Buffer Pool 与刷脏"要展开的话题。

参考与说明

  • 演示用的玩具 B+树阶为 4(每节点最多 3 个 key),仅用于把分裂/查找的过程画得肉眼可见;真实 InnoDB 页没有固定的"阶",扇出由页大小(16KB)和记录宽度动态决定,本文"回到真实数据"一节的表格就是这台机器上的真实扇出。
  • 页内查找一节的源码引用(page0cur.cc 二分查找的注释)与本文用于解析 .ibd 文件的字段偏移量,均取自 mysql/mysql-server 仓库 mysql-9.7.1 标签(commit a26ea1a2),与本机安装的 MySQL 9.7.1 版本完全一致。
  • 直接解析 .ibd 文件拿到的"树高"结果,用两种独立方式互相核对过:(a)按真实根页页号读取该页的 PAGE_LEVEL 字段;(b)全文件扫描按 index_id 分组统计每一层的页数。两者结果一致。全文件扫描中曾出现个别"幽灵根页"(层号异常但不是当前树的根)——这是 ALTER TABLE ADD INDEX 重建过程中产生、之后被释放但尚未被覆写的旧页残留,InnoDB 释放页时不会立刻清零内容,不代表当前树结构有误,已用(a)的权威根页号排除。
  • 没有涉及:自适应哈希索引(Adaptive Hash Index)、压缩页(page compression)、MVCC/undo 版本链、锁与事务隔离级别对读路径的影响——这些是更深的话题,不在"结构与查找"这一篇的范围内。
  • 聚簇索引与二级索引"回表"的真实 I/O 代价是系列第二篇的内容,Buffer Pool 缓存/淘汰机制是第三篇的内容。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电