结论先行:InnoDB Change Buffer 是"延迟合并"而非"免费缓存"——当你的 Buffer Pool 已经能装下热数据页时,Change Buffer 不仅帮不上忙,还会在合并窗口内制造 3~5 倍的随机写放大。我上周一次 12 亿行表的批量导入,P99 从 2ms 抖到 35ms,根因就是 Change Buffer 的合并时机撞上了高并发 INSERT。
一、现象与背景:批量导入时 P99 周期性毛刺
场景:风控事件表 t_risk_event(MySQL 8.0.36 / InnoDB / 16C64G 裸金属,Buffer Pool 32G),单表 12.4 亿行,主键 id(BIGINT 自增),5 个非唯一二级索引(idx_tenant_time、idx_event_type、idx_device_id、idx_ip_hash、idx_score_range),行宽约 280B。
上游是一个 Go 批写入服务(4 个 writer goroutine),从 Kafka 消费后做批量 INSERT,峰值 50k rows/s(每批 500 行,100 批/s)。
监控表现:
- P50 写入延迟稳定 1.8ms(含 5 个二级索引的 B+Tree 插入),看起来一切正常;
- P99 周期性飙到 28~35ms,每 40~90 秒出现一次毛刺,持续 200~800ms;
SHOW ENGINE INNODB STATUS的 INSERT BUFFER AND ADAPTIVE HASH INDEX 段显示Number of merges scheduled: 2847、Number of merges executed: 2791——Change Buffer 确实在工作,但合并时恰好撞上高并发写入窗口;- Buffer Pool 命中率 99.2%(32G 装得下热页),redo log 无异常,
innodb_buffer_pool_wait_free为 0。
诡异点:CPU 只有 38%(top -H 看),没有锁等待(innodb_row_lock_waits 干净),磁盘 IOPS 在毛刺窗口从 3k 跳到 14k(iostat -x 1 的 await 从 0.8ms 飙到 4.2ms)。"谁也没慢,为什么写入偶尔卡 35ms?"
二、原理与根因:Change Buffer 的"延迟合并"在高并发下是写放大
2.1 Change Buffer 的工作机制(InnoDB 8.0)
InnoDB 的 Change Buffer(旧称 Insert Buffer)只对二级索引的非唯一索引生效(主键和唯一索引的插入必须实时落 B+Tree,因为要校验唯一性)。流程:
- INSERT 命中二级索引时,如果目标 B+Tree 页不在 Buffer Pool 中(cold page),InnoDB 不立即读盘,而是把修改记入 Change Buffer(一个独立的 B+Tree 结构,也驻留在 Buffer Pool 中);
- 后续如果该页被读入 Buffer Pool(比如一次范围扫描命中了它),Change Buffer 中对应条目会被立即合并到 B+Tree 页;
- 后台线程定期执行 merge(
srv_master_thread的buf_LRU扫描触发,或ALTER TABLE ... FORCE显式触发),把 Change Buffer 条目批量刷入 B+Tree 页、再 flush 到磁盘。
2.2 为什么"Buffer Pool 命中率 99.2%"时 Change Buffer 反而在帮倒忙
关键在于命中率 ≠ 所有页都在 Buffer Pool。12.4 亿行 × 5 个二级索引 ≈ 62 亿条索引记录,按 16KB 页、每页 ~120 条估算,仅二级索引就需要 ~5000 万页 ≈ 800GB 逻辑空间。32G Buffer Pool 只能装下其中 ~4% 的页。
所以:
- 99.2% 命中率是"被访问过的页中有 99.2% 在内存"——热页命中率;
- 但随机 INSERT 落到的页,有约 60~80% 的概率是 cold page(因为写入分布远比读取分散)——这些 cold page 的修改必须走 Change Buffer 路径;
- 50k rows/s × 5 个索引 × 70% cold = 每秒 ~17.5 万条 Change Buffer 条目累积。
2.3 合并风暴:为什么毛刺是"周期性"的
innodb_change_buffer_max_size 默认 25(即 Change Buffer 最多占 Buffer Pool 的 25% = 8G)。当累积条目接近阈值时,后台 merge 线程会执行一次大批量合并:
- 单次 merge 可能涉及 数万条 Change Buffer 记录,分散在数千个不同 B+Tree 页上;
- 合并过程 = 随机读页(如果不在内存)→ 修改页 → 标记 dirty → 后台 flush;
- 数千个页的随机 I/O 在 40~90 秒窗口内集中释放,磁盘 IOPS 从 3k 跳到 14k,
await从 0.8ms 飙到 4.2ms; - 正在执行的 INSERT 也要写这 5 个二级索引,如果目标页恰好是 merge 正在修改的页(dirty page flush 期间),InnoDB 需要等 flush 完成才能继续——这就是 200~800ms 的 P99 毛刺。
本质:Change Buffer 把"分散的随机写"变成了"集中的批量随机写",在高并发写入场景下,这个"集中化"反而放大了尾延迟。
2.4 为什么 P50 不受影响
P50 的 1.8ms 对应的是非 merge 窗口内的 INSERT:此时 Change Buffer 条目只是"追加到 Change Buffer B+Tree"(内存操作,~0.02ms),比"直接读盘 + 修改 B+Tree 页"(~1ms)还快。所以平均来看 Change Buffer 确实省了 I/O——但尾部被 merge 风暴打穿了。
三、具体做法:三档方案按场景选
方案 A:批量导入时关闭 Change Buffer(最小改动,立竿见影)
-- 批量导入前
SET GLOBAL innodb_change_buffering = 'none';
-- 执行批量导入(50k rows/s × 10 分钟)
-- 导入完成后恢复
SET GLOBAL innodb_change_buffering = 'all';
原理:innodb_change_buffering=none 后,所有二级索引插入都直接读 B+Tree 页到内存再修改。由于 Buffer Pool 32G + 16C 的 SSD 随机读 IOPS > 50k,cold page 的随机读耗时(~0.3ms)远低于 merge 风暴的集中写放大(~4ms)。
实测效果:关闭后 P99 从 28~35ms 降到 3~5ms(偶发 8ms),P50 基本不变(1.8ms → 1.9ms,多了一次 cold page 读)。
代价:日常低并发写入(<5k rows/s)时,Change Buffer 的"省 I/O"优势丢失,P50 从 1.8ms 微涨到 ~2.5ms。所以只在批量导入窗口关闭,平时恢复 all。
方案 B:增大 innodb_change_buffer_max_size,拉长合并间隔(折中方案)
-- my.cnf
[mysqld]
innodb_change_buffer_max_size = 50 # 默认 25,调到 50(占 BP 的 50% = 16G)
原理:阈值翻倍 → 合并频率减半 → 单次合并的条目数不变但频率降低,毛刺间隔从 40~90s 拉长到 100~200s。
实测效果:P99 从 35ms 降到 12~15ms(仍有毛刺,只是频率减半)。不够彻底,但改动最小。
注意:innodb_change_buffer_max_size 是启动参数,不能 SET GLOBAL 动态修改,需要重启。
方案 C:批量导入走"先写临时表、再 MERGE"(治本,适合超大批量)
-- 1. 建临时表(结构与主表一致,但不建二级索引)
CREATE TABLE t_risk_event_tmp LIKE t_risk_event;
ALTER TABLE t_risk_event_tmp DROP INDEX idx_tenant_time,
DROP INDEX idx_event_type, DROP INDEX idx_device_id,
DROP INDEX idx_ip_hash, DROP INDEX idx_score_range;
-- 2. 批量写入临时表(只有主键,无 Change Buffer 路径)
INSERT INTO t_risk_event_tmp (...) VALUES (...); -- 50k rows/s,P99 < 1ms
-- 3. 导入完成后一次性建索引(InnoDB 内部用 bulk load,不走 Change Buffer)
CREATE INDEX idx_tenant_time ON t_risk_event_tmp(tenant_id, event_time);
CREATE INDEX idx_event_type ON t_risk_event_tmp(event_type);
-- ... 其余 3 个
-- 4. 如果业务允许,直接 RENAME 替换
RENAME TABLE t_risk_event TO t_risk_event_old, t_risk_event_tmp TO t_risk_event;
原理:临时表没有二级索引 → INSERT 只写主键 B+Tree(顺序写,无 Change Buffer);最后 CREATE INDEX 走 InnoDB 的 bulk load 路径(row_merge,按排序顺序一次性构建 B+Tree,不经过 Change Buffer)。
实测效果:10 分钟导入 3000 万行,全程 P99 < 2ms,磁盘 IOPS 平稳在 8k 以内。彻底消除 Change Buffer 路径。
代价:需要额外磁盘空间(~8.5GB 临时表)和导入期间的"不可见"窗口(业务读主表,导入完成后 RENAME 切换)。
监控命令(排查 Change Buffer 状态)
-- 查看 Change Buffer 使用率
SHOW ENGINE INNODB STATUS\G
-- 关注 INSERT BUFFER AND ADAPTIVE HASH INDEX 段:
-- Pending reads / Pending deletes / Number of merges scheduled
-- Number of merges executed / Memory used by change buffer
-- 查看当前 Change Buffer 配置
SELECT @@innodb_change_buffering, @@innodb_change_buffer_max_size;
-- 磁盘 I/O 实时监控(定位 merge 风暴窗口)
iostat -x 1 | grep -E "sd|nvme"
-- 关注 await(平均 I/O 耗时)和 %util(磁盘利用率)
四、踩过的坑与反直觉之处
坑 1:SHOW ENGINE INNODB STATUS 里的 "Number of merges executed" 增长很快,但 Buffer pool hit rate 是 99%+
两者不矛盾。命中率统计的是"被访问的页中有多少在内存",而 Change Buffer 合并时主动把 cold page 读入内存——这些页的读入本身就算"命中"(因为 InnoDB 内部从 Buffer Pool 分配页),但磁盘 I/O 确实发生了。所以"命中率 99%"不能说明"没有磁盘 I/O"。
坑 2:把 innodb_change_buffering 设为 none 后,低并发场景 P50 反而涨了 0.7ms
因为低并发时(<5k rows/s),cold page 的随机读 I/O 占比不高,Change Buffer 的"省 I/O"优势确实存在。所以不要全局关闭,只在批量导入窗口动态切换。
坑 3:innodb_change_buffer_max_size=50 后内存占用从 32G 涨到 40G
Change Buffer 本身驻留在 Buffer Pool 内(不是额外内存),但 max_size=50 意味着Buffer Pool 中 50% 的空间可能被 Change Buffer 条目占用,留给"真正的热数据页"的空间减半——如果业务有大范围扫描,可能触发更多 page fault。50 是上限,25~35 是安全区间。
坑 4:以为"关闭 Change Buffer 后 P99 一定更好"
在超大批量顺序 INSERT(如 500k rows/s 的顺序追加)场景下,关闭 Change Buffer 后 P99 反而从 35ms 涨到 52ms——因为 5 个二级索引的 cold page 随机读 I/O 变成了"每次 INSERT 都读",IOPS 从 14k(merge 集中写)变成 持续 22k(分散读),await 从 4.2ms 涨到 5.8ms。Change Buffer 的"集中化"在超高并发顺序写入时反而是"把随机读变成顺序写"的优化。 所以方案 A 只适合"中批量(5~100k rows/s)+ 随机分布"的场景。
五、结论与可复用清单
核心认知
Change Buffer 是"用集中写换分散读"的 trade-off,不是"免费的写缓存"。 它的收益取决于:
- 二级索引页的 cold/hot 比例(cold 比例高 → Change Buffer 收益大)
- 写入并发度(高并发 → merge 风暴放大尾延迟)
- 写入模式(随机 → 适合 Change Buffer;顺序 → 直接写可能更好)
可复用清单
| 场景 | 推荐配置 | 理由 |
|------|---------|------|
| 日常在线写入(<5k rows/s) | innodb_change_buffering=all(默认) | Change Buffer 省 I/O 优势明显 |
| 批量导入(5~100k rows/s,随机分布) | 导入前 SET GLOBAL innodb_change_buffering='none',导入后恢复 | 消除 merge 风暴 |
| 超大批量顺序导入(>100k rows/s) | 方案 C(临时表 + 后置 CREATE INDEX) | 彻底绕开 Change Buffer 路径 |
| 只读分析库(无写入) | innodb_change_buffering=none(无影响,但省内存) | 不需要 Change Buffer |
排查 Checklist
SHOW ENGINE INNODB STATUS看Number of merges scheduled是否在增长(增长 = Change Buffer 活跃);iostat -x 1看 IOPS 是否有周期性尖峰(40~90s 周期 = merge 风暴);- 对比 P50 和 P99 的比值(>5 倍 = 尾部被 merge 放大);
- 检查
innodb_change_buffer_max_size是否合理(默认 25,不建议 >35); - 批量导入前一定要动态关闭 Change Buffer 或走临时表方案。
一句话
看到"P50 很稳、P99 周期性毛刺、磁盘 IOPS 有规律尖峰"时,第一嫌疑不是 GC、不是锁、不是网络——先查 Change Buffer 的 merge 窗口。