MySQL - InnoDB 的 Buffer Pool
2026/7/26 7:55:32 网站建设 项目流程

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

目录

  1. 为什么需要 Buffer Pool
  2. Buffer Pool 的组成
  3. 三张链表,撑起整个管理逻辑
  4. LRU 链表:决定淘汰谁
  5. 脏页什么时候刷回磁盘
  6. 多实例与 chunk:为大内存、高并发准备
  7. SHOW ENGINE INNODB STATUS:看实时状态

一、为什么需要 Buffer Pool

InnoDB 里所有数据——不管是索引(聚簇索引、二级索引)还是各种系统数据——本质上都是按页存放在磁盘上的。哪怕只想读一条记录,也得先把它所在的整个 16KB 页加载进内存。

问题在于磁盘和内存的速度完全不是一个量级的。如果每次访问都老老实实读一次磁盘,性能根本没法看。所以 InnoDB 的做法是:页加载进内存后不立刻扔掉,而是缓存住,下次再有请求访问同一页时,直接从内存拿,省掉一次磁盘 IO。这片专门用来做缓存的内存区域,就是Buffer Pool

二、Buffer Pool 的组成

Buffer Pool 是 MySQL 启动时向操作系统申请的一整块连续内存,默认大小 128M,可以通过启动参数innodb_buffer_pool_size调整(最小 5M,低于这个值会被强制拉到 5M):

[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):

SHOWVARIABLESLIKE'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 区头部;间隔太短就留在原地。
SHOWVARIABLESLIKE'innodb_old_blocks_time';-- 1000 —— 单位毫秒

举个对比:假设一次全表扫描在 300 毫秒内把某一页的 20 条记录都读完了——这 20 次访问的时间跨度远小于 1000 毫秒,所以这一页会一直待在 old 区域,很快被淘汰,不会影响 young 区;而如果一个页面在 old 区域待了超过 1 秒后又被真实业务访问到,就说明它不是"一次性扫过场",值得升级进 young 区。

再抠一点性能:young 区域没必要每次都挪头部

对 young 区域来说,如果每次访问都要把节点挪到链表头部,调整链表本身也是有开销的,而 young 区里大多是热点数据,访问频率很高。所以又做了一个优化:只有节点位于 young 区域后 3/4 的部分被访问时才会挪到头部,前 1/4 的节点即使被访问,也不用再挪——减少不必要的链表调整,换取更好的性能。

把前面三、四两节的 free / flush / LRU 三条链表放到一起,Buffer Pool 的管理结构就是这样:


五、脏页什么时候刷回磁盘

后台线程会定期把脏页同步回磁盘,主要有三种方式:

方式触发时机
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把它拆成若干个相互独立的实例,各自管理各自的链表,互不干扰:

[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 会自动调整:

# 场景一:size 不是整数倍,自动向上取整# chunk_size 默认 128M,instances = 8,两者乘积 = 1Gmysqld --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:看实时状态

SHOWENGINEINNODBSTATUS\G

这条命令能看到 Buffer Pool 的运行时数据,几个最值得关注的字段:

字段含义
Buffer pool size能容纳的缓存页数量(单位是页,不是字节)
Free buffers当前空闲缓存页数量(free 链表长度)
Database pagesLRU 链表节点总数(young + old)
Old database pagesLRU 链表中 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这些参数就不再是需要死记的配置项,而是分别对应着某个具体问题的解决手段——遇到命中率异常时,也知道该往哪个方向去排查。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询