磁盘慢、CPU 快,这是一对天生的矛盾。InnoDB 用一整套内存管理机制来缓和这个矛盾,这套机制就是 Buffer Pool。这篇文章按"为什么需要缓存 → 缓存怎么组织 → 满了怎么淘汰 → 脏了怎么落盘 → 大内存高并发怎么优化"这条线索梳理,尽量把每个参数放回它要解决的具体问题里去理解,而不是当成孤立的配置项死记。
目录
- [为什么需要 Buffer Pool](#为什么需要 Buffer Pool "#sec-1")
- [Buffer Pool 的组成](#Buffer Pool 的组成 "#sec-2")
- 三张链表,撑起整个管理逻辑
- [LRU 链表:决定淘汰谁](#LRU 链表:决定淘汰谁 "#sec-4")
- 脏页什么时候刷回磁盘
- [多实例与 chunk:为大内存、高并发准备](#多实例与 chunk:为大内存、高并发准备 "#sec-6")
- [SHOW ENGINE INNODB STATUS:看实时状态](#SHOW ENGINE INNODB STATUS:看实时状态 "#sec-7")
一、为什么需要 Buffer Pool
InnoDB 里所有数据------不管是索引(聚簇索引、二级索引)还是各种系统数据------本质上都是按页存放在磁盘上的。哪怕只想读一条记录,也得先把它所在的整个 16KB 页加载进内存。
问题在于磁盘和内存的速度完全不是一个量级的。如果每次访问都老老实实读一次磁盘,性能根本没法看。所以 InnoDB 的做法是:页加载进内存后不立刻扔掉,而是缓存住,下次再有请求访问同一页时,直接从内存拿,省掉一次磁盘 IO。这片专门用来做缓存的内存区域,就是 Buffer Pool。
二、Buffer Pool 的组成
Buffer Pool 是 MySQL 启动时向操作系统申请的一整块连续内存,默认大小 128M,可以通过启动参数 innodb_buffer_pool_size 调整(最小 5M,低于这个值会被强制拉到 5M):
ini
[server]
innodb_buffer_pool_size = 268435456 # 256M,单位是字节
这块内存内部分三部分:
- 控制块:每个缓存页都配一个控制块,记录这一页属于哪个表空间、页号、在链表中的位置等元信息,存放在整块内存的前半段。
- 缓存页:真正存数据的地方,大小和磁盘页一致,默认 16KB,存放在整块内存的后半段。
- 碎片:控制块和缓存页必须一一配对,凑不齐一整对的那点空间用不上,就是碎片。
每个控制块大约占用一个缓存页大小的 5%,所以 InnoDB 实际申请到的内存,会比
innodb_buffer_pool_size设置的值略大一些。
三、三张链表,撑起整个管理逻辑
管理 Buffer Pool,核心靠三样东西:
① free 链表:谁是空的
初始化完成时,所有缓存页都是空的,全部登记在 free 链表上。每次要从磁盘加载一个新页,就从这条链表上摘一个节点下来用,用完就把它从链表里移除。
② 哈希表:这一页到底在不在缓存里
访问一个页之前,得先知道它是不是已经在 Buffer Pool 里了------总不能每次都把所有缓存页遍历一遍。解决办法很直接:用「表空间号 + 页号」当 key,缓存页当 value,建一张哈希表,一查便知。
③ flush 链表:谁被改过、还没落盘
缓存页被修改后就和磁盘上的原页不一致了,这种页叫"脏页"。脏页不会立刻同步回磁盘(太频繁地写磁盘会拖垮性能),而是先登记进 flush 链表,等到合适的时机再统一处理。
四、LRU 链表:决定淘汰谁
内存总是有限的,缓存页迟早会用完。用完之后要装载新页,就得先淘汰点旧的------淘汰谁,这是整个缓存淘汰机制里最值得琢磨的部分。
最朴素的想法
按"最近最少使用"淘汰,也就是 LRU(Least Recently Used)。实现起来很直白:每访问一个缓存页,就把它挪到链表头部;链表尾部自然就是最久没被碰过的,缓存不够时从尾部淘汰就好。
但这个朴素版本有两个坑
坑一:预读。InnoDB 会做"预读"------猜测接下来可能要用到的页,提前加载进 Buffer Pool。分两种:
- 线性预读 :顺序访问某个区(extent)里的页超过
innodb_read_ahead_threshold(默认 56)个之后,就异步把下一整个区都读进来。 - 随机预读 :某个区里已经缓存了 13 个页(不要求连续访问),就异步预读这个区剩下的页;默认关闭(
innodb_random_read_ahead = OFF)。
预读是把双刃剑:猜对了能明显提速,猜错了这些用不上的页会占住链表头部,把真正的热点数据往尾部挤,白白拉低命中率。
坑二:全表扫描。一条没写好索引、甚至没有 WHERE 子句的查询,会把整张表的页全部加载一遍。如果这张表很大,相当于把 Buffer Pool 里原本缓存的热点数据"洗牌"了一遍------而这类语句执行频率通常并不高,代价却很大。
解法:把 LRU 链表切成 young 和 old 两个区域
innodb_old_blocks_pct 控制 old 区域占比,默认 37(约 3/8):
sql
SHOW VARIABLES LIKE 'innodb_old_blocks_pct';
-- 37 ------ old 区域约占 LRU 链表的 3/8,其余是 young 区域
举个例子:假设某个 Buffer Pool 能装 10000 个缓存页,按默认的 37% 计算,old 区域大约 3700 个页,young 区域大约 6300 个页。
规则很简单,但很管用:
- 磁盘页第一次被加载进来,只放到 old 区域的头部,不会一步到位进 young 区。这样预读进来却没人用的页,会自然在 old 区域里被淘汰掉,不会伤及 young 区的热点数据。
- 但这样还不够:全表扫描时,页面进了 old 区头部之后马上就会被访问到(毕竟是刚查的这张表),如果访问一次就立刻升级到 young 区头部,热点数据还是会被顶掉。所以又加了一条时间限制------只有首次访问和最近一次访问的时间间隔超过
innodb_old_blocks_time(默认 1000 毫秒),才把这个页挪到 young 区头部;间隔太短就留在原地。
sql
SHOW VARIABLES LIKE 'innodb_old_blocks_time';
-- 1000 ------ 单位毫秒
举个对比:假设一次全表扫描在 300 毫秒内把某一页的 20 条记录都读完了------这 20 次访问的时间跨度远小于 1000 毫秒,所以这一页会一直待在 old 区域,很快被淘汰,不会影响 young 区;而如果一个页面在 old 区域待了超过 1 秒后又被真实业务访问到,就说明它不是"一次性扫过场",值得升级进 young 区。
再抠一点性能:young 区域没必要每次都挪头部
对 young 区域来说,如果每次访问都要把节点挪到链表头部,调整链表本身也是有开销的,而 young 区里大多是热点数据,访问频率很高。所以又做了一个优化:只有节点位于 young 区域后 3/4 的部分被访问时才会挪到头部,前 1/4 的节点即使被访问,也不用再挪------减少不必要的链表调整,换取更好的性能。
五、脏页什么时候刷回磁盘
后台线程会定期把脏页同步回磁盘,主要有三种方式:
| 方式 | 触发时机 |
|---|---|
| BUF_FLUSH_LRU | 定期从 LRU 链表尾部扫描一段(扫描深度由 innodb_lru_scan_depth 控制),把其中的脏页刷盘 |
| BUF_FLUSH_LIST | 定期从 flush 链表里取一批页刷盘,速率取决于系统当前繁忙程度 |
| BUF_FLUSH_SINGLE_PAGE | 后台刷得不够快、正好又没有空闲缓存页可用时,被迫现刷一个脏页腾地方 |
前两种是"后台悄悄干活",不影响正常请求;第三种则是"用户线程被迫等一次磁盘 IO",属于不得已的情况,也是排查性能问题时值得关注的信号。
六、多实例与 chunk:为大内存、高并发准备
多个 Buffer Pool 实例 :Buffer Pool 越大、并发访问越多,单一实例内部各种链表的加锁竞争就越明显,可能反而拖慢请求处理。解法是通过 innodb_buffer_pool_instances 把它拆成若干个相互独立的实例,各自管理各自的链表,互不干扰:
ini
[server]
innodb_buffer_pool_instances = 8
innodb_buffer_pool_size小于 1G 时,这个设置不生效,会被自动改回 1 个实例------Buffer Pool 太小,拆分反而增加管理开销。
innodb_buffer_pool_chunk_size :5.7.5 之前,调整 Buffer Pool 大小必须重启服务;之后支持了运行时调整,但做法不是整体重新申请内存再拷贝数据(太慢),而是以 chunk(默认 128M)为单位增减。
三者的约束关系 :innodb_buffer_pool_size 必须是 innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances 的整数倍,这样才能保证每个实例分到的 chunk 数量一致。不满足时,MySQL 会自动调整:
bash
# 场景一:size 不是整数倍,自动向上取整
# chunk_size 默认 128M,instances = 8,两者乘积 = 1G
mysqld --innodb-buffer-pool-size=3.5G --innodb-buffer-pool-instances=8
# 3.5G 不是 1G 的整数倍,服务器会自动把它调整为 4G
# 场景二:chunk_size × instances 反而超过了 size,chunk_size 会被调小
mysqld --innodb-buffer-pool-size=1G --innodb-buffer-pool-instances=8 --innodb-buffer-pool-chunk-size=256M
# 256M × 8 = 2G,大于指定的 1G
# 于是 chunk_size 被自动改写为 1G / 8 = 128M
七、SHOW ENGINE INNODB STATUS:看实时状态
sql
SHOW ENGINE INNODB STATUS\G
这条命令能看到 Buffer Pool 的运行时数据,几个最值得关注的字段:
| 字段 | 含义 |
|---|---|
| Buffer pool size | 能容纳的缓存页数量(单位是页,不是字节) |
| Free buffers | 当前空闲缓存页数量(free 链表长度) |
| Database pages | LRU 链表节点总数(young + old) |
| Old database pages | LRU 链表中 old 区域的节点数 |
| Modified db pages | 脏页数量(flush 链表长度) |
| Pending reads / writes | 正在等待从磁盘加载 / 正在等待刷盘的页面数量 |
| Pages made young | 从 old 区域升级到 young 区域头部的次数 |
| Buffer pool hit rate | 缓存命中率------平均访问 1000 次,有多少次命中了缓存 |
比如输出显示 Buffer pool hit rate 998 / 1000,说明平均 1000 次访问里有 998 次直接命中缓存,只有 2 次要真正读磁盘,健康状态;如果这个数字掉到 850 / 1000 甚至更低,就说明命中率不够、物理 IO 压力偏大,通常意味着该考虑加大 Buffer Pool,或者揪出那些拖累命中率的全表扫描语句了。
小结
Buffer Pool 的设计逻辑其实很清楚:磁盘和内存速度差太多,所以要缓存;缓存空间有限,所以要有淘汰策略;朴素的 LRU 会被预读和全表扫描"污染",所以拆出 young / old 两个区域,再用一个时间窗口过滤掉"一次性"的访问 。想通这条链路,innodb_old_blocks_pct、innodb_old_blocks_time 这些参数就不再是需要死记的配置项,而是分别对应着某个具体问题的解决手段------遇到命中率异常时,也知道该往哪个方向去排查。