很多人学习 MySQL 索引,是从"最左前缀""回表""覆盖索引""页分裂"这些结论开始的。结论记得不少,真正遇到一条新 SQL 时却仍然只能猜:它会从哪里开始扫描,为什么要回表,范围条件之后的列究竟还能不能使用,插入一行为什么可能修改好几张页面?
问题不在于口诀记得不够,而在于没有先建立索引的整体模型。
索引不是贴在表旁边的"加速标签",也不是一份只保存主键位置的目录。对 InnoDB 来说,每一棵普通索引都是一套独立的、有序的、由磁盘页组成的 B+Tree。查询沿着这棵树找到记录,写入则修改这棵树,同时保证下一次查询仍能按相同规则找到记录。
本文以 MySQL 5.7 为基准,依次回答四个问题:
latex
为什么索引必须围绕磁盘页设计?
一棵 B+Tree 在页中究竟保存什么?
点查、范围扫描、回表和联合索引如何发生?
插入、删除和页分裂怎样维持树始终可查?
先看全文总图。现在不必记住其中的函数或字段,只要先抓住两条主线:读取是在有序空间中找到一段路,写入是在改变页面之后继续维持这段路。

为了让后面的具体记录有统一含义,使用下面这张订单表作为例子;不同小节会选用不同 SQL,但底层都是同一套索引结构。
sql
CREATE TABLE shop.orders (
id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL,
status VARCHAR(16) NOT NULL,
amount DECIMAL(12,2) NOT NULL,
note VARCHAR(200) NULL,
PRIMARY KEY (id),
KEY idx_customer_time_status(
customer_id,
created_at,
status
)
) ENGINE=InnoDB
ROW_FORMAT=COMPACT;
这张表至少包含两棵树:
latex
PRIMARY(id)
idx_customer_time_status(customer_id, created_at, status)
理解 MySQL 索引的第一步,就是不再把"表"和"索引"想成一份行文件外加几个旁路目录,而是把它们看成多棵各自有序的 B+Tree。
索引首先是一个磁盘问题:真正昂贵的不是比较,而是读取页面
假设有一亿行订单,要寻找 id=4201。
如果全部数据已经在内存中,比较几十次通常不是主要成本;但数据库的设计不能假设所有数据永远驻留内存。InnoDB 按页管理数据,常见默认页大小是 16KiB。页面可能位于 .ibd 文件,也可能已经缓存到 Buffer Pool。即使一次查询最终只需要一行,存储引擎仍然要先得到包含该行的页面,再解析页内记录。
因此索引的核心目标不是单纯减少 CPU 比较次数,而是同时做到:
latex
用尽量少的页面访问找到起点
找到起点后可以连续读取范围
页面空间不足时仍能继续插入
结构变化后原有查询规则不失效
把几种常见结构放在磁盘环境下比较,就能理解 B+Tree 为什么出现。

哈希表适合精确等值定位,但哈希值不保留原始键值顺序。查询 created_at BETWEEN ...、ORDER BY created_at 或"从某个键开始向后读取 20 条"时,哈希表无法自然提供连续顺序。
普通二叉搜索树保留顺序,却只有很小的分支数。树中每个节点如果都对应一次磁盘页面访问,较深的路径意味着更多随机 I/O。B+Tree 让一张非叶子页保存大量分隔键和子页号,一层可以排除很大的键值范围,因此树可以保持很矮。
只连接叶子的有序链能够连续扫描,但缺少上层导航结构。寻找范围起点时可能要从头走很远。
B+Tree 把两种能力组合起来:
latex
上层页面:快速缩小目标范围
叶子页面:保存有序记录并相互连接
所以它既适合点查,也适合范围扫描。MySQL 文档和源码中经常使用 B-tree 这一名称;本文讨论的是 InnoDB 普通索引呈现出的 B+Tree 组织特征:真实索引记录位于叶子层,同层页面有前后链接。
这里还可以得到一个重要结论:
索引树的节点不是抽象圆圈,而是一张可以从表空间读取到 Buffer Pool 的数据库页。
树高决定"找到目标叶子页通常要经过多少层",每一层页面能保存多少分隔键则决定扇出。索引键越宽,一页可容纳的入口通常越少;同样的数据量可能需要更多页面,甚至更高的树。这也是"索引列宽度"会影响读取和存储成本的底层原因。
一棵索引就是一组有入口、有层级、有顺序的页面
CREATE INDEX 建立的不是一个孤立文件。对于独立表空间,聚簇索引页和二级索引页通常都位于同一张表的 .ibd 中,但每棵索引拥有自己的索引 ID、字段定义、根页号和 B+Tree 页面集合。
InnoDB 在内存数据字典中使用 dict_index_t 描述一棵索引。下面只摘取与理解本章有关的字段:
cpp
struct dict_index_t {
index_id_t id;
// 这棵索引的内部 ID
const char* table_name;
dict_table_t* table;
// 索引属于哪张表
unsigned space : 32;
// 索引页面位于哪个表空间
unsigned page : 32;
// B+Tree 根页号;搜索从这里进入
unsigned type;
// 是否为聚簇索引、唯一索引等
unsigned n_user_defined_cols : 10;
unsigned n_fields : 10;
// 用户定义字段数,以及内部补充字段后的总字段数
dict_field_t* fields;
// 按索引顺序排列的字段描述
};
源码位置:
mysql-server-5.7/storage/innobase/include/dict0mem.h
这段结构表达了一个很重要的关系:
latex
dict_index_t
给出 space_id + root_page_no
↓
从根页进入对应 B+Tree
↓
页面里的 node pointer 再指向下一层页面
根页只是搜索入口,不保存整棵树的所有记录。树增长时会分配更多页面,但搜索仍然从索引元数据记录的根页开始。
非叶子页负责缩小范围,叶子页负责保存索引记录
一棵稍大的索引可以有三层:根页、非叶子页、叶子页。

非叶子页中的记录可以概括为:
latex
分隔键 K + child page_no
它表达的不是一行业务数据,而是:从某个键值范围开始,应当进入哪个子页继续搜索。
例如搜索元组 (7, 08-18, ...),根页先判断它属于哪个大区间,进入对应的 level 1 页面;level 1 页面再把范围缩小到某张叶子页。每下降一层,目标都从"整棵树"收窄成"一个子页面范围"。
叶子页中的记录才是这棵索引真正保存的 index entry。它们按照完整索引键顺序排列。叶子页还通过 FIL_PAGE_PREV 和 FIL_PAGE_NEXT 形成同层双向链,因此找到范围起点之后,不必每读取下一页都重新回到根页;游标可以直接沿叶子页链继续向右。
同层非叶子页也具有前后页链接,但普通范围查询下降到叶子层后,连续读取依赖的是叶子页链。不要把"所有同层页面存在链接"和"查询会沿所有层级横向扫描"混为一谈。
还要特别注意:
latex
page_no 只是页面地址,不代表键值大小
分裂后新页的页号可能大于原页,却被插入原页与下一页之间。真正表示逻辑先后的是页面中的分隔键以及 FIL_PAGE_PREV/NEXT,不是页号数值。
进入一张页面后,查找从"树下降"变成"页内定位"
B+Tree 把目标缩小到一张叶子页,并不等于已经找到记录。一张 16KiB 页面中可能保存几十、几百条变长记录,InnoDB 还需要在页内找到第一条满足比较模式的记录。
页内搜索不是简单地从第一条用户记录扫描到最后一条,也不是对所有记录指针进行一次完整二分。InnoDB 组合使用 Page Directory 和有序记录链:

页面中有两条系统记录:
latex
Infimum 表示比页内所有用户记录都小的逻辑起点
Supremum 表示比页内所有用户记录都大的逻辑终点
用户记录通过记录头中的 next 偏移保持索引键有序:
latex
Infimum
→ record A
→ record B
→ record C
→ Supremum
记录的物理字节位置不必按键值连续排列。删除、更新和插入可能让页面内部产生碎片,但只要记录链仍按索引键有序,查询就能按逻辑顺序遍历。
Page Directory 位于页尾,由少量 Slot 管理若干记录。页内定位可以理解为两步:
latex
先在 Slot 之间二分,找到目标所属的小记录组
再沿组内记录链逐条比较,找到精确位置
源码中的页游标搜索函数直接体现了这种设计:
cpp
void page_cur_search_with_match(
const buf_block_t* block,
// 当前 Buffer Pool 页面
const dict_index_t* index,
// 用什么索引字段和类型解释页内记录
const dtuple_t* tuple,
// 要搜索的索引元组
page_cur_mode_t mode,
// 寻找 <、<=、> 或 >= tuple 的记录
...,
page_cur_t* cursor,
// 输出:页内游标停到目标位置
...)
{
ulint up;
ulint low;
ulint mid;
// Page Directory 二分所需的上下界和中点
const page_t* page;
page = buf_block_get_frame(block);
// 从 Buffer Block 取得真正的页面字节
...
}
源码位置:
mysql-server-5.7/storage/innobase/page/page0cur.cc
到这里,索引搜索已经形成了三层逐步收窄:
latex
父页分隔键:从整棵树缩小到一个子页
Page Directory:从整张页缩小到一个记录组
页内记录链:从记录组定位到具体记录
这比"B+Tree 是二分查找"更准确。树层、页目录和记录链使用的是不同粒度的导航结构。
叶子记录保存什么,决定了聚簇索引、二级索引与回表
前面的树结构对聚簇索引和二级索引都成立。二者真正的区别,主要出现在叶子记录保存的内容。
聚簇索引的叶子就是完整业务行
对于 PRIMARY(id):
latex
非叶子记录
= 主键分隔值 + 子页号
叶子记录
= 主键 id
+ 其余业务列
+ DB_TRX_ID
+ DB_ROLL_PTR
因此使用主键定位到聚簇叶子记录时,完整业务行已经在那里,不需要再根据"行地址"去另一份行文件读取数据。
这就是"表数据按聚簇索引组织"的真实含义。它不是说数据行旁边额外存在一棵主键树,而是:聚簇索引叶子记录本身就是行记录。
一张 InnoDB 表只能有一棵聚簇索引,因为一行业务数据只能以一种键值顺序作为主要物理组织方式。显式主键通常作为聚簇键;没有主键时,InnoDB 会寻找合适的唯一非空索引,仍然没有时会生成内部行 ID 作为聚簇键。
二级索引的叶子保存二级键和聚簇键
对于:
latex
idx_customer_time_status(customer_id, created_at, status)
二级叶子记录可以概括为:
latex
customer_id
+ created_at
+ status
+ 聚簇索引键 id
二级索引不复制完整业务行。它携带主键,是为了在命中二级记录后能够定位对应的聚簇记录,同时让相同二级键的多行保持可区分。

于是三种读取路线自然出现。
主键点查:
latex
PRIMARY 根页
→ PRIMARY 叶子页
→ 完整行
二级索引点查,并且查询列都在二级记录中:
latex
二级索引根页
→ 二级索引叶子页
→ 直接返回二级记录中的字段
这就是覆盖索引。它不是一种特殊索引类型,而是"本次查询需要的列,当前索引记录已经全部提供"。
二级索引点查,但查询还需要 amount:
latex
二级索引根页
→ 二级索引叶子页
→ 取出主键 id
→ PRIMARY 根页
→ PRIMARY 叶子页
→ 取得 amount 和完整行
这就是回表。回表不是从二级记录跳到某个固定文件偏移,而是拿二级记录中的聚簇键,对另一棵 B+Tree 再执行一次搜索。
因此一个范围查询如果命中一万条二级记录,并且每条都要读取聚簇记录,成本不只是"一次范围扫描",还可能包含大量聚簇索引点查。
一次查询的本质:先找到第一条候选记录,再从那里向后读
现在把树结构、页内定位和两类叶子记录组合起来。
查询并不是把完整 WHERE 文本直接交给某张叶子页。Server 层先选择访问路径,并把能够用于定位的条件整理成索引边界;InnoDB 再根据索引元数据和比较模式,把边界变成 B+Tree 中的起点。
Server 交给 InnoDB 的是一张查找指令
以这个范围为例:
sql
WHERE customer_id = 7
AND created_at >= '2026-08-01'
AND created_at < '2026-09-01'
下界可以理解为:
latex
Key Buffer : [ customer_id = 7 ][ created_at = 2026-08-01 ]
keypart_map : 110
flag : HA_READ_KEY_OR_NEXT
三者分别回答不同问题:
| 内容 | 回答的问题 | 本例含义 |
|---|---|---|
Key Buffer |
边界值是什么 | 7、2026-08-01 |
keypart_map |
索引定义中的哪些字段参与边界 | 第 1、2 列 |
flag |
与边界相等时停在哪一侧 | 找第一条 >= 下界的记录 |
Key Buffer 不是缓存,也不是一条完整索引记录。它只是 Server 层按照索引字段格式编码出的搜索前缀。
latex
搜索前缀: (7, 2026-08-01)
真实二级记录:(7, 2026-08-01, PAID, 4101)
keypart_map=110 只是为了直观表示:联合索引定义中的前两列参与当前边界,status 不属于这次定位键。主键 id 虽然存在于 InnoDB 二级记录中,却不是 Server 这条索引定义中的显式 keypart。
上界同样需要值和开闭语义:扫描遇到第一条 >= (7, 2026-09-01) 的记录时停止,该记录本身不属于结果。

InnoDB 把搜索前缀变成可以与索引记录比较的元组
InnoDB 根据 dict_index_t 中的字段顺序、类型和排序规则,把 Key Buffer 转换为内部搜索元组 dtuple_t。搜索函数接收的核心信息可以压缩成:
latex
dict_index_t + dtuple_t + page_cur_mode_t
= 搜索哪棵树 + 比较什么元组 + 找边界哪一侧
源码接口直接反映了这三个输入:
cpp
void btr_cur_search_to_nth_level(
dict_index_t* index,
// 搜索哪棵索引树;其中包含 space_id 和 root page
ulint level,
// 希望停在哪一层;普通记录查询最终到 level 0 叶子层
const dtuple_t* tuple,
// 已按索引字段顺序构造的搜索元组
page_cur_mode_t mode,
// PAGE_CUR_L / LE / G / GE 等比较方式
...,
btr_cur_t* cursor,
// 输出:树游标停到目标页和目标记录附近
...,
mtr_t* mtr);
源码位置:
mysql-server-5.7/storage/innobase/include/btr0cur.hmysql-server-5.7/storage/innobase/btr/btr0cur.cc
搜索从 index->page 指向的根页开始。每到一张非叶子页,页内比较选择一个 node pointer,再取得对应子页;直到进入叶子页,页游标找到第一条满足比较模式的候选记录。

这里的"游标"可以理解为存储引擎保存的当前索引位置:它知道当前位于哪棵索引、哪张页以及哪条记录附近。范围查询不需要为每一条记录重新从根页开始搜索,因为游标可以从当前位置沿记录链继续移动。
范围扫描有两个独立动作:下界定位和上界停止
范围查询不是在 B+Tree 中一次性取出一个数组,而是:
latex
使用下界搜索第一条候选记录
↓
逐条读取当前页中的后继记录
↓
当前页结束后沿 FIL_PAGE_NEXT 进入下一叶子页
↓
每取得一条记录都检查是否越过上界

如果查询还有 LIMIT 20,还要区分"扫描了多少条候选记录"和"最终产生了多少条结果"。只有记录经过索引内过滤、必要的回表和剩余 WHERE 判断后,才算一条最终结果。第 20 条合格结果出现时才能早停。
所以:
latex
LIMIT 20
不等于最多读取 20 条索引记录
如果候选记录中只有十分之一满足剩余条件,得到 20 条结果可能已经扫描了约 200 条索引记录,并可能执行多次回表。
联合索引不是一组口诀,而是一条按完整元组排列的数轴
理解联合索引之前,必须先写出这棵树真正保存的完整索引元组。
对:
latex
idx_customer_time_status(customer_id, created_at, status)
普通二级索引叶子还携带主键,因此可以把记录顺序理解为:
latex
(customer_id, created_at, status, id)
比较采用字典序:先比较 customer_id;相等时再比较 created_at;仍然相等时比较 status;最后使用 id 区分记录。

它不是三列分别保持全局有序,而是完整元组整体有序。
因此:
latex
customer_id = 6 的记录形成一段
customer_id = 7 的记录形成一段
customer_id = 8 的记录形成一段
在 customer_id=7 的区段内部,记录才继续按照 created_at、status、id 排列。脱离相同的 customer_id 前缀,所有客户的某一天并不会集中在一段。
所谓最左前缀,本质上是在回答:SQL 条件能否把目标记录压缩成这条元组数轴上的一个连续区间,或者少量几个连续区间。
为什么范围列之后的字段通常不能继续缩小同一个连续边界
考虑:
sql
WHERE customer_id = 7
AND created_at >= '2026-08-01'
AND created_at < '2026-09-01'
AND status = 'PAID'
customer_id 和 created_at 可以形成一个连续日期区间:
latex
[(7, 2026-08-01), (7, 2026-09-01))
但是在这段范围中,每一天内部又按 status 排列:
latex
(7, 08-01, CANCELLED, ...)
(7, 08-01, PAID, ...)
(7, 08-01, REFUND, ...)
(7, 08-02, CANCELLED, ...)
(7, 08-02, PAID, ...)
所有 PAID 并没有在整个八月范围中聚集成一段。若只读取 PAID,就要在每一天的局部状态区间之间反复跳跃。因此 status='PAID' 通常不能继续把当前日期范围压缩为一个更小的连续起止边界。
但这不表示 status 完全失效。因为它仍然保存在二级索引记录中,可以:
latex
参与 ICP,在回表前过滤
参与覆盖索引输出
在满足前置顺序时参与 ORDER BY
所以"范围列之后全部失效"是不准确的。更准确的说法是:
后续字段通常无法继续缩小当前这一段连续扫描边界,但仍可能在扫描、输出和排序阶段发挥作用。
常见条件只是不同的区间生成方式
不必为每一种 SQL 单独背规则,只要观察它在完整元组数轴上形成什么区域。
| 条件 | 在有序元组中的含义 |
|---|---|
a = 7 |
一个等值前缀区段 |
a IN (6,8) |
两个互不相邻的等值区段 |
a BETWEEN 6 AND 8 |
一个连续闭区间 |
name LIKE 'abc%' |
在匹配排序规则下形成字符串前缀范围 |
| 只有第二列条件 | 目标可能散布在每一个第一列区段中 |

IN 并不是"完全等价于一个等值条件"。它通常产生多个范围,每个范围都可以定位,但多个范围之间需要分别处理。
LIKE 'abc%' 可以利用从 abc... 开始的一段有序字符串范围;LIKE '%abc' 缺少确定的起始前缀,无法直接跳到一段连续区域的起点。
隐式类型转换的问题也可以从这里理解:若列值必须先经过函数或类型转换才能参与比较,转换后的比较顺序不一定还能直接映射到原索引字节顺序,存储引擎就难以构造可靠的起止边界。
联合索引的"左前缀"和字符串列的"前缀索引"也不是同一个概念:前者描述多列元组从左到右的排序依赖;后者是建索引时只保存字符串前 N 个字符,主动缩短索引记录,但可能失去完整值的区分能力。
ORDER BY 和 LIMIT 利用的是同一份索引顺序
如果结果顺序与选定索引从扫描起点向后的记录顺序一致,MySQL 可以边扫描边输出,不必把全部结果交给额外排序过程。

例如固定 customer_id=7 后,索引剩余顺序是:
latex
created_at → status → id
那么:
sql
ORDER BY created_at, status, id
与叶子记录向右扫描的顺序一致。若最终条件也能较早满足,LIMIT 20 可以在产生第 20 条结果后停止。
如果排序列顺序、方向或前置约束不能与索引顺序对应,索引即使帮助了 WHERE 定位,也不一定能同时消除 Filesort。索引不是分别为 WHERE 和 ORDER BY 维护两套顺序;它始终只有一份由完整索引元组定义的顺序。
覆盖索引、ICP、回表和 Using where,只是条件在不同位置执行
一条使用二级索引的查询,可以拆成一条清晰的数据缩减流水线:

latex
边界定位
→ 扫描候选二级记录
→ 使用二级记录中的字段做 ICP
→ 对剩余候选回表
→ 使用完整行做 Server 层过滤
→ 按索引顺序输出或额外排序
→ LIMIT 判断是否停止
这些术语并不是索引能力从低到高的三个等级,而是在说明不同工作发生在哪里。
覆盖索引省掉整次回表
若查询只需要:
sql
SELECT customer_id, created_at, status, id
这些值都存在于二级索引记录中,读取二级叶子后已经可以构造结果,不需要进入聚簇索引。这通常会在执行计划中出现 Using index。
ICP 在回表之前淘汰不合格二级记录
若 status='PAID' 不能继续缩小日期范围,但 status 存在于二级记录中,InnoDB 可以扫描每条候选二级记录时先检查状态,只为满足状态的记录回表。

假设日期范围内有 1000 条二级记录,其中只有 120 条是 PAID:
latex
没有 ICP:可能先回表约 1000 次,再判断 status
使用 ICP:二级索引内过滤后,只回表约 120 次
ICP 没有减少这个日期范围内必须扫描的二级记录数量;它减少的是昂贵的后续回表次数。这通常对应 Using index condition。
Using where 表示仍有条件需要在取得当前行后判断
如果条件依赖 amount,而 amount 不在二级索引中,就必须回表取得聚簇记录,再判断完整 WHERE。执行计划中的 Using where 说明仍存在这样的过滤工作,并不表示索引完全没有作用。

同一条执行计划可能同时出现多个提示,因为它们描述的阶段不同:
latex
Using index 当前索引已经覆盖查询所需列
Using index condition InnoDB 在索引记录阶段执行条件下推
Using where 取得当前行后仍需检查剩余条件
深分页慢,是因为 B+Tree 按键查找,不按"结果名次"跳转
LIMIT 100000,20 不能直接让游标跳到"第 100001 条最终结果"。B+Tree 能根据索引键定位,却没有为当前 WHERE 结果动态维护名次。存储引擎通常仍需从范围起点开始扫描、过滤并丢弃前 100000 条结果。
如果业务能携带上一页最后一条记录的完整排序键,例如:
latex
(created_at, status, id)
就可以把"按名次跳转"改写为"按索引键寻找新起点",这也是游标式分页通常更稳定的底层原因。
当二级范围扫描产生大量主键,并导致大量随机聚簇索引点查时,MRR 可以先收集一批主键,再按更有利于聚簇读取的顺序回表。它优化的是批量回表的局部性,不会改变联合索引如何形成范围,也不会消除回表本身。
写入不是查询之外的另一套逻辑,而是在维护同一套有序结构
读到这里,查询依赖的规则已经明确:
latex
父页分隔键能把搜索带到正确子页
页内记录链保持完整索引键有序
叶子页链可以连续遍历
二级记录中的主键能找到正确聚簇记录
INSERT、UPDATE 和 DELETE 的任务,就是在数据变化之后继续保持这些规则。
插入一行业务数据,需要给每棵索引各写一条记录
执行:
sql
INSERT INTO shop.orders
(id, customer_id, created_at, status, amount, note)
VALUES
(4905, 7, '2026-08-25', 'PAID', 128.00, '...');
InnoDB 不会先写一份独立"行文件",再让索引只保存文件地址。它会根据每棵索引的字段顺序构造不同 index entry:
latex
PRIMARY entry
= id 4905 + 完整业务列 + 事务隐藏字段
二级 entry
= customer_id 7
+ created_at 08-25
+ status PAID
+ id 4905

表中如果有五棵索引,一次 INSERT 通常就要为五棵树分别构造并插入记录。UPDATE 修改索引列时,也可能表现为删除旧索引 entry,再向新键值位置插入新 entry。
这解释了索引最直接的写入成本:索引越多,一次业务写入需要维护的有序结构越多。
插入位置仍然使用 B+Tree 搜索
新 entry 不是追加到文件末尾,而是按索引键找到它应该位于的叶子页和前驱记录。
latex
构造 entry
→ 从根页下降
→ 页目录定位记录组
→ 记录链找到插入位置
这与查询起点定位使用的是同一套有序结构。区别在于查询通常取得共享访问,写入需要修改页面并维护记录链、目录和空间信息。
页面放得下,就不必改变树结构
叶子页存在足够连续空间时,InnoDB 可以直接把新记录写入页面,修改相邻记录的 next 关系,并在需要时调整 Page Directory。
如果总空闲空间足够,但被删除记录和历史变化切成了多个碎片,页面可以先重组:按逻辑记录顺序重新整理物理字节,合并出较大的连续空间,再尝试插入。
页面重组不会改变 B+Tree 层级,也不会改变记录的逻辑顺序。它只是重新安排同一页中的物理字节。
InnoDB 源码把"不预期改变树结构"和"允许修改树结构"分成两条插入路径:

cpp
err = btr_cur_optimistic_insert(
flags,
cursor,
&offsets,
&offsets_heap,
entry,
&insert_rec,
&big_rec,
n_ext,
thr,
&mtr);
// 先假设只修改当前叶子页就能完成
if (err == DB_FAIL) {
err = btr_cur_pessimistic_insert(
flags,
cursor,
&offsets,
&offsets_heap,
entry,
&insert_rec,
&big_rec,
n_ext,
thr,
&mtr);
// 当前页无法容纳时,进入允许分裂页面、修改父页的路径
}
源码位置:
mysql-server-5.7/storage/innobase/row/row0ins.ccmysql-server-5.7/storage/innobase/btr/btr0cur.cc
这里的 optimistic/pessimistic 描述的是"是否预期需要修改树结构",不是事务锁中的乐观锁与悲观锁。
页面重组后仍放不下,才需要页分裂
页分裂不是把原页从物理中间随便切成两半。InnoDB 要根据索引键选择分裂位置,分配新页,把部分记录移动过去,再把新 entry 插入正确半页。
同时必须修改两类导航信息:
latex
同层页链:把新页插入原页与下一页之间
父页入口:增加指向新页的 node pointer

如果只移动记录而不更新父页,下一次从根页下降时找不到新页;如果只更新父页而不修复叶子页链,范围扫描跨页时会跳过新页。页分裂之所以比"申请一张新页"复杂,是因为树形定位和横向扫描必须同时保持正确。
父页也可能没有空间容纳新的分隔键。此时分裂会继续向上传播,直到某一层能够保存新入口。
根页满时,MySQL 5.7 保持原根页号并让树升高
如果当前根页也需要分裂,MySQL 5.7 不必把索引元数据切换到一个全新根页号。它会把原根记录移到新分配的子页,清空原根页并把原根提升一层,再在原根中写入子页入口。

源码注释直接说明了这个过程:
cpp
/* Allocate a new page to the tree.
Root splitting is done by first
moving the root records to the new page,
emptying the root,
putting a node pointer to the new page,
and then splitting the new page. */
level = btr_page_get_level(root, mtr);
new_block = btr_page_alloc(
index,
0,
FSP_NO_DIR,
level,
mtr,
mtr);
// 分配与原根同层的新页面
源码位置:
mysql-server-5.7/storage/innobase/btr/btr0btr.cc
最终树高增加,但搜索入口仍然是 dict_index_t.page 记录的原根页号。原根页从"保存叶子记录"变成"保存子页入口"。
一次分裂可能同时修改原页、新页、相邻页、父页,甚至更高层页面。InnoDB 使用 mini-transaction,也就是 mtr,组织这种短小的物理修改:在非常短的窗口内持有相关页面 Latch,记录对应 Redo,并在结构修改完成后释放保护。
mtr 不是用户执行的 BEGIN/COMMIT。用户事务可能持续数秒甚至更久,一次页分裂的 mtr 只负责一小组底层页面修改。
DELETE 先逻辑标记,再等待安全的物理清理
删除一行时,InnoDB 不能立即把聚簇记录和所有二级记录的字节擦除。当前事务可能回滚,已经存在的历史读取也可能仍然需要旧版本。
因此删除通常先给相关索引记录设置 delete-mark。记录仍留在页面和记录链中,但逻辑状态已经改变。等到没有事务再需要相应历史版本,Purge 才执行物理清理,使页内空间重新可用。

要区分四件事:
latex
SQL 逻辑删除完成
索引记录带 delete-mark
Purge 物理移除索引记录
表空间文件物理缩小
它们不是同一时刻,也不是同一个动作。Purge 释放的空间通常先供表空间内部后续写入复用,不表示 .ibd 文件立刻变小。
B+Tree 可以持续变化,但四个查询不变量不能被破坏
把查询与写入放在一起,MySQL 索引设计可以收束成四条不变量。

第一,父页分隔键必须把搜索带到正确子页。否则根页下降会进入错误范围。
第二,页内记录链必须保持完整索引键有序。物理字节可以移动,逻辑 next 顺序不能错误。
第三,同层页面前后链接必须保持连通。否则范围游标无法跨页连续扫描。
第四,同一行业务数据的所有索引记录必须保持一致。二级记录中的主键必须能够回到对应聚簇记录,事务失败时也不能留下半套索引状态。
这些不变量解释了为什么查询和写入必须学习在一起:
latex
查询依靠不变量寻找记录
写入负责在变化后恢复并维持不变量
索引减少了什么成本,又增加了什么成本
理解结构之后,可以不用"索引越多越好"或"索引会拖慢写入"这样的模糊结论,而是具体计算工作发生在哪里。
一次主键点查的主要结构成本可以概括为:
latex
从根页下降到聚簇叶子页
+ 在叶子页中定位记录
一次二级索引点查可能是:
latex
从二级根页下降到二级叶子页
+ 从聚簇根页下降到聚簇叶子页
一次范围查询可能是:
latex
一次起点定位
+ 扫描若干二级叶子页
+ 对部分或全部候选记录回表
+ 可能的额外排序
一次 INSERT 则是:
latex
为每棵索引构造 entry
+ 为每棵索引搜索插入位置
+ 修改对应叶子页
+ 必要时重组、分裂并更新父页
因此索引的成本来自几个明确来源。
索引数量增加,会增加磁盘空间、Buffer Pool 工作集以及每次写入需要维护的 B+Tree 数量。
索引键变宽,会让单条记录占用更多页内空间。一页能够容纳的叶子记录和非叶子入口都可能减少,树占用更多页面,缓存密度下降。
二级索引选择性低或者范围很大时,即使能够定位起点,也可能扫描大量二级记录,并执行大量随机回表。此时全表扫描或聚簇扫描有时反而更便宜;是否选择索引是优化器的成本决策,不是"只要存在索引就必须使用"。
覆盖索引能够省掉回表,但为了覆盖而把大量列塞入索引,也会让索引记录变宽。收益是减少读取路径,代价是增加存储、缓存和写入维护成本,两者来自同一个底层结构。
所以设计索引时,真正应该问的不是"这列要不要加索引",而是:
latex
这棵树按什么完整元组排序?
目标查询在这条顺序上形成多大的连续范围?
叶子记录能否提供查询需要的数据?
预计扫描多少条记录、多少张页、发生多少次回表?
每次业务写入还要维护多少棵树?
最后把整套索引模型重新连起来
现在可以用一条读取链和一条写入链概括 MySQL 索引。
读取链:
latex
SQL 条件
→ Server 选择索引并构造搜索边界
→ InnoDB 根据 dict_index_t 找到根页
→ 搜索元组沿非叶子页逐层下降
→ Page Directory 缩小页内范围
→ 记录链找到第一条候选记录
→ 范围查询沿叶子页链继续扫描
→ ICP 尽量在回表前过滤
→ 二级记录携带的主键用于聚簇索引点查
→ 覆盖、排序顺序和 LIMIT 决定后续工作
写入链:
latex
一行业务数据
→ 为每棵索引构造不同 entry
→ 使用同一套 B+Tree 搜索找到插入位置
→ 页面有空间时直接插入
→ 有碎片时先重组
→ 仍放不下时分裂叶子页
→ 修复同层页链并向父页增加入口
→ 必要时继续向上分裂或提升根页
→ 删除则先标记,再由 Purge 安全清理
"最左前缀、覆盖索引、回表、ICP、页分裂"并不是互不相干的规则,而是同一套页级有序结构在查询和写入路径上的不同表现:
InnoDB 把索引键组织成全局有序的 B+Tree 页面;父页负责快速定位,页目录和记录链负责页内定位,叶子页链负责连续扫描,聚簇叶子承载完整行,写入则通过页内插入、分裂和父页更新持续维护这些查询规则。
这套结构把三种看似冲突的需求统一起来:父页和页目录缩短定位路径,叶子页的有序链接支持连续范围读取,页面分裂和父页更新又让有序结构可以持续增长。
查询成本最终落在定位层数、扫描页面数、候选记录数和回表次数上;写入成本则落在索引数量、entry 宽度、页面修改和结构分裂上。所谓索引设计,本质上是在读取路径与写入维护之间,选择一套合适的页面组织方式。
索引的价值不只是"查询可以走 B+Tree",而是用可维护的页级有序性,换取可预测的定位与扫描成本。