📌 今日关键词:Buffer Pool、Change Buffer、Log Buffer、Adaptive Hash Index、LRU 算法
大家好,我是 数据库小学妹 👋
前几天我们把 InnoDB 的三大日志(Redo Log、Undo Log、Binlog)拆了个底朝天。写完那篇之后我就在想:日志管的是"出了事怎么恢复",但数据在内存里到底是怎么跑的?一条 SELECT 到底经历了什么,为什么有的查询飞快,有的却慢得要命?
说实话,我之前对 Buffer Pool 的理解就停留在"它是个缓存"------直到被同事追问"Buffer Pool 的 LRU 和普通 LRU 有什么区别",整个人当场卡住。翻了好久的资料才搞明白,今天把这些全整理出来,省得你们再踩我踩过的坑。
一、为什么不能直接读磁盘?
在讲 Buffer Pool 之前,先说一个让我特别有感触的事。
我以前做设计师的时候,打开一个 2GB 的 PSD 文件,如果素材都在本地 SSD 上,几秒钟就出来了;但如果素材放在公司的 NAS 网盘上,光加载就要等半天。数据库也是一样的道理------
磁盘 IO 是数据库最贵的操作。 贵到什么程度呢?从机械硬盘读一条数据大概要 10 毫秒,听起来不多对吧?但从内存读只要 100 纳秒,中间差了整整 10 万倍。SSD 比机械硬盘快不少,大概 100 微秒左右,但跟内存比还是差两个数量级。所以 InnoDB 搞了一个大内存区域来缓存磁盘上的数据页,避免每次查询都去"仓库"里翻。这个区域叫 Buffer Pool。
二、Buffer Pool,给数据页安个家
2.1 Buffer Pool 缓存了什么?
Buffer Pool 缓存的不是一条条的数据行,而是数据页(Page)------InnoDB 的最小 IO 单位,默认 16KB 一个。
读取任何一行数据之前,InnoDB 会先检查:
"这个行所在的数据页,是不是已经在 Buffer Pool 里了?"
- 命中(Hit):直接从内存返回,飞快
- 未命中(Miss):从磁盘读取数据页放入 Buffer Pool,再返回
所以 Buffer Pool 命中率基本就代表了你的缓存效率。
sql
SHOW ENGINE INNODB STATUS\G
-- 在 BUFFER POOL AND MEMORY 段找到:
-- Buffer pool hit rate XXX / 1000
-- 990以上为健康,低于950说明Buffer Pool太小或存在全表扫描
2.2 LRU 不是朴素 LRU------young 区 + old 区
这是面试经常问到的问题,也是一般人最容易忽略的地方。
朴素 LRU 的问题 :每次访问一个数据页,就把它放到链表头部。问题是------一次全表扫描会把大量只用一次的冷数据页全部推到链表前面,把真正的热数据挤出去。这就是预读失效 和Buffer Pool 污染。
InnoDB 的解决方案是把 LRU 链表分成两段:
┌─────────────────────────────────────────────────────────────┐
│ LRU 链表 │
│ │
│ ◀── old 区 (3/8) ──▶│◀──── young 区 (5/8) ────▶│ │
│ │ │ │
│ 新页先放这里 │ 被再次访问后才"晋升"到这里 │ │
│ 只访问一次就淘汰 │ 热数据驻留区 │ │
│ │ │ │
│ midpoint tail │
└─────────────────────────────────────────────────────────────┘
工作流程:
- 一个数据页从磁盘读入后,先放到 old 区的头部(不是 young 区!)
- 如果这个页在 old 区被再次访问,且距离第一次访问超过 1 秒 (由
innodb_old_blocks_time控制),才会被移到 young 区头部 - 如果在 1 秒内又被访问了------不移动,防止全表扫描的冷数据污染 young 区
其实就是个"考察期"制度:新来的先在老区待着,证明自己是"常客"之后才给升到核心区。
sql
-- 查看 Buffer Pool 的 LRU 配置
SHOW VARIABLES LIKE 'innodb_old_blocks_time'; -- 默认 1000ms
SHOW VARIABLES LIKE 'innodb_old_blocks_pct'; -- 默认 37(old区占3/8)
2.3 脏页什么时候刷回磁盘?
Buffer Pool 里的数据页被修改后,就变成了脏页(Dirty Page)------内存里是新数据,磁盘上还是旧数据。
脏页不可能永远留在内存里,需要刷回磁盘。但什么时候刷?
| 刷盘触发条件 | 说明 |
|---|---|
| Redo Log 写满了 | InnoDB 必须停下来把一部分脏页刷盘,腾出 Redo Log 空间(这种情况要尽量避免) |
| Buffer Pool 不够用了 | LRU 淘汰时发现是脏页,需要先刷盘再释放 |
| 后台线程定期刷 | Master Thread 每秒/每 10 秒的定时任务 |
| 数据库正常关闭 | 把所有脏页都刷回磁盘 |
相关参数:
sql
SHOW VARIABLES LIKE 'innodb_io_capacity'; -- 告诉 InnoDB 磁盘的 IO 能力(SSD建议2000-20000)
SHOW VARIABLES LIKE 'innodb_max_dirty_pages_pct'; -- 脏页比例上限,默认90
踩坑提醒 :机械硬盘的话,innodb_io_capacity 别设太高,不然后台刷盘会跟前台查询抢 IO。我之前在一台 HDD 测试机上设了 2000,查询抖得不行,调回 200 才好。
三、Change Buffer:非唯一索引的延迟写入
3.1 它解决什么问题?
假设你有一张千万级的订单表,建了个非唯一二级索引 idx_user_id。
现在执行一条 UPDATE orders SET status=1 WHERE user_id=12345,这条语句不仅要改聚簇索引,还得改 idx_user_id 这个二级索引。
如果 idx_user_id 对应的索引页不在 Buffer Pool 里怎么办?
不用 Change Buffer:把索引页从磁盘读进来 → 在内存里修改 → 写回磁盘。一次操作就是 3 次 IO。
用 Change Buffer:不读索引页,直接把"这次修改"记录到 Change Buffer 里 → 后台线程等这个页被其他查询读取时,顺手合并进去。一次操作只需要 1 次 IO。
3.2 唯一索引为什么不能用?
因为唯一索引需要判断"改完之后是否违反唯一性约束"------这个判断必须读取索引页本身。既然都读进来了,直接改就是了,没必要再走 Change Buffer。
简单说:唯一索引要先读页才能判断有没有重复,既然都读了就顺手改了;非唯一索引不用判断重复,能不读就不读,记到 Change Buffer 里等合并。
3.3 相关配置
sql
SHOW VARIABLES LIKE 'innodb_change_buffer_max_size';
-- 默认 25,表示 Change Buffer 最多占 Buffer Pool 的 25%
-- 写入密集场景可以适当调大(最大50),读密集场景可以调小
3.4 什么时候会合并?
- 后台合并:Master Thread 定期把 Change Buffer 中的记录合并到对应的数据页
- 读取合并:当一条查询恰好要用到某个索引页时,发现 Change Buffer 里有它的待合并记录,顺手合并
- 数据库关闭时:也会触发合并
四、Log Buffer,Redo Log 的中转站
上一篇讲 Redo Log 时提到过,InnoDB 不是每写一条 Redo Log 就刷一次磁盘,而是先写到内存中的 Log Buffer,再择机刷盘。
这里的关键参数是 innodb_flush_log_at_trx_commit,三个值代表三种策略:
| 值 | 写入时机 | 刷盘时机 | 性能 | 安全性 |
|---|---|---|---|---|
| 0 | 每秒写入 Log Buffer | 每秒由 OS 刷盘 | ⭐⭐⭐ 最快 | ❌ 最差,最多丢1秒数据 |
| 1 | 每次事务提交时写入 | 每次事务提交时刷盘 | ⭐ 最慢 | ✅ 最好,不丢数据 |
| 2 | 每次事务提交时写入 | 每秒由 OS 刷盘 | ⭐⭐ 居中 | ⚠️ 居中,OS崩溃可能丢1秒 |
我个人的建议:生产环境老老实实用默认值 1。多那点性能真比不上丢数据的代价。
之前在测试环境为了压测好看,改成 2 跑了一下,TPC-C 确实高了不少。但你想想,银行转了一笔账,你告诉客户"钱可能丢了"------这画面太恐怖了。
sql
-- 生产环境确认
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
-- 确保是 1
五、Adaptive Hash Index:InnoDB 的自动加速
5.1 它是什么?
InnoDB 的索引是 B+ 树,查找一条数据需要从根节点一路走到叶子节点,一般要 3-4 次磁盘 IO(即使在 Buffer Pool 里,也需要 3-4 次内存查找)。
Adaptive Hash Index(AHI) 是 InnoDB 自己搞的一个优化:如果发现某些索引页被高频等值查询命中,会自动在内存中为这些页建立一个哈希索引,把 B+ 树的 3-4 次查找降到 1 次。
说白了,B+ 树查找像翻字典------从目录到章节到具体词条,要翻好几页;AHI 就像 Ctrl+F 搜索------直接跳到目标位置。
5.2 怎么看它有没有生效?
sql
SHOW ENGINE INNODB STATUS\G
-- 找到 INSERT BUFFER AND ADAPTIVE HASH INDEX 段
-- Hash table size: XXXX
-- hash searches/s, non-hash searches/s
-- 如果 hash searches/s 远大于 non-hash searches/s,说明 AHI 在高效工作
5.3 什么时候该关?
AHI 并不是所有场景都好使:
| 场景 | 建议 |
|---|---|
| 等值查询为主 | ✅ 保留 AHI |
| 范围查询为主 | ⚠️ AHI 帮不上忙,但也没坏处 |
| 写多读少、高并发写入 | ❌ 关掉 AHI,锁争用严重会拖性能 |
| CPU 核心很多(64+) | ⚠️ 观察 btr_search 相关的 mutex 争用 |
sql
-- 关闭 AHI
SET GLOBAL innodb_adaptive_hash_index = OFF;
-- 不确定就别动,让 InnoDB 自己决定
六、内存全景图:一条查询的完整路径
把上面四个组件串起来,一条 SELECT 的完整内存路径是这样的:
客户端发起查询: SELECT * FROM orders WHERE id = 10086
│
▼
┌─── SQL 层解析 ───┐
│ 生成执行计划 │
└──────────────────┘
│
▼
┌─── InnoDB 存储引擎 ───────────────────────────────────────┐
│ │
│ 1. 先查 Adaptive Hash Index │
│ ├── 命中 → 1次内存查找,直接返回 ✅ │
│ └── 未命中 → 走 B+ 树查找 │
│ │
│ 2. B+ 树从根节点到叶子节点 │
│ ├── 每个节点先查 Buffer Pool │
│ │ ├── 命中(Buffer Pool Hit)→ 继续往下 │
│ │ └── 未命中(Buffer Pool Miss)→ 磁盘IO读入Buffer Pool │
│ └── 到达叶子节点,找到数据行 │
│ │
│ 3. 检查数据是否满足 MVCC 可见性 │
│ ├── 可见 → 返回数据 │
│ └── 不可见 → 沿 Undo Log 版本链找到可见版本 │
│ │
│ 4. 如果查询还涉及二级索引 │
│ ├── 索引页在 Buffer Pool → 直接查 │
│ └── 索引页不在 → 读盘,同时检查 Change Buffer 是否有待合并│
│ └── 有 → 先合并 Change Buffer,再返回结果 │
└───────────────────────────────────────────────────────────┘
可以看到,Buffer Pool 是整个内存体系的核心,AHI、Change Buffer 都是围着它转的。
七、实战:监控与调优
7.1 Buffer Pool 命中率
sql
SHOW ENGINE INNODB STATUS\G
-- BUFFER POOL AND MEMORY 段:
-- Buffer pool hit rate 998 / 1000 ← 99.8%,非常好
-- 如果低于 950 / 1000,考虑:
-- 1. 加大 innodb_buffer_pool_size
-- 2. 排查是否有全表扫描的大查询
7.2 Buffer Pool 大小设置
sql
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- 建议:物理内存的 60%-80%
-- 比如 16G 内存的服务器,设 10G-12G
-- 留足够空间给 OS、连接线程、其他进程
-- MySQL 5.7+ 支持在线调整:
SET GLOBAL innodb_buffer_pool_size = 12 * 1024 * 1024 * 1024; -- 12GB
7.3 脏页比例监控
sql
-- 当前脏页比例
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') * 100
AS dirty_pct;
-- 如果经常超过 70%,考虑调小 innodb_max_dirty_pages_pct
-- 或者调大 innodb_io_capacity 让后台刷盘更积极
7.4 Change Buffer 监控
sql
SHOW ENGINE INNODB STATUS\G
-- INSERT BUFFER AND ADAPTIVE HASH INDEX 段:
-- seg size: Change Buffer 当前大小
-- inserts: 合并的插入操作数
-- merges: 合并操作数
-- 如果 merges 远小于 inserts,说明 Change Buffer 堆积了太多未合并的记录
八、生产避坑清单
- Buffer Pool 命中率低于 95%?先检查是不是有不带 WHERE 条件的全表扫描,再考虑加大 Buffer Pool
innodb_flush_log_at_trx_commit生产环境必须是 1,不要为了性能改 0 或 2innodb_io_capacity要匹配实际磁盘能力:HDD 设 200,SATA SSD 设 2000,NVMe SSD 设 5000-20000- Change Buffer 的
innodb_change_buffer_max_size默认 25 就够了,写入密集场景可以提到 50,但别超过这个数 - AHI 默认开启,不确定就别关。只有在
SHOW ENGINE INNODB STATUS中看到明显的 mutex 争用时才考虑关闭 - Buffer Pool 别设太大,给操作系统和其他进程留够内存,不然触发 swap 性能直接崩盘
- MySQL 8.0 支持多个 Buffer Pool 实例(
innodb_buffer_pool_instances),高并发场景下可以减少锁争用
学习心得
写这篇的时候我自己也晕了好几次,尤其是 AHI 那块,反复看了好几遍才理清楚。总结几个我觉得最容易忘的点:
Buffer Pool 用的不是朴素 LRU,有个 young + old 双区设计,新页有 1 秒考察期。这个设计细节挺巧的,专门防全表扫描污染缓存。
Change Buffer 只对非唯一索引生效,页不在内存的时候先记下来,等被读取时再合并,省掉了随机 IO。唯一索引因为要判重,所以走不了这条路。
innodb_flush_log_at_trx_commit 三个值各有各的道理,但生产环境用 1 就对了,别手痒去改。
AHI 是 InnoDB 自动干的活,把热点等值查询从 B+ 树的多次查找降到 1 次。不过高并发写入的时候可能反而拖后腿,这个我还没实际踩过坑,先记下来。
日常监控的话,盯着 Buffer Pool 命中率(别低于 95%)、脏页比例(别超过 70%)、Change Buffer 合并效率这三个数就行。说白了,大部分问题都能从这仨数里看出端倪。
👋 我是 数据库小学妹,一个用设计师思维学数据库的转行人。我们一起,把复杂的技术变得简单有趣!💕