结论先行:高频 UPDATE 低选择性列时,真正的瓶颈不是 CPU、不是主键索引,而是「二级索引 B+ 树的随机 I/O + 页分裂」,而 InnoDB 用来吸收这类成本的机制叫 change buffer,它的命中率直接决定你的 P99 是 8ms 还是 210ms。社区里讲加索引、拆表的很多,但很少人会把 change buffer 这一段单独拆出来看——这篇把一次真实的 P99 治理复盘写完整。
一、现象与背景:每秒 200 次 UPDATE,P99 从 8ms 涨到 210ms
去年给一套支付网关做慢查询治理。核心实例 MySQL 8.0.32(阿里云 16C64G、NVMe 云盘),buffer pool 32G。核心表 t_payment 规模 4200 万行、单表 6.8GB,有一个二级索引 idx_status(仅 status 一列,选择性极低,命中行约 8%)。业务侧每秒约 200 次 UPDATE t_payment SET status=? WHERE id=?,其中 60% 是状态流转(pending → paid / refunded),40% 是字段修正。
问题表现:
- 读侧
SELECT ... WHERE id=?的 P99 从 8ms 一路爬到 210ms,且随业务量线性上涨; - 云监控看到磁盘 util 长期 92%–97%,iowait 8%;
SHOW ENGINE INNODB STATUS里 History list length 稳定在 120000+,且Innodb_buffer_pool_wait_free在涨;- 慢日志里没有一条超过 1s 的读 SQL——慢的不是某条 SQL,是整张表的写路径把 buffer pool 挤爆了。
二、原理:一次 UPDATE 到底做了多少事
InnoDB 是聚簇索引组织,主键即数据行。一次 UPDATE t_payment SET status=? WHERE id=? 在引擎内部至少触发:
- 读主键页(大概率已在 buffer pool,因为按 id 点查是热路径);
- 写 undo log(用于回滚 + MVCC,redo 保护);
- 写 redo log(WAL,fsync 频率由
innodb_flush_log_at_trx_commit=1决定); - 维护所有包含
status列的二级索引:定位旧值在叶子页中的位置 → 删 → 按新值重新插入到新叶子页; - 若新值落到了不在 buffer pool 的叶子页,理想情况是 InnoDB 把这次改动暂存到 change buffer,等该页将来被读进来时再 apply;若该页已经在 buffer pool(热页),change buffer 不生效,直接内存改。
关键在第 4 步:一次 UPDATE 要维护 N 个二级索引,每个索引都是一次 B+ 树操作。而 idx_status 选择性低、扇出大,状态值只有 4 种(pending/paid/refunded/closed),意味着新值大概率落在少数几个叶子页——这几个叶子页是「热页」,被高频写、也高频读,常驻 buffer pool,change buffer 命中率必然低。
三、量化验证:change buffer 命中率才是真相
SHOW ENGINE INNODB STATUS 里的 Change buffer 段:
Change buffer usage, 6% out of max. 25%
Shared: 5%
.1024: 0%
...
Insert operations, 118342 out of 452113
Delete operations, 0 out of 0
Purge operations, 0 out of 99821
Record covers, 0 out of 0
Insert 命中率 118342/452113 ≈ 26%——远低于健康值(经验上 >60% 才说明 change buffer 在有效吸收 I/O)。
再交叉验证:
Innodb_buffer_pool_pages_data/Innodb_buffer_pool_pages_total= 0.985,数据页几乎占满 buffer pool,留给 change buffer 的空间被挤压;performance_schema.events_waits_summary_global_by_event_name里wait/io/file/innodb/ibdata1和wait/io/file/innodb/#innodb_redo合计占 71% 的 wait 时间——写路径 I/O 堆积实锤;pt-query-digest输出显示UPDATE t_payment的 99th percentile 是 380ms,而SELECT ... WHERE id的 99th 是 210ms——读被写的 I/O 拖累了。
四、具体做法:三步把 P99 拉回 12ms
Step 1:让 change buffer 有空间工作
-- 8.0 默认 innodb_change_buffering=all,不要动
-- innodb_change_buffer_max_size 默认 25,保持
-- 关键是把 buffer pool 从 32G 扩到 48G,给 change buffer + 数据页都留空间
SET GLOBAL innodb_buffer_pool_size = 51539607552; -- 48G,需重启
扩完 24h 后 change buffer Insert 命中率从 26% 升到 71%,History list length 从 120000+ 降到 8000 以内。
Step 2:把低选择性索引拆成复合索引,降低热页扇出
idx_status 单独一列、4 种取值,意味着所有状态变更都挤在 4 个叶子页,这是热页的根源。拆成 idx_status_create_time (status, create_time):
ALTER TABLE t_payment DROP INDEX idx_status,
ADD INDEX idx_status_create_time (status, create_time);
拆完后状态值按时间分散到数百个叶子页,单页写压力降 2 个数量级;同时业务侧「按状态 + 时间范围查」的高频 SQL 也直接受益(覆盖索引),一石二鸟。
Step 3:批量 UPDATE 分批 + 控制 redo 同步压力
把应用侧「循环单条 UPDATE」改成「每 500 行一批 + 显式 COMMIT」:
# 伪代码:原逻辑是 for row in rows: update_one(row)
batch = 500
for i in range(0, len(rows), batch):
cur.execute_many(update_sql, rows[i:i+batch])
conn.commit()
分批后 Innodb_log_waits 从 4200/h 降到 300/h,单批耗时稳定在 30–80ms。
结果:读侧 P99 从 210ms 回落到 12ms,磁盘 util 从 95% 降到 38%,iowait 降到 1.2%。
五、踩过的坑与反直觉之处
- change buffer 只对「页不在内存中」的修改有效。
idx_status这种 4 种取值、热页常驻的索引,change buffer 命中率天然低——这时调 change buffer 参数没用,应该拆索引降低热页扇出。 Innodb_buffer_pool_pages_data接近Innodb_buffer_pool_pages_total时,change buffer 能用的空间极少——先扩 buffer pool 再谈 change buffer,顺序不能反。innodb_change_buffering=all是 8.0 默认,但很多老运维手册建议改成inserts或none——这会直接砍掉 change buffer 的核心收益(delete mark + purge 缓存),在没有明确理由前不要动这个参数。- History list length 高 ≠ purge 参数没调对。120000+ 的根因是「长事务 + 高频 UPDATE 产生大量 undo 无法回收」,先
pt-kill清掉长事务,再考虑innodb_purge_batch_size(默认 300)和innodb_purge_rseg_truncate_frequency(默认 128)。 ALTER TABLE ... ENGINE=InnoDB重建表会锁表,4200 万行的表重建耗时约 22 分钟,必须低峰期做;8.0 的ALTER TABLE ... ALGORITHM=INPLACE对加索引是 online 的,但重建整表不是,要预留窗口。
六、结论与可复用清单
- 高频 UPDATE 低选择性列 → 先查
SHOW ENGINE INNODB STATUS的 Change buffer 段,Insert 命中率 < 50% 且二级索引页常驻 → 拆索引(加第二列扩散热页)而不是调参数。 Innodb_buffer_pool_pages_data/Innodb_buffer_pool_pages_total> 0.95 → buffer pool 太挤,先扩容再谈其他调优。- History list length > 100000 → 先查长事务(
information_schema.INNODB_TRX),再动 purge 参数。 - 批量 UPDATE 一定要分批(500–2000 行/批)+ 显式 COMMIT,避免单事务 redo 堆积。
- 二级索引扇出 > 10000 行/值(低选择性)→ 考虑拆成复合索引或换一张「状态表」分离。
- 关键参数基线(MySQL 8.0):
innodb_change_buffering=all、innodb_change_buffer_max_size=25、innodb_purge_batch_size=300、innodb_flush_log_at_trx_commit=1(支付类业务不要动这个)。
写这篇的动机:慢查询治理社区文章很多,但绝大多数聚焦「读侧优化」(索引、改写、拆表),写侧的二级索引维护成本被严重低估。下次再遇到「P99 涨但慢日志里没慢 SQL」,先把 change buffer 命中率拿出来看,往往比 EXPLAIN 更有信息量。