MySQL - InnoDB 的 Buffer Pool
磁盘慢、CPU 快这是一对天生的矛盾。InnoDB 用一整套内存管理机制来缓和这个矛盾这套机制就是 Buffer Pool。这篇文章按为什么需要缓存 → 缓存怎么组织 → 满了怎么淘汰 → 脏了怎么落盘 → 大内存高并发怎么优化这条线索梳理尽量把每个参数放回它要解决的具体问题里去理解而不是当成孤立的配置项死记。目录为什么需要 Buffer PoolBuffer Pool 的组成三张链表撑起整个管理逻辑LRU 链表决定淘汰谁脏页什么时候刷回磁盘多实例与 chunk为大内存、高并发准备SHOW ENGINE INNODB STATUS看实时状态一、为什么需要 Buffer PoolInnoDB 里所有数据——不管是索引聚簇索引、二级索引还是各种系统数据——本质上都是按页存放在磁盘上的。哪怕只想读一条记录也得先把它所在的整个 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 链表决定淘汰谁内存总是有限的缓存页迟早会用完。用完之后要装载新页就得先淘汰点旧的——淘汰谁这是整个缓存淘汰机制里最值得琢磨的部分。最朴素的想法按最近最少使用淘汰也就是 LRULeast 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/8SHOWVARIABLESLIKEinnodb_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 区头部间隔太短就留在原地。SHOWVARIABLESLIKEinnodb_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 8innodb_buffer_pool_size小于 1G 时这个设置不生效会被自动改回 1 个实例——Buffer Pool 太小拆分反而增加管理开销。innodb_buffer_pool_chunk_size5.7.5 之前调整 Buffer Pool 大小必须重启服务之后支持了运行时调整但做法不是整体重新申请内存再拷贝数据太慢而是以chunk默认 128M为单位增减。三者的约束关系innodb_buffer_pool_size必须是innodb_buffer_pool_chunk_size × innodb_buffer_pool_instances的整数倍这样才能保证每个实例分到的 chunk 数量一致。不满足时MySQL 会自动调整# 场景一size 不是整数倍自动向上取整# chunk_size 默认 128Minstances 8两者乘积 1Gmysqld --innodb-buffer-pool-size3.5G --innodb-buffer-pool-instances8# 3.5G 不是 1G 的整数倍服务器会自动把它调整为 4G# 场景二chunk_size × instances 反而超过了 sizechunk_size 会被调小mysqld --innodb-buffer-pool-size1G --innodb-buffer-pool-instances8--innodb-buffer-pool-chunk-size256M# 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 oldOld 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_pct、innodb_old_blocks_time这些参数就不再是需要死记的配置项而是分别对应着某个具体问题的解决手段——遇到命中率异常时也知道该往哪个方向去排查。