MySQL - InnoDB 的 Buffer Pool

磁盘慢、CPU 快,这是一对天生的矛盾。InnoDB 用一整套内存管理机制来缓和这个矛盾,这套机制就是 Buffer Pool。这篇文章按"为什么需要缓存 → 缓存怎么组织 → 满了怎么淘汰 → 脏了怎么落盘 → 大内存高并发怎么优化"这条线索梳理,尽量把每个参数放回它要解决的具体问题里去理解,而不是当成孤立的配置项死记。

目录

  1. [为什么需要 Buffer Pool](#为什么需要 Buffer Pool "#sec-1")
  2. [Buffer Pool 的组成](#Buffer Pool 的组成 "#sec-2")
  3. 三张链表,撑起整个管理逻辑
  4. [LRU 链表:决定淘汰谁](#LRU 链表:决定淘汰谁 "#sec-4")
  5. 脏页什么时候刷回磁盘
  6. [多实例与 chunk:为大内存、高并发准备](#多实例与 chunk:为大内存、高并发准备 "#sec-6")
  7. [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_pctinnodb_old_blocks_time 这些参数就不再是需要死记的配置项,而是分别对应着某个具体问题的解决手段------遇到命中率异常时,也知道该往哪个方向去排查。

相关推荐
L1624763 小时前
MySQL 全环境生产快速安装 + 完整配置手册(整合国产银河麒麟适配 + 主从补充版)
数据库·mysql
前端世界4 小时前
MySQL 基础入门(学习第4天)——约束与 DML 操作详解
数据库·学习·mysql
Vect__6 小时前
MySQL 数据类型和约束:从字段设计到表结构建模
数据库·mysql
Mico188 小时前
MySQL 8.0.35 基于GTID 主从复制安装增强半同步复制
android·mysql·adb
CodexDave20 小时前
MySQL事务隔离级别与MVCC机制解析
前端·数据库·mysql·nginx·性能优化·负载均衡
王琦03181 天前
生产环境中使用通用二进制包安装
mysql
蓝田~1 天前
MySQL慢查询怎么优化?B+Tree索引原理+MVCC读不阻塞写+EXPLAIN执行计划,从5秒到0.05秒
数据库·mysql
Herbert_hwt1 天前
MySQL学习前言:关于MySQL的历史与当下学习的必要性
mysql
时间的拾荒人1 天前
MySQL 视图详解
android·数据库·mysql