结论先行:MySQL 8.0.3 起
innodb_change_buffering默认从all改成了none,绝大多数 8.0 新实例上「二级索引写合并」根本没开。对持续 INSERT 的流水表(非唯一二级索引、写入远多于点查),显式打开并调好 merge 时机,可以把写 P99 从 40ms+ 的周期性抖动压到 9ms 以内稳定。
一、现象与背景
我们有一套物流轨迹流水表 t_track_event,InnoDB、MySQL 8.0.32、单表 2.1 亿行、二级索引 3 个(idx_shipment_id、idx_node_code、idx_created_at,全部非唯一)。业务侧持续 INSERT,峰值 3200 行/秒,buffer pool 32GB。
监控上观察到的问题:
- 平均写耗时 8.4ms,P99 周期性冲到 41~47ms,周期大约 90~120 秒;
- 同一时段 buffer pool 命中率稳定在 99.2%+,不是缓存不够;
- redo log 写入平稳,没有 fsync 抖动;
- 磁盘 IOPS 在 P99 尖峰时出现 800+ 随机读的毛刺,平时只有 50~120。
一开始怀疑是 change buffer merge 在批量回刷索引页,但查 SHOW ENGINE INNODB STATUS 时愣住了:CHANGE BUFFER 段显示 Memory used: 0 KB、Pending reads/writes 全 0。
二、根因分析:change buffer 在 8.0 上默认是关的
InnoDB 的 change buffer 机制:对非唯一二级索引的 INSERT/UPDATE,如果目标索引页不在 buffer pool 里,InnoDB 不会立刻去磁盘读页,而是把「该插入/更新到哪棵 B+ 树的哪个位置」这个信息记录到 change buffer 的内存结构里,等后续真正访问该页时再「合并(merge)」回索引页。这样把随机读变成了内存追加,对写多读少的流水表非常划算。
但 MySQL 8.0.3 的 release note 里有一行容易被忽略:
The default value of the
innodb_change_bufferingvariable was changed fromalltonone.
也就是说,从 8.0.3 开始,新建实例的 change buffer 默认是关的。MySQL 5.7 默认是 all,很多从 5.7 升上来的实例在升级时没改配置,所以「change buffer 开着」是 5.7 时代的肌肉记忆,到 8.0 就失效了。
我们的 8.0.32 实例正是这种情况:innodb_change_buffering=none(默认值),change buffer 从未生效。那么 P99 抖动的来源是什么?
答案是:二级索引的随机读直接打到磁盘。每次 INSERT 一条流水,3 个非唯一二级索引各要定位插入点,如果目标叶页不在 buffer pool(2.1 亿行的索引页远超 32GB 可用),就要做一次 16KB 的随机读。3 个索引 × 每行 1 次随机读 = 每行 3 次随机 I/O。在 3200 行/秒的写入速率下,随机读 IOPS 周期性堆高,触发 OS 层 I/O 调度抖动,表现为 P99 尖峰。
三、具体做法
1. 打开 change buffer
SET GLOBAL innodb_change_buffering = 'all';
取值说明(5.7/8.0 通用):
| 值 | 含义 |
|---|---|
| none | 全部关闭(8.0.3+ 默认) |
| inserts | 只对 INSERT 记录 change buffer |
| updates | 只对 UPDATE 记录 change buffer |
| all | INSERT + UPDATE 都记录(5.7 默认) |
对流水表(INSERT 为主),inserts 和 all 差异不大,但如果有大批量 UPDATE(比如状态回刷),建议用 all。
2. 确认生效
SHOW VARIABLES LIKE 'innodb_change_buffer%';
期望输出(关键行):
innodb_change_buffering all
innodb_change_buffer_max_size 25
innodb_change_buffer_max_size 单位是 buffer pool 的百分比(0~255),不是 MB。25 意味着 change buffer 最多占 buffer pool 的 25%,32GB buffer pool → 最多 8GB 给 change buffer。对持续写场景这个值够用,不需要调大。
3. 观察 merge 行为
SHOW ENGINE INNODB STATUS\G
重点看 CHANGE BUFFER 段:
--- CHANGE BUFFER ---
Pending reads: 12
Pending writes: 847
Memory used: 204800 KB
Residual: 0
Pending reads:等待合并的「读」操作(通常是后续 SELECT 触发的 merge);Pending writes:等待合并的「写」操作(INSERT/UPDATE 的延迟落盘);Memory used:change buffer 实际占用的内存(KB);Residual:异常残留,正常应为 0。
健康状态:Pending writes 在几千以内、Memory used 不持续增长(说明 merge 跟得上)、Residual=0。
4. 配合参数(可选)
# my.cnf
[mysqld]
innodb_change_buffering = all
innodb_change_buffer_max_size = 25
# 如果 merge 跟不上,可以调高 purge 线程
innodb_purge_threads = 4
5. 验证效果
观察 1 小时写入监控:
- 写 P99 从 41~47ms → 9.2ms(稳定);
- 随机读 IOPS 毛刺从 800+ → 60~90(平稳);
- buffer pool 命中率从 99.2% → 99.7%(索引页的随机读被 change buffer 吸收了);
- redo log 写入量无明显变化(change buffer 不减少 redo,只是减少随机读)。
四、踩过的坑与反直觉之处
坑 1:唯一索引用不了 change buffer
change buffer 的前提是「不需要读页就能确定插入位置」。唯一索引必须检查唯一性,必须读页,所以唯一索引的 INSERT/UPDATE 不走 change buffer。如果你的表上 3 个二级索引里有一个是 UNIQUE INDEX,那个索引的写路径仍然要随机读。
坑 2:大范围扫描会「冲刷」change buffer
一旦有一条 SQL 对带 change buffer 的索引做了大范围扫描(比如 SELECT COUNT(*) WHERE idx_node_code BETWEEN 'A' AND 'Z'),InnoDB 会强制 merge 该索引的全部 change buffer 记录,瞬间产生大量随机读。
我们的线上就踩过:一个报表任务 SELECT ... WHERE created_at >= '2024-01-01' 走了 idx_created_at,一次 merge 了 3 天的 change buffer(约 1200 万条),磁盘随机读 IOPS 瞬间打到 2000+,同时段的在线写入 P99 飙到 80ms。
对策:大报表类查询走只读从库,或者用 IGNORE INDEX(idx_created_at) 强制走主键全扫,避免冲刷主库的 change buffer。
坑 3:8.0.3 默认 none 这个变更太隐蔽
MySQL 8.0.3 的 release note 里这一行改动没有任何大篇幅讨论,但它的实际影响是:所有从 5.7 升到 8.0 的实例,如果没有显式配置 innodb_change_buffering,change buffer 就悄悄关闭了。我们这次排查花了 2 小时才发现根因,就是因为团队里所有人都默认「change buffer 是开着的」。
坑 4:innodb_change_buffer_max_size 不是 MB
这个参数单位是 buffer pool 的百分比(0~255),不是 MB。写成 innodb_change_buffer_max_size = 1024 不会报错(会被 clamp 到 255),但语义完全不对。
五、结论与可复用清单
- 检查你的 8.0 实例:
SHOW VARIABLES LIKE 'innodb_change_buffering',如果是none,对写多读少的流水表考虑改成all或inserts。 - 适用条件:非唯一二级索引 + 写入远多于对该索引的点查 + 索引页超出 buffer pool(即有随机读发生)。
- 不适用:唯一索引、对该索引做频繁大范围扫描的表、写入量极低(<100 QPS)的表(change buffer 的 merge 开销可能大于收益)。
- 上线前压测:用 sysbench 或业务回放,对比开/关 change buffer 时的 P99 和随机读 IOPS,确认收益。
- 监控项:把
SHOW ENGINE INNODB STATUS的 CHANGE BUFFER 段接入监控,Pending writes > 10000或Residual > 0时告警。 - 报表隔离:对带 change buffer 的索引做大范围扫描的查询,路由到只读实例,避免冲刷主库。