MySQL Buffer Pool 的 LRU 链表为什么需要分为 Young 和 Old 区
直接回答:为了 防止一次性大查询(如全表扫描) 把频繁访问的热数据从内存中“挤出去”,导致缓存命中率断崖式下降。下面通过拆解传统 LRU 的缺陷,然后展示分区后的工作机制,让你彻底理解这个设计。
1. 先看传统 LRU 的致命弱点
传统的最近最少使用(LRU)链表通常是这样工作的:新读入的页面加到链表头部,当链表满时,从尾部淘汰。这种设计在理想情况下没问题,但面对 突发性的大批量冷数据访问(比如 SELECT * FROM huge_table 没有索引)时,就会出现一个经典问题:缓冲池污染。
1.1 缓冲池污染如何发生
- 假设缓冲池只有 4 个 slot,当前全是热点数据:
A, B, C, D,链表顺序是A(头) -> B -> C -> D(尾,最久未用)。 - 执行全表扫描大表,读入页面
X, Y, Z, W等。按照传统 LRU,每个新页面都会插入链表头部。 - 随着扫描进行,
A, B, C, D依次被挤到尾部,然后被淘汰。 - 当查询结束后,链表中的全是刚刚扫描的冷数据,而真正的热点
A, B, C, D已经不在内存中。 - 下次访问热点数据时,必须从磁盘重新读取,导致大量 I/O,性能暴跌。
核心矛盾:一个临时的大查询污染了整个缓冲池,破坏了缓存的热点局部性。
2. MySQL 的解决方案:将 LRU 链表分为 Young 和 Old 区
MySQL InnoDB 引擎把 LRU 链表 物理上分成两个区域:Young(年轻)区和 Old(老)区。默认 Young 占 5/8,Old 占 3/8。这个比例可以通过参数 innodb_old_blocks_pct 调整。
2.1 分区后的链表结构
Young 区(热数据,靠近头部) <--> Old 区(冷数据,靠近尾部)
| Head ----------> ... ----------> Midpoint | ----------> ... ----------> Tail
- Midpoint:是 Young 区和 Old 区的分界点。新读入的页面 不是插入头部,而是插入到 Midpoint 位置,即 Old 区的头部。
- Young 区:存放被反复访问的热数据。
- Old 区:存放新加入的页面或只被访问一次的数据。
2.2 具体操作步骤(动词导向)
- 数据页首次被读入缓冲池时:将 该页 插入 LRU 链表 Midpoint 处(Old 区头部)。此时该页处于 Old 区。
- 如果该页在 Old 区被再次访问:如果两次访问的 时间间隔 小于
innodb_old_blocks_time(默认 1000 毫秒),则 认为 这是偶然访问,不移动 到 Young 区,继续保留在 Old 区。这样做是为了避免“瞬间热点”(比如一次循环中的多次访问)把真正的热数据挤走。 - 如果该页在 Old 区被再次访问且时间间隔大于阈值:将 该页 提升 到 Young 区的头部(即整个 LRU 链表的头部)。
- 当缓冲池满,需要淘汰页面时:从 链表尾部(Old 区尾部)淘汰 页面。也就是说,只有 Old 区尾部的页面才会被淘汰,Young 区的数据相对安全。
3. 分区为什么能解决问题:一个对比示例
还是用 4 个 slot 的缓冲池(假设 Young 和 Old 各一半,实际比例不同,但原理一致):
| 步骤 | 传统 LRU 生效后 | 分区 LRU(Young:Old=2:2)生效后 |
|---|---|---|
| 初始 | A(头)->B->C->D(尾) |
假设 Young: A(头),B Old: C(头),D(尾) |
全表扫描读入 X |
X(头)->A->B->C->D(尾) → 淘汰 D |
X 插入 Old 头部:Young: A,B Old: X(头),D(尾) → 淘汰 D |
扫描继续读入 Y |
Y(头)->X->A->B->C->D(尾) → 淘汰 C |
Y 插入 Old 头部:Young: A,B Old: Y(头),X(尾) → 淘汰 X |
扫描继续读入 Z |
Z(头)->Y->X->A->B->C->D(尾) → 淘汰 B |
Z 插入 Old 头部:Young: A,B Old: Z(头),Y(尾) → 淘汰 Y |
| 扫描结束,链表状态 | Z,Y,X,A (全部是冷数据,热点 A 被淘汰) |
Young: A,B (热点存活) Old: Z(头),? (如果只有4个槽,此时Old:Z(头),Y(尾),但很快会被后续淘汰) |
关键差异:分区后,扫描读入的冷数据全部堆积在 Old 区,Young 区的热点 A, B 始终没被触及。即使扫描大量页面,也只会淘汰 Old 区内的冷数据,直到 Old 区被占满,然后开始淘汰 Old 区尾部的页面(即最老的冷数据)。热点数据被保护在 Young 区内,除非长时间不被访问,否则不会被淘汰。
4. 参数调优:你可以控制的细节
innodb_old_blocks_pct:控制 Old 区占 LRU 链表的百分比。默认 37(即 3/8)。如果你的场景中有大量临时大查询,可以 增大 这个值(比如 50),让 Old 区容量更大,更不容易挤到 Young 区。但如果热点数据本身很多,增大 Old 区会压缩 Young 区,可能导致热点被提前淘汰。innodb_old_blocks_time:控制页面从 Old 区提升到 Young 区的“冷静期”。默认 1000 毫秒。如果应用有频繁的短时间循环扫描(比如每 500ms 扫描一次小表),可以 减小 这个值(例如 500),让更快的迁移。反之,如果经常有突发的超大查询,可以 增大 这个值(例如 2000),防止误提升。
调整方法:在 MySQL 配置文件中设置,或动态修改:
SET GLOBAL innodb_old_blocks_pct = 40;
SET GLOBAL innodb_old_blocks_time = 500;
注意:innodb_old_blocks_pct 必须在启动前设置(动态修改只对后续连接生效?实际上 5.7 开始支持动态修改,但需确认:innodb_old_blocks_pct 是 global 变量,可在运行时修改,但生效范围取决于版本,建议测试。文档说明该变量可动态设置)。保险的做法是在配置文件中写入并重启。
5. 分区的真正价值总结
- 防止缓冲池污染:临时大查询只污染 Old 区,不冲击 Young 区的热点。
- 提高缓存命中率:热点数据长期驻留,磁盘 I/O 大幅减少。
- 适应混合负载:既有 OLTP(频繁小查询)又有 OLAP(大查询)的场景下,性能更稳定。
- 降低调优成本:默认参数已经能应对 80% 的场景,不需要人工干预太多。
一句话记住:Young 区负责留住真正的热点,Old 区负责缓冲突发的冷流,两者通过 Midpoint 插入和 time 阈值实现隔离。这就是 MySQL 缓冲区 LRU 分区设计的精髓。

暂无评论,快来抢沙发吧!