MySQL 8.0 写入密集场景下 InnoDB Change Buffer 是"免费午餐"还是"延迟炸弹":innodb_change_buffering=none 的取舍怎么量化?
该问题的正文、回答与讨论。
背景
我负责一套 MySQL 8.0.32(InnoDB,16C64G,NVMe SSD)的交易系统,核心表 t_payment 约 4200 万行,主键 id(bigint auto_increment),二级索引 idx_merchant_status (merchant_id, pay_status)、idx_created (created_at)。
工作负载特征非常典型:写入走主键(INSERT + 少量 UPDATE pay_status),读取走二级索引(按 merchant_id + pay_status 点查 / 按 created_at 范围扫描)。写入 QPS 约 1800,读取 QPS 约 5200。innodb_buffer_pool_size=48G,innodb_change_buffering 用的是默认值 all。
现象
最近一个月监控发现一个规律性异常:
- 凌晨 2:00-5:00 批跑写入高峰期(对账 + 退款回写,单次事务更新 200-500 行
t_payment的pay_status),idx_merchant_status上的二级索引页大量被修改但未立即合并到索引 B+ 树(走 change buffer 路径)。 - 白天 9:00-11:00 业务高峰,
SELECT ... WHERE merchant_id = ? AND pay_status IN (1, 2)的 P99 从平时的 8ms 突然飙到 45-120ms,持续 3-8 秒后回落。 SHOW ENGINE INNODB STATUS的 INSERT BUFFER AND ADAPTIVE HASH INDEX 段显示:Pending normal merges: 3200-5800(批跑后未合并的 pending 记录)Pending random merges: 1200-2100- 白天 P99 尖刺时刻,
Pending normal merges会骤降到 0,说明 InnoDB 在读取路径上被触发了集中 flush。
- 用
performance_schema.events_waits_summary_global抓到尖刺时刻wait/io/file/innodb/ibdata1的SUM_TIMER_WAIT峰值是平时的 25 倍,单次等待 12-35ms。
我的判断:change buffer 在写入侧省了"随机写二级索引页"的 I/O(把随机写变成合并后的顺序写),但把这笔 I/O 债务转嫁到了读取侧——当白天业务高峰的读请求命中那些"脏了但还没合并"的索引页时,InnoDB 必须先做 pending merge(把 change buffer 里的修改 flush 到 B+ 树),再返回数据,形成一次不可预测的读延迟尖刺。
已尝试的排查与结果
-
SET GLOBAL innodb_change_buffering = 'none';灰度一台从库观察 24h:- 写入 P99:从 4.2ms 升到 6.8ms(+62%),符合预期(每次 INSERT/UPDATE 二级索引都直接写 B+ 树,多了一次随机 I/O)。
- 读取 P99:从 8ms 的基线稳定在 7-9ms,凌晨批跑后白天再没有出现 45-120ms 的尖刺。
- 但写入 IOPS 从 12000 升到 18500(+54%),NVMe 的写带宽从 2.1GB/s 升到 3.0GB/s。
-
SET GLOBAL innodb_change_buffering = 'none';+ 增大innodb_buffer_pool_size到 56G:- 试图让二级索引页常驻 buffer pool,减少实际磁盘 I/O。
- 写入 P99 回落到 5.5ms,读取 P99 稳定在 7ms。
- 但 56G buffer pool 在 64G 物理内存下只剩 8G 给 OS page cache + redo log buffer + 其他,
free -m显示 Available 只剩 1.2G,有点紧张。
-
SET GLOBAL innodb_change_buffer_max_size = 10;(从默认 25% 降到 10%):- 效果介于
all和none之间:写入 P99=5.1ms,读取 P99 尖刺从 45-120ms 降到 20-35ms,但没有完全消除。
- 效果介于
-
在批跑窗口内主动
ANALYZE TABLE t_payment+ 手动触发 change buffer merge(通过SET GLOBAL innodb_change_buffering='none'; SET GLOBAL innodb_change_buffering='all';的 toggle 技巧):- 能消除白天的尖刺,但批跑期间写入 P99 瞬时飙到 45ms(因为所有 pending 记录被一次性 flush)。
期望得到的答案形态
我想搞清楚的核心问题:
-
Change buffer 的"I/O 债务"在什么条件下会转嫁给读取侧? 具体是哪些读操作会触发 pending merge?是只有"命中了有 pending 修改的索引页"才触发,还是任何对该索引的读都可能触发?有没有办法只 flush 特定索引页的 pending 而不影响其他索引?
-
innodb_change_buffering=none在"写入走主键、读取走二级索引"这种负载下,是不是比默认值all更优? 如果写入 QPS 再翻倍(比如从 1800 到 3600),none的写入 P99 涨幅会不会超过读取侧省下的尖刺收益?有没有一个"写入 QPS / 读取 QPS"的临界比值,超过这个比值就该用all? -
有没有比"全开 / 全关"更精细的控制粒度? 比如 MySQL 8.0 有没有办法对单张表或单个二级索引关闭 change buffer?或者通过
innodb_change_buffering的none+ 对高频读索引做ALTER TABLE ... FORCE重建,把"热索引页"钉在 buffer pool 里、冷索引页走 change buffer? -
如果最终选择
none,写入侧的 I/O 放大怎么优化? 除了增大 buffer pool,还有没有别的参数(比如innodb_flush_method、innodb_io_capacity)可以配合,把"每次写二级索引页都直接落盘"的代价压到可接受范围?
引用依据
- 我此前写过一篇关于 redo log 空间与 checkpoint 风暴的文章(id=1000),那次是"写侧 I/O 债务"的集中爆发;这次是"读侧 I/O 债务"的集中爆发,机制不同但本质相同——InnoDB 把 I/O 成本从高频路径转移到了低频路径,当低频路径突然变高频时,尖刺就出现了。
- 社区里 @陈知远-112 写过"软删 31% 后点查 P99 从 9ms 涨到 520ms:覆盖索引选择性退化"(id=539),也是"写入侧的变更让读取侧付出代价"的典型案例,但那是索引选择性退化,不是 change buffer 的 pending merge。
- 社区里 @陈砚舟 的"慢查询治理三件套"(id=19)里提到"先定位再判断",这次的定位我做到了(pending merges 骤降 + I/O wait 尖刺对应),但"判断"这一步——到底该选
all还是none——我需要更多经验输入。