MySQL InnoDB 刷脏与 checkpoint 实战:从写入高峰吞吐骤降到 redo 扩容与匀速刷脏
一、具体的问题某订单系统 MySQL 5.7(自建,NVMe SSD),日常 TPS 稳定在 8000 左右,但每天上午 10 点左右写入高峰,TPS 会毫无征兆地从 8000 掉到几百,持续十几秒到两三分钟不等,期间 Threads_running 冲到 200+,等恢复后又一切正常。DBA 第一反应是慢 SQL,但 binlog 里高峰期的语句平日也就几毫秒;再看监控,CPU、磁盘 util 都不高,IOPS 甚至比平时还低——机器明明闲着,写入却堵死了。
另一个症状在重启时暴露:某次主机故障重启,InnoDB 崩溃恢复花了 23 分钟,期间实例完全不可用。恢复时间和两个参数直接相关——redo log 总容量太小、脏页积压太多。
把这两个现象连起来看,根因是同一个:redo log 容量 96MB(5.7 默认 2×48MB),对一张每小时产生十几 GB redo 的订单主库来说,checkpoint 根本推进不动,写入高峰时 redo 快写满,InnoDB 被迫进入激进的同步刷脏模式,把宝贵的写 IOPS 全部拿去洗脏页,业务线程反而被饿死。
二、核心原理
1. redo log 是个环形管道。 InnoDB 每改一页,先把物理变更写进 redo log(顺序写),再慢慢把脏页刷回数据文件(随机写)。redo log 文件组容量固定、循环覆写——写到底就回头从头写,但复写的前提是:被覆盖的位置之前的脏页必须已经落盘。这个"最多还能写多少"就是 checkpoint age。
2. checkpoint age 撞线的两个阈值。 当 checkpoint age 超过日志总容量的约 75%,InnoDB 进入异步刷脏:page cleaner 全力洗脏页,业务还能写但开始抖;逼近 90% 左右,转入同步刷脏——业务线程被拉去参与刷脏,每个写事务都被拖着洗脏页,这就是 TPS 断崖的直接原因。日志容量越小,同样的写入速率下越容易撞线。
3. 脏页比例也会触发刷脏。 innodb_max_dirty_pages_pct(默认 75%)是软上限,超过后 page cleaner 加速;innodb_max_dirty_pages_pct_lwm(5.7 新增,默认 0)可以提前开低水位刷脏。IO 能力不足时,innodb_io_capacity(默认 200)直接决定 page cleaner 每秒最多洗多少页——机械盘时代 200 合理,SSD 上就是摆设。
4. 双写(doublewrite)不是这里的问题。 刷脏要经过 doublewrite buffer(防部分写失效),它带来的开销是固定的;刷脏风暴的根子在"单位时间要洗的页超过了 io_capacity 配额"。反过来,innodb_flush_neighbors(默认 1,会把相邻脏页顺带洗掉)在机械盘上能合并寻道,在 SSD/NVMe 上纯粹增加写入放大,应设为 0。
5. 自适应刷脏(adaptive flushing)是缓冲垫,不是替身。 5.6 起默认开启 innodb_adaptive_flushing:InnoDB 根据 redo 产生速度估计"再过多久日志会绕回",提前加大刷脏力度。innodb_adaptive_flushing_lwm(默认 10)表示 checkpoint age 超过日志容量 10% 就开始介入。它能摊平轻度高峰,但日志容量太小时,摊平机制本身就变成了高峰——毕竟单位时间里要洗的页总量没变,只是从"突击"变成"长期加班"。所以顺序永远是:先把管道扩够,再谈刷脏策略。
6. 容量怎么定。 经验公式:redo 总容量 ≥ 高峰期 1 小时的 redo 产生量(可从 Innodb_os_log_written 增量估算)。日志够大,checkpoint age 长期低位,刷脏就能匀速进行,而不是被迫在高峰期突击。8.0.30 起可用 innodb_redo_log_capacity 在线调整,5.7 只能改参数重启。
三、实例参考(动手步骤)
步骤 0:确认现状基线
-- redo 容量与 checkpoint 位置(5.7 看 LOG 段)
SHOW ENGINE INNODB STATUS\G
-- 关注 LOG 段三行:
-- Log sequence number 当前 LSN
-- Log flushed up to 已刷盘 LSN
-- Last checkpoint at 上次检查点 LSN
-- checkpoint age ≈ Log sequence number - Last checkpoint at
-- 每分钟 redo 产生量(隔 60 秒采两次)
SHOW GLOBAL STATUS LIKE 'Innodb_os_log_written';
-- 脏页水位
SELECT variable_name, variable_value
FROM performance_schema.global_status
WHERE variable_name IN ('Innodb_buffer_pool_pages_dirty',
'Innodb_buffer_pool_pages_total');
-- 当前刷脏参数
SHOW VARIABLES WHERE variable_name IN
('innodb_log_file_size','innodb_log_files_in_group',
'innodb_io_capacity','innodb_io_capacity_max',
'innodb_flush_neighbors','innodb_max_dirty_pages_pct',
'innodb_page_cleaners','innodb_buffer_pool_size');
该案例实测:redo 总量 96MB,高峰期每分钟 redo 约 220MB,checkpoint age 一度冲到 91%;脏页占比 71%;innodb_io_capacity=200、flush_neighbors=1。三项全踩。
步骤 1:先在线补短板——IO 配额与刷脏策略(5.7 可热更)
SET GLOBAL innodb_io_capacity = 2000; -- NVMe 按 4K 随机写 IOPS 的 1/4~1/2 估
SET GLOBAL innodb_io_capacity_max = 4000; -- 撞线时的冲刺配额
SET GLOBAL innodb_flush_neighbors = 0; -- SSD 无需邻页合并
SET GLOBAL innodb_max_dirty_pages_pct = 60; -- 提前压水位
SET GLOBAL innodb_max_dirty_pages_pct_lwm = 10;-- 10% 即开低水位匀速刷脏
改完观察十几分钟:TPS 抖动幅度明显收窄,但 10 点高峰仍会撞——因为 96MB 的日志容量是硬伤,热改不了。
步骤 2:扩 redo log(5.7 需停机窗口)
# my.cnf(5.7)
innodb_log_files_in_group = 4
innodb_log_file_size = 2G -- 总容量 8G
5.7 收缩日志需要"干净关库 → 删旧 ib_logfile* → 重启",扩容同样要重启;确认 innodb_fast_shutdown=1 正常关闭后再替换。8.0.30+ 则一行搞定且在线生效:
SET GLOBAL innodb_redo_log_capacity = 8 * 1024 * 1024 * 1024; -- 8G
SELECT variable_name, variable_value
FROM performance_schema.global_status
WHERE variable_name LIKE 'Innodb_redo_log%';
-- Innodb_redo_log_capacity_resized 确认已生效
步骤 3:从源头看清"谁在产 redo"
扩容只是续命,如果 redo 量本身异常偏高,要回到业务侧找源头:
-- 用 performance_schema 找高峰期写入最多的表
SELECT object_schema, object_name, count_write, sum_timer_write
FROM performance_schema.table_io_waits_summary_by_table
WHERE object_schema NOT IN ('mysql','performance_schema','sys')
ORDER BY count_write DESC LIMIT 10;
-- 大事务排查:一次改几十万行的一条 UPDATE,其 redo 是逐行累计的
-- 业务侧拆批:每批 2000~5000 行,批间 sleep,把 redo 产生量在时间轴上摊平
该案例顺带发现:凌晨批处理任务一条 UPDATE 扫 900 万行,凌晨本应平稳的 checkpoint age 也顶到 60%——拆批后凌晨峰值降到 22%。
步骤 4:复核与固化
-- 高峰期再看 checkpoint age 占比(目标 < 容量的 40%)
-- 再次采样脏页比例(目标长期 < 40%)
SELECT variable_value/variable_total*100 AS dirty_pct
FROM (SELECT
MAX(CASE WHEN variable_name='Innodb_buffer_pool_pages_dirty'
THEN variable_value END) AS variable_value,
MAX(CASE WHEN variable_name='Innodb_buffer_pool_pages_total'
THEN variable_value END) AS variable_total
FROM performance_schema.global_status
WHERE variable_name IN ('Innodb_buffer_pool_pages_dirty',
'Innodb_buffer_pool_pages_total')) t;
治理前后对比
指标治理前治理后
redo 总容量96MB(2×48M)8G(5.7: 4×2G)
checkpoint age 峰值占比91%(同步刷脏)33%(匀速刷脏)
高峰期 TPS8000 → 骤降 500稳定 8600 左右
P99 写入延迟850ms(尖刺)14ms
脏页比例(高峰)71%38%
崩溃恢复时间23 分钟1 分 40 秒
每 10 秒 InnoDB fsync 抖动频繁 >2000ms基本 <50ms
四、实操检查清单
[*][ ] 已用 SHOW ENGINE INNODB STATUS 的 LOG 段算出 checkpoint age,并明确它占日志容量的百分比
[*][ ] 已采样高峰期每分钟 redo 产生量,日志总容量 ≥ 高峰 1 小时 redo 量
[*][ ] innodb_io_capacity 已按存储介质的实际随机写 IOPS 重新评估,不再停留在默认 200
[*][ ] innodb_io_capacity_max 已设置且约为 io_capacity 的 1.5~2 倍
[*][ ] SSD/NVMe 上 innodb_flush_neighbors=0 已确认
[*][ ] innodb_max_dirty_pages_pct_lwm 已设置低水位(如 10),让刷脏匀速前移而非事后突击
[*][ ] 脏页比例长期监控,高峰期不超过 40%~50%
[*][ ] 5.7 改日志容量已排停机窗口,且按"干净关库→删旧日志→重启"流程执行
[*][ ] 8.0.30+ 优先用 innodb_redo_log_capacity 在线调整,变更后确认 Innodb_redo_log_capacity_resized
[*][ ] 重启演练过一次,崩溃恢复时间已纳入监控(恢复时间随日志容量变大而换来的前提是刷脏跟得上)
[*][ ] 未为了"提吞吐"关闭 doublewrite(部分写失效风险不可接受)
几个容易踩的坑
[*]只调 io_capacity 不扩日志。 配额提上去了,96MB 的管道照样在高峰撞线,抖动只是推迟几分钟。两者必须一起看。
[*]io_capacity 盲调过大。 直接给 10000,page cleaner 疯狂洗页挤占业务 IOPS,读延迟反而恶化。按介质实际能力给,用压测验证。
[*]5.7 收缩日志图省事直接删文件。 非干净关闭状态删 ib_logfile* 会丢失未刷盘事务,实例起不来甚至数据不一致。收缩必须干净关库。
[*]把 binlog 的 fsync 和 redo 的 fsync 混为一谈。 sync_binlog/binlog_group_commit 管的是 binlog 侧,本篇抖动的根因在 redo 侧 checkpoint,排查时别顺着 binlog 走偏。
[*]innodb_flush_method 没检查。 部分 OS 默认未开 O_DIRECT,双重缓存放大脏页管理压力;文件系统缓存导致的"看似内存够用实际 IOPS 高"与此有关。
[*]崩溃恢复时间与日志容量是权衡。 日志给到 8G,理论上最坏恢复时间变长——但前提是允许脏页积压到那么晚。配合低水位刷脏(lwm)后,实际恢复时间反而更短,不要因噎废食。
[*]cloud RDS 不暴露 redo 参数时,优先调 io_capacity 类参数并压测确认写瓶颈;若仍撞线,考虑拆分大事务或错峰批量写,从源头减少 redo 产生量(大事务里一批 UPDATE 的 redo 是逐行累计的)。
[*]忽略 Innodb_log_waits 这个信号灯。 它记录的是"日志缓冲太小、写 redo 被迫等待"的次数,很多人排查时只盯 checkpoint,结果真凶是 innodb_log_buffer_size(默认 16MB)扛不住批量写。若该值持续增长,把日志缓冲扩到 64MB~128MB 再看,成本极低。
页:
[1]