前两篇讲的都是"索引怎么让读变快"。这篇补上另一半:写路径。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 结果对照:
| SQL | type | key | rows | 问题 |
|---|---|---|---|---|
WHERE user_id=100 AND action='pay' | ref | idx_ua | 12 | 满配 |
WHERE user_id=100 | ref | idx_ua | 900 | 正常 |
WHERE action='pay' | ALL | NULL | 500万 | 断最左,全表 |
WHERE user_id>100 AND action='pay' | range | idx_ua | 40万 | 只用了 user_id |
WHERE phone='13800138000' | ALL | NULL | 500万 | 没有对应索引 |
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 是应急手段不是长期方案,用了要在代码里留注释说明原因,否则表结构一变它就变成陷阱。
三篇合起来:一个心智模型
把系列收拢成三句话:
- 索引 = 磁盘上的有序树,以 16KB 页为读写单位,B+ 树的矮胖形状把 IO 压到个位数(第一篇)。
- 表本身就是树:聚簇索叶子存整行,二级索引叶子存主键,回表、覆盖、最左前缀、ICP 全是这棵树的推论(第二篇)。
- 读路径省的每一分钱,写路径都在付费:页分裂、页合并、change buffer 的账,加上函数、隐式转换、断左前缀这些让树"失效"的操作,本质都是让树失去有序性(本篇)。
判断一条 SQL 该不该建索引、为什么慢,不再需要背规则:回到"树是否有序、要读多少页、要不要回表"这三个问题,答案自己浮出来。