InnoDB 的 Buffer Pool:MySQL 的性能其实全押在这块内存上
一条 SQL 慢,90% 的情况可以归到一句话:它需要的页不在内存里。
索引、执行计划、锁、事务,最后都要落到 16 KB 的页上,而页在不在 Buffer Pool 里,决定了这次查询是 0.1 ms 还是 10 ms。
结论先说
| 问题 | 答案 |
|---|---|
| Buffer Pool 到底装了什么 | 数据页、索引页、undo 页、change buffer、自适应哈希索引——全是 16 KB 的页 |
| 命中率多少算正常 | OLTP 场景 > 99%;低于 99% 说明内存不够,别急着加索引 |
| 为什么白天很稳、晚上突然抖 | 脏页比例撞红线 + redo 空间吃紧,刷脏阻塞了用户线程 |
| 内存给了 32 GB 还慢 | 可能 instances/chunk_size 配错,或者被全表扫描把热数据挤出 LRU |
| 加完索引为什么还卡 | 大表 DDL 要扫全表,会把 Buffer Pool 冲一遍,热数据全丢 |
下面从页讲起,一路推到刷脏和抖动。
一、InnoDB 的基本单位:16 KB 的页
InnoDB 管理磁盘的最小单位是 page,默认 16 KB(innodb_page_size,可选 4K/8K/16K/32K/64K,建库后不可改)。
一个索引页的大致布局:
┌──────────────────────────────────────────────┐
│ File Header (38B) 页号、页类型、LSN、校验和 │
├──────────────────────────────────────────────┤
│ Page Header (56B) 槽数、记录数、堆顶、层级 │
├──────────────────────────────────────────────┤
│ Infimum / Supremum 两条虚拟边界记录 │
├──────────────────────────────────────────────┤
│ User Records 真实行记录(单向链表) │
├──────────────────────────────────────────────┤
│ Free Space 未分配空间(向中间生长) │
├──────────────────────────────────────────────┤
│ Page Directory 稀疏槽位,每 4~8 条一组 │
├──────────────────────────────────────────────┤
│ File Trailer (8B) 校验和 + LSN 低 32 位 │
└──────────────────────────────────────────────┘
两个关键点:
- 页内记录是单向链表,但页目录是稀疏索引。页内查找先二分页目录(槽),再在槽内顺序遍历——这就是为什么单页内记录不能太多,16 KB 页在行宽 1 KB 时大约只放十几行。
- 页是有 LSN 的。每次修改都会把当前 LSN 写进页头,崩溃恢复时靠它判断这个页是否需要重放 redo。
Buffer Pool 就是这么一个"页的缓存",键是 (space_id, page_no),值是一个 16 KB 的帧(frame)加一个控制块。
二、Buffer Pool 的内部结构:三个链表 + 一个哈希表
┌─────────────┐
磁盘读入的页 → │ free list │ 空闲帧
└──────┬──────┘
│ 取一帧装载页
┌──────▼──────────────────┐
│ LRU list │
│ young (5/8) │ old (3/8) │ ← 所有页都在这
└──────┬──────────────────┘
│ 页被修改
┌──────▼──────┐
│ flush list │ 脏页(按 oldest_modification 排序)
└─────────────┘
哈希表:page hash,(space_id, page_no) → 帧指针,O(1) 定位
| 结构 | 作用 | 出问题时的症状 |
|---|---|---|
| free list | 尚未使用的空闲帧 | 空了就要淘汰,Innodb_buffer_pool_wait_free 开始涨 |
| LRU list | 所有已加载的页,分 young/old 两代 | 全表扫描会导致热数据被挤出,命中率断崖 |
| flush list | 脏页(内存与磁盘不一致) | 太长说明刷脏跟不上,checkpoint 被迫推进 |
| page hash | 页定位 | —— |
LRU 的改良:分代 + 停留时间
原生 LRU 有个致命问题:一条全表扫描的 SQL 会把热数据全部挤出去(经典的"缓存污染")。
InnoDB 的做法是把 LRU 切成两段,新页只插入 old 区头部(midpoint insertion):
LRU 尾部 ←── old 区 (3/8) ──┬── young 区 (5/8) ──→ LRU 头部
(最冷,优先淘汰) │ (最热)
插入点
配合 innodb_old_blocks_time(默认 1000 ms):一个页插入 old 区后,1 秒内被再次访问也不会晋升到 young 区。
这一条直接决定了:全表扫描扫过的页会在 old 区里被快速淘汰,不会污染 young 区的热数据。所以大表扫一遍,热数据基本还在——但如果你把 innodb_old_blocks_time 设成 0,这个保护就失效了。
内存怎么分
innodb_buffer_pool_size = 24G # 专用机器给物理内存的 60%~75%
innodb_buffer_pool_instances = 8 # 大内存多实例,减少 mutex 争用
innodb_buffer_pool_chunk_size = 128M # 分配粒度
三个参数有强约束:最终大小会被向上取整到 chunk_size × instances 的整数倍。
比如 size=10G, instances=8, chunk=128M:128M × 8 = 1G,10G 正好是 10 倍,没问题。但如果 size=10G, instances=16, chunk=256M,每份 4G,实际会分配 12G——你以为给了 10G,实际占了 12G,容器里很容易因此被 OOM Kill。
另外,Buffer Pool 之外还有一块常被忽略的内存:
| 组件 | 默认值 | 说明 |
|---|---|---|
| change buffer | Buffer Pool 的 25% | innodb_change_buffer_max_size |
| log buffer | 16 MB | innodb_log_buffer_size |
| 自适应哈希索引 | 自动 | innodb_adaptive_hash_index,高并发下可能是争用源 |
所以"Buffer Pool 24G"在一台 32G 的机器上其实不够,还要留连接、排序缓冲、临时表、OS page cache 的空间。
三、命中率怎么算,多少算健康
SELECT
ROUND(100 * (1 - reads / read_requests), 2) AS hit_rate_pct,
reads, read_requests
FROM (
SELECT
(SELECT variable_value FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_reads') AS reads,
(SELECT variable_value FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_read_requests') AS read_requests
) t;
口径解释:
read_requests:逻辑读次数(每次访问一个页算一次,不管在不在内存)reads:物理读次数(必须从磁盘读的页数)
判读标准:
| 命中率 | 判断 | 动作 |
|---|---|---|
| > 99.5% | 健康 | 无需处理 |
| 99% ~ 99.5% | 轻微不足 | 观察是否周期性掉坑(一般是刷脏或大查询) |
| < 99% | 内存明显不够 | 先加 innodb_buffer_pool_size,再看是不是有全表扫描 |
| < 95% | 严重不足 | 大概率是内存配小了,或数据集远大于内存 |
⚠️ 注意:这个指标是实例启动以来的累计值,跑了几天的实例会把它平滑掉。要看瞬时值,隔 10 秒采两次做差,或者用
mysqladmin extended-status -r。
再看空闲页:
SHOW ENGINE INNODB STATUS\G
-- 关注 BUFFER POOL AND MEMORY 段落:
-- Free buffers 剩余空闲页
-- Database pages 数据页数量
-- Modified db pages 脏页数量(超过 75% 就该警惕)
-- Pending reads/writes 正在进行的 IO
-- Buffer pool hit rate 最近一段的命中率(这里是瞬时值,更有参考性)
Innodb_buffer_pool_wait_free 只要不为 0,就说明曾经出现过"要淘汰页但一个可淘汰的都没有、只能等刷脏"的情况——这是最直接的抖动信号。
四、Change Buffer:二级索引的写合并
这是 InnoDB 里最反直觉的一个组件。
场景:给一张 5000 万行的表建了二级索引 idx_city,然后插入一条 city='杭州' 的记录。这条记录要插到二级索引的某个叶子页上,但那个页不在内存里。
普通做法:把页从磁盘读进来 → 修改 → 标记脏。一次随机读。
InnoDB 的做法:如果这个索引不是唯一索引,就把这次修改记到 change buffer 里(也是一棵 B+ 树,存在表空间里),不读页。等以后这个页因为其他原因被读进内存时,再把 change buffer 里的修改 merge 进去。
| 条件 | 能否使用 change buffer |
|---|---|
| 普通二级索引 | ✅ 可以 |
| 唯一二级索引 | ❌ 不行(必须读页判重) |
| 聚簇索引(主键) | ❌ 不行 |
| 页已在 Buffer Pool 中 | 不需要(直接改内存) |
这解释了两个常见现象:
- 唯一索引比普通索引写入慢:不是"判重"这个动作慢,而是每次写都要把页读进来,随机 IO 数量级差别。
- 批量导入时 change buffer 会涨:
SHOW ENGINE INNODB STATUS里的Ibuf: size 1, free list len ...就是在看它。如果 change buffer 满了(默认占 Buffer Pool 25%),写性能会掉。
innodb_change_buffer_max_size = 25 # 写多读少的场景可调到 50
反过来,如果业务是写少读多(比如配置表),把它调小甚至关掉,可以省下内存给数据页。
五、刷脏:抖动的真正来源
5.1 脏页是怎么产生的
UPDATE t SET c = c + 1 WHERE id = 1;
↓
1. 页若不在内存 → 从磁盘读入(随机读)
2. 直接改内存里的页 → 页变"脏",挂到 flush list
3. 同时写 redo log(顺序写,很快返回)
注意第 2 步:修改内存页是极快的,写 redo 是顺序的,两者都不慢。 真正的成本被推迟到了"脏页刷盘"。
这就是 WAL(Write-Ahead Logging)的核心交易:用顺序写换掉随机写,把随机写推迟到后台批量做。
5.2 什么时候必须刷
| 触发条件 | 说明 |
|---|---|
| redo 空间不足 | redo 是循环写的,checkpoint 不推进就没地方写了 → 强制刷脏(最危险) |
| 脏页比例超阈值 | innodb_max_dirty_pages_pct(默认 90%)+ 低水位 _lwm(默认 10%) |
| LRU 尾部需要腾页 | 要淘汰的页是脏页 → 必须先刷才能复用 |
| 正常后台刷脏 | 自适应刷脏按 innodb_io_capacity 控制速率 |
5.3 为什么会"抖"
刷脏本身是后台线程干的,正常不影响用户请求。但有两个坑:
坑一:LRU 尾部扫描被阻塞。
当 free list 空了、要淘汰的页又恰好是脏页时,用户线程必须等这一页刷完才能拿到帧。这就是 Innodb_buffer_pool_wait_free 的来源,表现为偶发的、几十到几百毫秒的长尾,而且往往集中在业务高峰。
坑二:redo 空间不够导致的"急刹车"。
MySQL 8.0.30 之后用 innodb_redo_log_capacity(默认 100 MB)替代了 innodb_log_file_size × innodb_log_files_in_group。默认的 100 MB 对写密集的业务非常小:
写入速率 20 MB/s → 100 MB 只够撑 5 秒
一旦 redo 快满,InnoDB 会同步阻塞所有用户线程去刷脏推进 checkpoint——这就是那种"整库卡住 2 秒又自己好了"的现象。
innodb_redo_log_capacity = 4G # 写密集至少给到"峰值写入 × 30 秒"
innodb_io_capacity = 2000 # SSD 可以给 5000~10000
innodb_io_capacity_max = 4000 # 通常是 io_capacity 的 2 倍
innodb_max_dirty_pages_pct = 75 # 别用默认 90
innodb_max_dirty_pages_pct_lwm = 10
innodb_io_capacity 是最容易被忽略的参数。 它告诉 InnoDB "这块盘每秒能扛多少 IO",默认 200 是给机械盘的值。跑在 NVMe 上却留着 200,等于让后台刷脏按自行车的速度干活——脏页越积越多,最后撞红线。
六、预热:重启后的第一小时最慢
Buffer Pool 是内存,实例重启就空了。冷启动后命中率会从 0 慢慢爬,爬升期间所有查询都是物理读。
两个办法:
办法一:官方 dump/load(推荐)
innodb_buffer_pool_dump_at_shutdown = ON # 关机时把页号列表写到 ib_buffer_pool
innodb_buffer_pool_load_at_startup = ON # 启动时按列表预读
innodb_buffer_pool_dump_pct = 100 # 8.0 支持只 dump 最热的一部分
注意:dump 的只是页号清单,不是数据本身。启动后仍需按清单把页读进来,这段时间 IO 会很重,建议放在低峰重启。
办法二:手动预热
-- 把主键索引全扫一遍,强制加载进 Buffer Pool
SELECT COUNT(*) FROM orders FORCE INDEX (PRIMARY);
对大表来说这一句可能要跑几分钟,但比让线上查询一篇一篇去缺页要划算。
七、几个由此推导出来的结论
1)为什么 UUID 主键会拖慢写入
UUID v4 是随机的,插入位置随机分布在 B+ 树各处 → 每次插入都可能命中一个不在内存的页 → 物理读 + 页分裂 + 脏页散落。这就是上一篇《为什么一张 MySQL 表到千万行就变慢》里说的"页分裂"的成本来源。自增主键则永远写最右页,几乎全部命中内存。
2)为什么加索引会让线上变慢
ALTER TABLE ADD INDEX 要扫全表建索引,过程中大量页被读入 Buffer Pool。虽然有 innodb_old_blocks_time 保护,但扫描产生的脏页会挤占 flush list,导致正常的刷脏节奏被打乱。大表 DDL 建议用 pt-online-schema-change 或 gh-ost,并且避开高峰。
3)为什么"内存加了一倍,性能没变"
先确认三件事:命中率是否本来就 > 99.5%(那就不是内存问题);innodb_buffer_pool_instances 是否太多导致每个实例太小;瓶颈是否其实在 CPU 或锁等待(SHOW ENGINE INNODB STATUS 里 ROW OPERATIONS 段的 queries inside InnoDB 和锁等待时间)。
八、一分钟体检清单
-- 1. 命中率(瞬时:隔 10 秒采两次)
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
-- 2. Buffer Pool 概况
SHOW ENGINE INNODB STATUS\G -- BUFFER POOL AND MEMORY 段
-- 3. 是否有等空闲页
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_wait_free';
-- 4. 脏页比例
SELECT
(SELECT variable_value FROM performance_schema.global_status
WHERE variable_name='Innodb_buffer_pool_pages_dirty') /
(SELECT variable_value FROM performance_schema.global_status
WHERE variable_name='Innodb_buffer_pool_pages_total') AS dirty_ratio;
-- 5. change buffer 占用
SHOW GLOBAL STATUS LIKE 'Innodb_ibuf%';
| 指标 | 红线 | 处理 |
|---|---|---|
| 命中率 | < 99% | 加 buffer_pool_size |
| wait_free | > 0 且持续增长 | 调大 io_capacity,或加内存 |
| 脏页比例 | > 75% | 同上;检查是否有大事务批量写 |
| Innodb_ibuf_merges 增长极快 | —— | 写密集场景正常,关注 ibuf 是否满 |
九、小结
InnoDB 的性能模型可以压缩成一句话:
所有操作都在内存的页上完成;磁盘只负责两件事——把页读进来,把脏页写回去。
读的性能取决于"页在不在",写的性能取决于"刷脏跟不跟得上"。
理解这一点之后,很多"玄学"就有解释了:冷启动慢是页不在;长尾抖动是刷脏阻塞;唯一索引写慢是要读页;UUID 主键慢是页散落。剩下的全是参数。
系列内链
- 前置:[05-数据库与MySQL/01-为什么一张MySQL表到千万行就变慢](01-为什么一张MySQL表到千万行就变慢.md)
- 关联:[05-数据库与MySQL/06-explain执行计划详解](06-explain执行计划详解.md)——执行计划决定了要访问哪些页
- 关联:[05-数据库与MySQL/02-redo-log-undo-log与binlog](02-redo-log-undo-log与binlog.md)——脏页刷盘为什么必须等 redo 先落盘
- 关联:[10-Linux运维与性能/03-磁盘IO分析iostat与iotop](../10-Linux运维与性能/03-磁盘IO分析iostat与iotop.md)——
iowait高的时候,先看是不是刷脏
- 点赞
- 收藏
- 关注作者
评论(0)