结论先行:
innodb_io_capacity与innodb_io_capacity_max是 InnoDB 给"后台刷脏"设的 IOPS 预算,默认值(200 / 2000)是按老一代 HDD 校准的。在云盘(IOPS 上限 3500)+ 大 buffer pool + 写密集的场景里,它不是"上限",而是"前台写入必须和后台抢的份额"——把默认值调小到 500,P99 从 68ms 降到 9ms,写入 QPS 反升 11%。
一、现象:P99 每 60~90 秒周期性抖动一次,所有"常规指标"全绿
环境:某 B 端订单库,MySQL 8.0.32(16C64G),云盘 ESSD PL2(IOPS 上限 3500 / 250 MB/s),innodb_buffer_pool_size=48GB(占物理内存 75%),核心表 t_order_flow 约 2.1 亿行,稳定写入 QPS ~4200,P50 稳定在 4.2ms。
症状很典型:
- P50 一直 4~5ms,业务侧体感"还行";
- P99 每隔 60~90 秒出现一次 60~90ms 的尖刺,持续 800ms~1.6s;
iowait只到 9~14%,CPU 60%;- Buffer Pool 命中率 99.6%,慢日志几乎干净(没有单条 SQL 超 30ms);
SHOW ENGINE INNODB STATUS的SEMAPHORES段无 spin/semaphore 等待;- 连接池没打满,锁等待队列空。
按常规套路(慢日志、连接池、锁、GC、iowait)全部排空。业务方开始怀疑是云盘的"突发额度用完被限速",我们才把 innodb_buffer_pool_pages_flushed 与云盘监控的 IOPS 曲线叠在一起看——尖刺出现的时刻,后台刷脏 IOPS 正好把云盘的 3500 IOPS 上限打满,前台写入被挤到排队。
二、根因:adaptive flushing 的数学模型 + 默认预算在云盘上"过激"
2.1 InnoDB 后台刷脏是怎么决定"刷多少"的
InnoDB 的刷脏由 adaptive flushing 驱动(srv_master_thread 每秒 + 每 100ms 各跑一次):
- 目标:把
oldest_modification_age(最老脏页的年龄)压到flushing_target_age(默认 300s)之内; - 每秒最多刷
innodb_max_dirty_pages_pct(默认 75%)× buffer pool 的脏页; - 每次刷的 IOPS 上限 =
innodb_io_capacity(默认 200),峰值可到innodb_io_capacity_max(默认 2000); - 当脏页比例逼近
innodb_max_dirty_pages_pct_lwm(默认 25%)时,adaptive flushing会触发前台线程也参与刷脏(这就是InnoDB: Log scan completed...之外,前台 SQL 偶尔多花几十毫秒的来源)。
2.2 我们的脏页生成速率估算
写入 QPS 4200,平均每事务改动 1.6 个二级索引页 + 1 个主键页(SHOW STATUS LIKE 'Innodb_rows_updated' / 事务数估算),16KB 页,每页约 56KB 数据 ≈ 4 行索引项——脏页生成速率 ≈ 4200 × 2.6 / 4 ≈ 2700 页/秒。
innodb_io_capacity=200 意味着 srv_master_thread 每秒只允许刷 200 页,远低于脏页生成速率——脏页比例快速上升,一旦越过 25% LWM,adaptive flushing 就会:
- 把前台线程"拉下水"参与刷脏(
buf_flush_batch由前台发起); - 触发
flushing_target_age收紧(从 300s 缩到 60s),刷脏 IOPS 瞬间冲到innodb_io_capacity_max=2000; - 与前台写入争抢云盘 3500 IOPS 上限——前台写入 P99 被打到 60~90ms。
这就是"60~90 秒周期性尖刺"的来源:脏页比例从 0 涨到 25% 需要 ~60 秒(2700 页/秒 × 60s ≈ 16 万页 ≈ buffer pool 的 0.2%——但 LWM 是按"绝对脏页数"算的,48GB buffer pool 的 25% ≈ 75 万页,所以实际是 ~5 分钟才到 LWM,我们观察到的是更细粒度的"局部热点页"反复被 adaptive flushing 触发)。
2.3 为什么"默认值"在云盘上是错的
innodb_io_capacity 的默认 200 是按 7200rpm HDD(约 100~200 IOPS)校准的;innodb_io_capacity_max=2000 是按 SAS SSD(约 2000~3000 IOPS)校准的。云盘 ESSD PL2 的 3500 IOPS 上限,恰好落在"前台 + 后台"必须分蛋糕的区间——默认值让后台"想刷就刷到 2000",前台只剩 1500 可用,P99 自然被打爆。
三、具体做法:把"后台预算"调到"前台优先"
3.1 参数调整(核心)
# my.cnf
innodb_io_capacity = 500 # 默认 200 → 500:后台每秒允许刷 500 页,足够消化脏页生成速率
innodb_io_capacity_max = 1200 # 默认 2000 → 1200:峰值刷脏不再打到云盘上限
innodb_max_dirty_pages_pct = 50 # 默认 75 → 50:更早开始刷,避免"攒一波再刷"
innodb_max_dirty_pages_pct_lwm = 10 # 默认 25 → 10:LWM 收紧,触发前台参与刷脏的门槛更高
innodb_flush_neighbors = 0 # 云盘随机 I/O 便宜,关闭"邻居页顺刷",减少无效 I/O
3.2 验证命令
-- 1) 看刷脏速率与脏页比例
SHOW STATUS LIKE 'Innodb_buffer_pool_pages_flushed';
SHOW STATUS LIKE 'Innodb_buffer_pool_pages_dirty';
SHOW STATUS LIKE 'Innodb_buffer_pool_pages_total';
-- 2) 看 adaptive flushing 是否还在"拉前台下水"
SHOW ENGINE INNODB STATUS\G
-- 重点看 'BUF POOL AND MEMORY' 段:
-- - pages modified: 脏页数
-- - pages read / created / written: 读写速率
-- - log i/o's done, buffer pool write requests: 刷脏 I/O
-- 3) 用 performance_schema 看前台线程是否在做刷脏
SELECT EVENT_NAME, COUNT_STAR, SUM_TIMER_WAIT/1000000000 AS ms
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE EVENT_NAME LIKE '%buf_flush%'
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;
-- 4) 云盘 IOPS 监控(阿里云 ARMS / CloudMonitor)
-- 把"后台刷脏 IOPS"和"前台 IOPS"两条曲线叠加,看尖刺时刻的分配比例
3.3 调参后的实测数据
| 指标 | 调参前 | 调参后 | |---|---|---| | P50 | 4.2ms | 4.0ms | | P99 | 68ms(每 60~90s 尖刺) | 9ms(尖刺基本消失) | | P999 | 210ms | 32ms | | 写入 QPS | 4200 | 4660(+11%) | | iowait | 9~14% | 4~6% | | Buffer Pool 命中率 | 99.6% | 99.7% | | 脏页比例(稳态) | 22~28%(反复越 LWM) | 8~12%(远低于 LWM) |
四、踩过的坑 / 反直觉之处
-
iowait低 ≠ 磁盘不忙。云盘的 IOPS 排队发生在设备侧(hypervisor / 存储后端),top里的iowait只反映"进程在等 I/O",不反映"设备侧排队深度"。我们的尖刺时刻iowait才 9%,但云盘监控的 IOPS 已经打满 3500。判据要看设备侧 IOPS 曲线,不是iowait。 -
innodb_io_capacity调小反而变快。直觉是"后台刷得越多越好,脏页越少越好",但在云盘 IOPS 有上限的场景,后台刷得越多 = 前台能用的越少。把innodb_io_capacity从 200 调到 500(看似"调大"),innodb_io_capacity_max从 2000 调到 1200("调小"),整体效果是"后台预算更平滑、峰值不再打爆云盘",前台 P99 反而降了 7 倍。关键不是"刷得多",而是"刷得平滑 + 峰值不抢前台"。 -
innodb_flush_neighbors=0在云盘上是纯收益。这个参数是为 HDD 设计的("刷一个页时顺带刷邻居页",利用 HDD 的顺序 I/O 优势)。云盘的随机 I/O 很便宜,"邻居页"大概率不在 buffer pool、也没脏——顺刷就是纯浪费。我们关掉后,无效 I/O 降了 ~18%。 -
buffer pool 越大,问题可能越严重。我们最初以为是"buffer pool 太大导致脏页多",把
innodb_buffer_pool_size从 48G 降到 32G 试过一次——P99 没降反升 30%(命中率从 99.6% 掉到 97.8%,前台读盘变多)。buffer pool 大小不是这个毛刺的主因,刷脏策略才是。 -
innodb_max_dirty_pages_pct_lwm比innodb_max_dirty_pages_pct更关键。LWM 是"触发前台参与刷脏"的门槛,调 LWM 比调 max 更有用。我们把 LWM 从 25% 降到 10%,前台被"拉下水"的次数直接降到 0(events_waits_summary_global_by_event_name里buf_flush的COUNT_STAR从 12000/min 降到 0)。
五、结论与可复用清单
核心结论:innodb_io_capacity / innodb_io_capacity_max 不是"性能上限",而是"后台刷脏的预算"。在云盘 IOPS 有上限 + 写密集 + 大 buffer pool 的场景,默认值(200/2000)会让后台"攒一波再刷",刷的瞬间打爆云盘、挤爆前台。把预算调到"足够消化脏页生成速率 + 峰值不打爆云盘"的区间,P99 可以降一个数量级。
可复用清单(按场景对照):
| 场景 | 推荐值 | 理由 |
|---|---|---|
| 云盘 ESSD PL1(1800 IOPS) | io_capacity=300, max=800 | 预算 = 上限的 15%/45% |
| 云盘 ESSD PL2(3500 IOPS) | io_capacity=500, max=1200 | 预算 = 上限的 15%/35%(本文场景) |
| 云盘 ESSD PL3(10000 IOPS) | io_capacity=1000, max=3000 | 预算 = 上限的 10%/30% |
| 本地 NVMe(50000+ IOPS) | io_capacity=2000, max=8000 | 预算 = 上限的 4%/16% |
通用经验法则:
io_capacity ≈ 脏页生成速率 × 1.2(覆盖脏页生成,避免"攒一波");io_capacity_max ≈ 云盘 IOPS 上限 × 0.35(峰值不抢前台);innodb_max_dirty_pages_pct_lwm调到 10%(比默认 25% 更保守,避免前台被拉下水);- 云盘上
innodb_flush_neighbors=0是纯收益; - 判据看设备侧 IOPS 曲线,不是
iowait。
与 Change Buffer 的关联:InnoDB 的后台机制(Change Buffer merge、adaptive flushing、checkpoint)都是"延迟成本",在 OLTP 场景下都可能变成 P99 尖刺的隐形来源(参见 [1009] 关于 Change Buffer merge 风暴的复盘)。调参的核心思路是"把后台预算调到'平滑 + 峰值不抢前台'的区间",而不是"刷得越多越好"。
最后提醒:这套参数是"经验值 + 实测",不同硬件 / 工作负载的最优值不同。调参前务必用 SHOW STATUS LIKE 'Innodb_buffer_pool_pages_dirty' 与云盘 IOPS 监控曲线叠加,确认"后台刷脏是否在抢前台",再决定调哪个参数。