02-BufferPool与内存管理

#mysql #数据库 #内存管理 #review

总结
  • Buffer Pool 是 InnoDB 的核心内存组件,默认 128MB,生产环境要调大
  • 内部三套链表:free(空闲页)、flush(脏页)、lru(淘汰顺序)
  • LRU 采用冷热分离设计,避免预读和全表扫描污染热点数据
  • 具体配置和验证方法见 Buffer Pool配置调优

1. Buffer Pool 是干嘛的?

简单说就是 InnoDB 的数据缓存区。你查数据、改数据,都是先把磁盘上的页(page)加载到 Buffer Pool 里,然后在内存里操作。操作完不一定马上写回磁盘,有个后台线程定期刷盘。

所以 Buffer Pool 越大,能缓存的数据越多,磁盘 IO 就越少,性能自然好。但也不能太大,得给操作系统、其他进程、连接栈内存等留够空间。

2. 里面存的是什么?

存的是数据页,磁盘上的数据页大小是 16KB,Buffer Pool 里的缓存页跟它一一对应。

1695276351039 1b51deb4 e2b3 434c beb7 a770162d3d9e

每个缓存页都有一个描述信息(大概占缓存页大小的 5%-8%),记录了这个数据页属于哪个表空间、页编号是多少、在 Buffer Pool 的地址等元数据。

3. 三套链表

Buffer Pool 内部用三套链表管理缓存页:

3.1 free 链表:空闲页在哪?

用一个哈希表记录哪些缓存页是空闲的。key 是 表空间 + 数据页号,value 是缓存页地址。

要加载新数据页时,先查哈希表,如果没有就从磁盘加载进来。

1695276677212 2a0f35d7 f06c 4d1f b104 ef994fca148d

3.2 flush 链表:哪些是脏页?

你更新了 Buffer Pool 里的数据页,但还没刷回磁盘,这页就是脏页(跟磁盘不一致)。

flush 链表就是专门记录这些脏页的,方便后续批量刷盘。

3.3 lru 链表:淘汰谁?

用来管理缓存页的访问顺序,内存满了就从尾部淘汰。但 MySQL 的 LRU 不是简单的 LRU,而是冷热分离设计(见第 4 节)。

LRU 是双向链表,通过 3 个关键指针来管理:

                    lru_old 指针
                       ↓
热数据区头部 ←→ ... ←→ 分界点 ←→ ... ←→ 冷数据区尾部
 ↑头部指针                                      ↑尾部指针
指针 作用
头部指针 热数据区入口,提升的页插入到这里
lru_old 指针 冷热分界点,新加载的页插入到这个位置(冷数据区头部)
尾部指针 冷数据区末端,淘汰时从这里踢

每个节点自身的 prev/next 指针维护双向连接,保证核心操作都是 O(1):新页加载插到 lru_old 位置,页提升插到头部,淘汰从尾部踢出。

4. LRU 的冷热分离设计

普通 LRU 有个问题:预读机制或者全表扫描会把大量冷数据加载进来,把真正的热点数据挤出去。

MySQL 的解法是把 LRU 链表拆成两部分:

1695277598145 596a1d04 af2a 4c87 9259 ace6f6d9d22b

比例通过 innodb_old_blocks_pct 控制,默认 37%。

规则

  1. 新加载的页放在冷数据区头部
  2. 冷数据区的页被访问后,如果超过 1 秒innodb_old_blocks_time 默认 1000ms)还有再次访问,才移到热数据区头部
  3. 淘汰时从冷数据区尾部

这样预读和全表扫描进来的大批量数据,如果没有后续访问,就会待在冷数据区,被淘汰时也不会影响热数据。

热数据区的优化:前 1/4 的缓存页被访问时不移动到头部,后 3/4 才移动,减少频繁移动带来的性能开销。

这套冷热分离的设计思路挺值得借鉴的,Redis内存淘汰策略清单 里的 LRU 也是类似的思想。

4.1 举个例子:user 表 id=1 的旅程

假设 Buffer Pool 只有 10 个缓存页,冷数据区占 4 个(37% 取整),热数据区占 6 个。

第①步:第一次查询 SELECT * FROM user WHERE id = 1

user 表 id=1 的数据页不在 Buffer Pool,从磁盘加载,放到冷数据区头部,同时冷数据区尾部的 J 被淘汰:

加载前:                      加载后:
热区:[A][B][C][D][E][F]      热区:[A][B][C][D][E][F]  (不变)
冷区:[G][H][I][J]←淘汰点     冷区:[user][G][H][I]←淘汰点
                              ↑ 新页放这里

第②步:Buffer Pool 继续被使用
又有新数据要加载,冷数据区尾部的 I 被淘汰,user 被挤到中间:

热区:[A][B][C][D][E][F]  
冷区:[新数据][user][G][H]←淘汰点

第③步:1 秒内再次查询 user id=1

冷数据区的页被访问,但还没超过 1 秒不会移到热数据区,只更新访问时间戳。这是为了防止全表扫描时,同一页在短时间被多次访问就误判为热点。

第④步:过了 1 秒后,再次查询 user id=1

超过 innodb_old_blocks_time(默认 1000ms)还有访问,提升到热数据区头部

提升前:                          提升后:
热区:[A][B][C][D][E][F]          热区:[user][A][B][C][D][E]  ←user提升到这里
冷区:[新数据][user][G][H]        冷区:[新数据][G][H][F]←淘汰点
       ↑ 要提升的                         ↑ F被挤到冷区

对比:如果是全表扫描会怎样?

全表扫描一次性加载大量数据页到冷数据区,但这些页只被访问一次,不会超过 1 秒再被访问,所以不会被提升到热数据区。等 Buffer Pool 需要空间时,这些冷数据从尾部依次被淘汰,热数据区的真正热点数据安然无恙。

热区:[A][B][C][D][E][F]  ← 这些热点数据不受影响
冷区:[scan1][scan2][scan3][scan4]← 全表扫描数据,只访问一次,很快被淘汰

5. 脏页什么时候刷回磁盘?

有三种触发方式:

  1. 定时刷:后台线程定期把冷数据区尾部的一些缓存页刷回磁盘,释放回 free 链表
  2. flush 链表刷:后台线程定时把 flush 链表里的脏页刷盘,同时从 lru 链表移除,加入 free 链表
  3. 紧急刷:free 链表空了,没有空闲页了,从 lru 链表冷数据区尾部找一个缓存页强制刷盘,腾出位置

具体怎么配置多实例、chunk、生产环境参数,以及验证方法,见 [[Buffer Pool配置调优]]。