← 返回博客
mysql2026-09-07 19:12:396 分钟 · 1,607 0

MySQL 索引底层(三):写路径、页分裂与索引失效实战

索引不是免费的。这篇从写入侧讲起:顺序追加和随机插入在 B+ 树上的两副面孔、页分裂怎么发生、change buffer 攒的是什么;然后用一组 EXPLAIN 输出对照五个最常见的索引失效场景,每个给反例和改法。

前两篇讲的都是"索引怎么让读变快"。这篇补上另一半:写路径。B+ 树为读优化的每一个设计,都在写入时收对应的费——理解收费方式,"自增主键""为什么这条 SQL 不走索引"这类问题就不用背答案了。

顺序写与随机写:同一棵树的两副面孔

自增主键的写入:新行主键永远最大,定位到最右叶子,追加到页的末尾。页写满就开新页,旧页不迁移任何数据——这是 B+ 树最舒服的写入姿势,相当于顺序写磁盘。

UUID / 随机主键的写入:新行的主键落在树的任意位置。目标页写满了?触发页分裂:InnoDB 申请新页,把原页约一半的行搬过去,父节点补一个指针,最坏情况下树高 +1。搬数据、改父节点、可能还要读盘找空间,一次插入的 IO 成本翻几倍。

量化过一次真实迁移:5000 万行的表把主键从 UUID 换成 bigint 自增,表体积从 62GB 缩到 38GB——所有二级索引的叶子都跟着主键瘦身,页利用率同步回升。主键选型影响的是每一棵索引树。

对应的反面是页合并:删除让一页的数据低于阈值(默认约一半),InnoDB 尝试把它和相邻页合并,回收空间。频繁的批量删除会在树上留下"半满页"带,让后续写入更频繁地分裂——这也是"定期归档冷数据比直接删更划算"的结构性原因。

change buffer:先记账,后清账

更新二级索引有个隐藏成本:新行的索引键落在哪棵树的哪个页,不由你选。如果目标页不在 Buffer Pool,就得先把页从磁盘读进来,改完再写回——一个只改一行的 UPDATE,IO 花在读一个无关的 16KB 页上。

change buffer 的解法:目标页不在内存时,不读页,把这次变更记进 change buffer(本身也是一棵 B+ 树,在系统表空间里),返回。等以后这条页被查询真正读到内存时,再把攒下的变更合并进去(merge)。

代价是读到这页之前,它处于"变更未应用"状态,涉及它的查询可能略慢。所以 change buffer 适合写多读少的索引(日志表、流水表);写完立刻要读的场景,merge 反而叠加延迟。一个限制值得记住:唯一索引用不上 change buffer——插入前必须读页判重,"先记账"的前提不存在。这是"唯一索引不只是多个约束,还更贵"的底层依据。

EXPLAIN 实战:一张表看五条 SQL

建一张表做实验,索引只有一个联合索引 idx_ua (user_id, action)

CREATE TABLE t_log (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  action VARCHAR(32) NOT NULL,
  phone CHAR(11) NOT NULL,
  created_at DATETIME NOT NULL,
  KEY idx_ua (user_id, action)
) ENGINE=InnoDB;

五条查询的 EXPLAIN 结果对照:

SQLtypekeyrows问题
WHERE user_id=100 AND action='pay'refidx_ua12满配
WHERE user_id=100refidx_ua900正常
WHERE action='pay'ALLNULL500万断最左,全表
WHERE user_id>100 AND action='pay'rangeidx_ua40万只用了 user_id
WHERE phone='13800138000'ALLNULL500万没有对应索引

type 从好到差大致是 system > const > eq_ref > ref > range > index > ALL,生产 SQL 出现 ALL(全表扫描)和 index(扫全索引)就要警惕。但比 type 更值得看的是 rows(预估扫描行数)和 Extra——rows 决定了这条 SQL 的成本下限,Extra 里的 Using index(覆盖索引)和 Using filesort(额外排序)直接对应钱。

五个失效场景:每个都有反例和改法

1. 对索引列做函数或运算

-- 失效:树里存的是 created_at 的原值,函数算不出有序性
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- 改法:把运算搬到常量一侧,范围条件等价替换
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';

WHERE id + 1 = 100 同理失效,写成 WHERE id = 99。原则一句话:树按原值有序,任何加工后的值在树里无序

2. 隐式类型转换

-- phone 是 CHAR(11)
SELECT * FROM users WHERE phone = 13800138000;   -- 全表扫描
SELECT * FROM users WHERE phone = '13800138000'; -- 走索引

字符串列和数字比较时,MySQL 把转成数字(而不是把常量转成字符串),等价于对整列套了 CAST(phone AS SIGNED)——场景 1 的函数失效。反过来,数字列 WHERE id = '5' 没事,因为常量转数字不碰列。

3. 前缀模糊匹配

WHERE name LIKE '张%'  -- 能走索引:树按前缀有序,等于范围 [张, 张+∞)
WHERE name LIKE '%三'  -- 失效:开头就不确定,树无法定位

'张%' 能用索引的原理和范围查询完全一样——前缀固定,剩余部分在树上是一个连续区间。'%三' 连起点都找不到。

4. OR 两边有不带索引的列

WHERE user_id = 100 OR phone = '13800138000'  -- idx_ua 帮不上 phone

优化器必须同时满足两个条件,一个能走索引、一个只能全表扫时,索引路径作废。改法:给 phone 单独建索引让 index merge 接手,或用 UNION ALL 拆成两个各走各索引的子查询。

5. 优化器选错了索引

索引都在,EXPLAIN 却走了全表——通常不是优化器的锅,是统计信息过期:删了几百万行后索引统计还停留在旧行数,回表成本被高估,优化器"理性地"放弃了索引。

ANALYZE TABLE t_log;               -- 重新采样统计信息
SELECT ... FORCE INDEX(idx_ua);    -- 业务上确认走某索引更优时兜底

force index 是应急手段不是长期方案,用了要在代码里留注释说明原因,否则表结构一变它就变成陷阱。

三篇合起来:一个心智模型

把系列收拢成三句话:

  1. 索引 = 磁盘上的有序树,以 16KB 页为读写单位,B+ 树的矮胖形状把 IO 压到个位数(第一篇)。
  2. 表本身就是树:聚簇索叶子存整行,二级索引叶子存主键,回表、覆盖、最左前缀、ICP 全是这棵树的推论(第二篇)。
  3. 读路径省的每一分钱,写路径都在付费:页分裂、页合并、change buffer 的账,加上函数、隐式转换、断左前缀这些让树"失效"的操作,本质都是让树失去有序性(本篇)。

判断一条 SQL 该不该建索引、为什么慢,不再需要背规则:回到"树是否有序、要读多少页、要不要回表"这三个问题,答案自己浮出来。

相关推荐

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/174