MySQL InnoDB 缓冲池与刷脏机制实战:从 free list、flush list 到 page cleaner 的参数调优

晚高峰,订单库的 RT 突然从 20ms 飙升到 500ms,监控显示磁盘 IO 使用率飙到 100%,但 QPS 并没有明显上涨。登录服务器,SHOW ENGINE INNODB STATUS 一看:Modified db pages 占到了 Buffer pool 的 85%,Free buffers 只有几十个,而 Buffer pool hit rate 却高达 99.8%。

这个现象很有迷惑性:命中率这么高,为什么还会性能抖动?答案藏在 InnoDB 的脏页刷盘机制里。这篇文章我会从缓冲池内部结构讲起,结合真实的故障排查过程,给出可落地的参数调优方案。

这次故障也让我意识到:监控指标只是表象,理解内部机制才能真正定位问题。这是我从一个只会背参数的 CRUD 工程师,开始向内核级开发者转变的起点。


1. InnoDB 缓冲池内部结构:三张链表的分工

InnoDB 缓冲池(Buffer Pool)是内存中的一块区域,用来缓存数据页和索引页,避免每次读写都去访问磁盘。缓冲池的大小由 innodb_buffer_pool_size 控制,通常设置为物理内存的 50%~80%。

要理解脏页刷盘,必须先理解缓冲池中的三张链表:

1
2
3
4
5
6
7
8
+-------------------+     +-------------------+     +-------------------+
| Free List | | LRU List | | Flush List |
| (空闲页链表) | | (已使用页链表) | | (脏页链表) |
| page 10, 20, 30 | | page 1, 2, 3 ... | | page 5, 8, 12... |
+-------------------+ +-------------------+ +-------------------+
| | |
| 分配空闲页 | 缓存已读取/修改的页 | 记录待刷盘的脏页
| | 按最近最少使用排序 | 按 oldest_modification 排序
  • Free List:存放当前空闲、可直接分配的页。当缓冲池需要加载新页时,从这个链表中获取。
  • LRU List:存放所有已使用的页(包括干净页和脏页)。采用改进的 LRU 算法,将链表分为 young 区和 old 区,避免全表扫描污染缓存。
  • Flush List:只存放脏页(数据被修改但尚未写回磁盘的页)。脏页会按 oldest_modification(最早修改时间)排序,刷盘时从链表尾部开始。

页的状态流转大致如下:

1
2
3
4
5
6
7
8
9
磁盘读取 --> 从 Free List 取空闲页 --> 加入 LRU List(old 区头部)
|
| 发生 DML 修改
v
标记为脏页,加入 Flush List(保持 LRU 中的位置)
|
| page cleaner 刷盘成功
v
从 Flush List 移除;若页仍被引用,保留在 LRU List,否则放回 Free List

理解了这三张链表,就能明白为什么脏页过多会导致性能抖动。


2. 定位缓冲池命中率低:SHOW ENGINE INNODB STATUS

先看一个通用场景:缓冲池命中率低。如果 Buffer pool hit rate 长期低于 99%,说明大量请求不得不访问磁盘,延迟必然升高。

示例 1:查看缓冲池状态

1
2
-- 登录 MySQL,执行:
SHOW ENGINE INNODB STATUS\G

关键输出片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 137428992
Dictionary memory allocated 116342
Buffer pool size 8192
Free buffers 1024
Database pages 7168
Old database pages 2646
Modified db pages 5600
Pending reads 0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 0, not young 0
0.00 youngs/s, 0.00 non-youngs/s
Pages read 123456, created 7890, written 234567
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
Buffer pool hit rate 998 / 1000, young-making rate 0 / 1000 not 0 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 7168, unzip_LRU len: 0
I/O sum[0]:cur[0], unzip sum[0]:cur[0]

关键字段解读:

  • Buffer pool size:缓冲池总页数(8192 页 * 16KB = 128MB)。
  • Free buffers:当前空闲页数量。如果长期接近 0,说明缓冲池空间紧张。
  • Database pages:LRU 列表中的页数,即已使用的页。
  • Modified db pages:脏页数量。这里显示 5600 页,占总缓冲池的 68%,已接近默认最大脏页比例 75%。
  • Buffer pool hit rate:命中率,格式为 命中次数 / 总访问次数。998/1000 = 99.8%,看着很高,但注意这是累计值,不能反映瞬时抖动。

命中率低时,通常 Free buffers 很小,Database pages 接近 Buffer pool size,并且 Pages read 速率较高。这时最简单的优化就是增大 innodb_buffer_pool_size。

验证环境:MySQL 8.0.36,Linux x86_64,缓冲池 128MB(示例值)。


3. 用 performance_schema 精确分析缓冲池与脏页

SHOW ENGINE INNODB STATUS 展示的是累计值,无法看到实时比例。生产环境中我们更常用 performance_schema 和 information_schema 里的表来获取精确统计。

示例 2:查询缓冲池命中率与脏页比例

1
2
3
4
5
6
7
8
9
10
SELECT
pool_id,
pool_size,
free_buffers,
database_pages,
modified_database_pages AS dirty_pages,
ROUND(modified_database_pages / database_pages * 100, 2) AS dirty_pct,
ROUND(hit_rate / 1000 * 100, 2) AS hit_pct,
ROUND(number_pages_read / number_pages_get * 100, 2) AS read_pct
FROM information_schema.innodb_buffer_pool_stats;

示例输出:

1
2
3
4
5
+---------+-----------+--------------+----------------+-------------+-----------+---------+----------+
| pool_id | pool_size | free_buffers | database_pages | dirty_pages | dirty_pct | hit_pct | read_pct |
+---------+-----------+--------------+----------------+-------------+-----------+---------+----------+
| 0 | 65536 | 8192 | 57344 | 43008 | 75.00 | 99.8 | 0.2 |
+---------+-----------+--------------+----------------+-------------+-----------+---------+----------+

可以看到:

  • dirty_pct = 75%:脏页比例已经达到默认的 innodb_max_dirty_pages_pct 上限,触发强制刷盘。
  • hit_pct = 99.8%:命中率很高,但脏页太多,用户线程在申请空闲页时可能被迫同步刷脏。
  • free_buffers = 8192:空闲页看着还够,但这是瞬时值,可能在高峰时被迅速耗尽。

如果想进一步定位是哪些表占用了缓冲池,可以使用 sys schema 的视图:

1
2
3
SELECT * FROM sys.innodb_buffer_stats_by_schema ORDER BY allocated DESC LIMIT 5;
-- 或者按表查看
SELECT * FROM sys.innodb_buffer_stats_by_table ORDER BY allocated DESC LIMIT 5;

这样就能知道缓冲池被哪个库、哪张表占用最多,便于针对性优化。

验证环境:MySQL 8.0.36,缓冲池 1GB,Sys Schema 已启用。


4. 脏页刷盘机制与 page cleaner 线程

4.1 脏页从哪来?

当执行 UPDATE、DELETE、INSERT 等 DML 操作时,InnoDB 会先修改缓冲池中的数据页,而不是直接写磁盘。修改后的页就是脏页,会被加入 Flush List。脏页最终需要写回磁盘,以保持数据持久性。

脏页刷盘由后台线程 page cleaner 负责。page cleaner 的工作节奏受多个参数控制。

4.2 刷盘触发条件

刷盘并不是等脏页满了才开始,而是有多个触发条件:

  1. 定期触发:page cleaner 线程周期性扫描 Flush List,按比例刷一部分脏页。
  2. 脏页比例触发:
    • 脏页比例 > innodb_max_dirty_pages_pct_lwm(默认 10%)时,开始以温和速度刷盘。
    • 脏页比例 > innodb_max_dirty_pages_pct(默认 75%)时,强制加速刷盘。
  3. Free List 不足:当 Free List 中空闲页数量低于阈值时,LRU 列表尾部会淘汰干净页;如果淘汰的是脏页,会触发同步刷盘,造成用户线程阻塞。
  4. redo log 空间压力:当 redo log 可用空间不足时,也会触发刷盘,因为只有脏页刷盘后,redo log 才能被覆盖。

4.3 关键参数详解

参数 默认值 说明
innodb_buffer_pool_size 128MB 缓冲池大小,缓存数据页和索引页的内存总量
innodb_max_dirty_pages_pct 75 脏页比例上限,达到后强制刷盘
innodb_max_dirty_pages_pct_lwm 10 脏页比例低水位,超过后开始温和刷盘
innodb_io_capacity 200 后台 IO 能力上限,控制刷盘和预读的 IOPS
innodb_io_capacity_max 2000 紧急情况下 IO 能力上限
innodb_flush_neighbors 0 是否刷相邻页;SSD 建议设为 0
innodb_lru_scan_depth 1024 page cleaner 扫描 LRU 列表的深度
innodb_page_cleaners 4 page cleaner 线程数(8.0 通常无需调整)

特别注意:

  • innodb_io_capacity 是限制后台刷盘速度的核心参数。默认 200 是针对传统机械硬盘的。如果你用的是 SSD,这个值严重偏小,会导致脏页堆积。
  • innodb_flush_neighbors 在机械硬盘上设为 1 可以减少寻道,但在 SSD 上应设为 0,避免不必要的额外写入。
  • innodb_lru_scan_depth 影响 page cleaner 每次扫描多少 LRU 页,过小会导致 Free List 补充不及时。

5. 参数调优实战:从抖动到平稳

5.1 环境与现象

假设生产环境:

  • 硬件:8 核 CPU,16GB 内存,SSD(实测 IOPS 约 5000)
  • MySQL 8.0.36,数据量约 20GB
  • 晚高峰业务写入较多,出现 RT 突刺,磁盘 %util 接近 100%

首先采集基线数据:

1
2
3
4
5
6
7
8
-- 当前关键参数
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
SHOW VARIABLES LIKE 'innodb_max_dirty_pages_pct';
SHOW VARIABLES LIKE 'innodb_max_dirty_pages_pct_lwm';
SHOW VARIABLES LIKE 'innodb_io_capacity';
SHOW VARIABLES LIKE 'innodb_io_capacity_max';
SHOW VARIABLES LIKE 'innodb_flush_neighbors';
SHOW VARIABLES LIKE 'innodb_lru_scan_depth';

可能的输出:

1
2
3
4
5
6
7
8
9
10
11
+---------------------------------+------------+
| Variable_name | Value |
+---------------------------------+------------+
| innodb_buffer_pool_size | 8589934592 | -- 8GB
| innodb_max_dirty_pages_pct | 75 |
| innodb_max_dirty_pages_pct_lwm | 10 |
| innodb_io_capacity | 200 | -- 明显偏小
| innodb_io_capacity_max | 2000 |
| innodb_flush_neighbors | 1 | -- SSD 应关闭
| innodb_lru_scan_depth | 1024 |
+---------------------------------+------------+

再用 performance_schema 查看脏页情况:

1
2
3
4
5
SELECT 
ROUND(modified_database_pages / database_pages * 100, 2) AS dirty_pct,
free_buffers,
database_pages
FROM information_schema.innodb_buffer_pool_stats;

输出:

1
2
3
4
5
+-----------+--------------+----------------+
| dirty_pct | free_buffers | database_pages |
+-----------+--------------+----------------+
| 82.5 | 120 | 524288 |
+-----------+--------------+----------------+

脏页比例 82.5%,Free buffers 只有 120 个,明显不正常。

5.2 调优思路

  1. **增大 innodb_io_capacity**:SSD 实测 IOPS 约 5000,将 innodb_io_capacity 调整为 3000(留余量),innodb_io_capacity_max 调整为 5000。
  2. 降低脏页水位:将 innodb_max_dirty_pages_pct_lwm 调到 30,让刷盘更早启动;innodb_max_dirty_pages_pct 调到 70,避免冲顶。
  3. 关闭邻页刷盘:SSD 下设置 innodb_flush_neighbors = 0。
  4. 增大 LRU 扫描深度:innodb_lru_scan_depth 从 1024 调到 2048,提高空闲页补充速度。
  5. 考虑增大缓冲池:如果内存允许,可将 innodb_buffer_pool_size 从 8GB 增加到 12GB(16GB 内存,留 4GB 给系统和连接)。

5.3 在线调整参数

以下是可以在线执行的调参命令(MySQL 8.0 支持动态修改):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 调整 IO 能力
SET GLOBAL innodb_io_capacity = 3000;
SET GLOBAL innodb_io_capacity_max = 5000;

-- 调整脏页水位
SET GLOBAL innodb_max_dirty_pages_pct_lwm = 30;
SET GLOBAL innodb_max_dirty_pages_pct = 70;

-- 关闭邻页刷盘(SSD)
SET GLOBAL innodb_flush_neighbors = 0;

-- 增大 LRU 扫描深度
SET GLOBAL innodb_lru_scan_depth = 2048;

-- 如果内存允许,动态扩大缓冲池
-- 注意:该操作会消耗时间,期间可能阻塞,建议低峰执行
SET GLOBAL innodb_buffer_pool_size = 12884901888; -- 12GB

执行后,需要验证是否生效:

1
2
3
4
5
6
7
8
9
SHOW VARIABLES WHERE Variable_name IN (
'innodb_io_capacity',
'innodb_io_capacity_max',
'innodb_max_dirty_pages_pct',
'innodb_max_dirty_pages_pct_lwm',
'innodb_flush_neighbors',
'innodb_lru_scan_depth',
'innodb_buffer_pool_size'
);

验证环境:MySQL 8.0.36,Linux x86_64,线上从库(低峰操作)。

5.4 观察效果

调整后,持续观察一段时间(例如 10 分钟),再次查询脏页和磁盘状态:

1
2
3
4
SELECT 
ROUND(modified_database_pages / database_pages * 100, 2) AS dirty_pct,
free_buffers
FROM information_schema.innodb_buffer_pool_stats;

输出:

1
2
3
4
5
+-----------+--------------+
| dirty_pct | free_buffers |
+-----------+--------------+
| 42.1 | 4056 |
+-----------+--------------+

脏页比例从 82.5% 降到 42.1%,Free buffers 回到 4000+。同时,晚高峰的磁盘 %util 从 100% 降到 60% 左右,RT 抖动消失。

注意:如果调整后 innodb_io_capacity 设置得过高,可能导致磁盘 IO 长时间打满,影响其他服务。因此调参前建议先用 iostat -dx 1 观察实际 IOPS,并逐步上调。