搞 MySQL 这些年,我手机里存得最多的截图不是各种监控大屏,而是SHOW ENGINE INNODB STATUS里 BUFFER POOL AND MEMORY 那一小段。很多看似莫名其妙的“数据库突然变慢、磁盘读飙升、接口超时”,最后基本都能落到 bufferpool 这一个点上:要么热区被全表扫描污染了,要么脏页刷不过来导致后台打架,要么重启之后缓存没预热,冷冰冰的缓冲池直接扛流量。
这篇东西不是系统的原理课,更像是我在生产环境和测试环境里摸 bufferpool 时攒下的一堆杂知识——它内部怎么组织、为什么教科书上的 LRU 在 InnoDB 里不好使、哪些参数动了真有价值、调参时最容易踩哪些坑。如果你正在维护 MySQL,并且每次看innodb_buffer_pool_size就知道它“越大越好”,但对其他细节两眼一抹黑,那这篇文章值得你当个随身笔记看。
1. 先搞清楚 bufferpool 到底是啥
1.1 一条 SQL 是怎么让缓冲池“动手”的
你可以把 bufferpool 想象成厨房里的操作台:菜放在冰箱(磁盘)里,但每次做菜都从冰箱拿,手会冻僵、腰会累断。操作台(内存)上放着最常用的食材,拿取速度就快得多。数据库也一样,所有数据页、索引页都住在一张“大桌子”上。
一条普通的SELECT过来时,InnoDB 会先去 bufferpool 里找对应的数据页。找到了就是“命中”,直接内存返回,这个过程中磁盘完全没参与;找不到就是“未命中”,只能从磁盘把那 16KB 的数据页读进内存,再返回结果。写入路径也类似,但多一个步骤:页面先改在内存里,同时生成 redo log,后续再慢慢把脏页刷到磁盘。这套机制决定了数据库的读写性能上限,本质就是“能用内存挡多少磁盘请求”。
我经常跟新人讲一个暴论:数据库快不快,很多时候不取决于 CPU 有多强,而在于你往磁盘要数据的次数有多频繁。CPU 再强,如果每次查询都压在磁盘 IO 上,那体验就是“八核处理器跑出了单核红米的效果”。而 bufferpool 就是挡住这种灾难最关键的一道闸门。
1.2 页、索引和数据是怎么塞进内存的
InnoDB 里的最小单位不是行,而是页。默认情况下一个页是 16KB,里面可能装了十几行到几千行数据,具体看行的大小。索引也是一页一页组织的,B+ 树的每个节点本质上就是一个页。所以所谓“把表加载到内存”,其实不是说把整张表的行一个个摆进去,而是让 B+ 树沿途的索引页和数据页都尽可能留在内存里。
这里有个容易忽略的点:压缩表会让事情变复杂。如果开了表压缩,磁盘上可能是 8KB 甚至 4KB 的压缩页,但进内存之后要解压成原始大小,于是 InnoDB 维护了两套 LRU 结构,一套是常规的 buffer pool LRU,另一套是 unzip_LRU 来管解压页。你在SHOW ENGINE INNODB STATUS里会看到unzip_LRU len这个字段,很多时候它不为零,就是压缩表在起作用。
另外要提一嘴 change buffer。它本身也是 bufferpool 的一部分,专门用来缓冲“对二级索引的写操作”。比如你往一张表里插数据,主键索引可以顺序写,但二级索引位置散乱,如果每次都要先读索引页再改,代价很高。change buffer 把这些变更先攒在内存里,等后续合并。它默认占用 bufferpool 的 25%,可以通过innodb_change_buffer_max_size调整。很多 DBA 不知道这玩意儿的存在,结果在排查内存占用时一脸懵。
1.3 缓冲池小了的代价,不止是慢
缓冲池不够用的时候,最直观的问题是“命中率低”。但更麻烦的是高频淘汰带来的连锁反应:每一次新页读入,都要把某个旧页挤出去;如果那个旧页恰好是脏页,还得先把它刷到磁盘才能腾位置,一个查询的响应时间就不止是“多一次磁盘读”,而是“一次磁盘读 + 一次磁盘写 + 等待”。在高并发下,这种颗粒状的读写叠加起来,数据库很容易变成一台忙着搬家却没空干活的机器。
我之前接过一个客户的环境,他们的 MySQL 跑在 32GB 内存的机器上,innodb_buffer_pool_size只给了 4GB,业务数据却有 60GB。结果就是每天业务峰值时段磁盘读每秒好几万次,很多简单的SELECT要几百毫秒。后来把内存加到 64GB,bufferpool 给到 40GB,同样的 SQL 直接降到个位数毫秒。这个案例没什么高深技巧,纯粹是缓冲池太小,系统天天在做无用功。
2. 内部结构:一个“加了保险”的 LRU 和一条 flush 列表
2.1 教科书 LRU 为啥在数据库里不好使
大学课程里讲 LRU,通常是一张很简单的链表:数据被访问就挪到头部,链表尾部就是淘汰候选。这个模型在数据库场景下有一个致命问题:全表扫描。
假设一张表有 100GB 数据,你半夜跑一个报表查询,从头到尾扫了整张表。如果用标准 LRU,这些被扫过一遍的页会全部跑到链表头部,把原本真正的热点数据挤到尾部。报表查完,几十 GB 的冷数据占满了热区,第二天早高峰一来,热点数据全不在内存里,系统命中率瞬间崩盘。这就是俗称的缓存污染,也是 InnoDB 没有直接使用朴素 LRU 的根本原因。
InnoDB 的 LRU 被分成了两段:young 区(新子列表)和 old 区(旧子列表)。你可以粗暴地理解为“热区”和“冷区”。新读入的页面不会直接进热区,而是先进冷区待着,只有在冷区里“活过”一定时间还被继续访问,才有资格被提拔到热区。这样偶发扫描的冷数据就不会影响真正的热数据。
2.2 old 区、young 区和“隐蔽晋升”规则
具体参数是innodb_old_blocks_pct,默认 37,代表 old 区占整个 LRU 列表的 37%。新页面进来时,会插入到 old 区的头部,也就是整个列表大概 63% 的位置。之后页面的命运有两种:
- 如果这个页面在 old 区停留超过
innodb_old_blocks_time毫秒,再次被访问时就会被晋升到 young 区头部。 - 如果没超过这个时间就被再次访问,那它大概率只是被一次扫描偶然带过,不会被提拔。
innodb_old_blocks_time默认是 1000 毫秒,单位是毫秒。也就是说,一个页面进入内存后,至少要在冷区待 1 秒钟,才有资格变成热数据。这个设计非常巧妙:它让“临时访问”的页面在冷区自然滑落,但真正被反复查询的页面能顺利晋升。
实际调优时,如果你们业务里确实有跑批任务、报表查询,但又不希望它们污染热区,可以把innodb_old_blocks_time调大一些,比如 2000 甚至 5000。我试过最极端的场景是数仓同步任务,每 5 分钟扫描一次大表,原来的热数据总是被冲掉,后来把时间调到 3000,问题基本消失。当然,这个值不能无限大,否则真实的周期性热点数据也会被拦在热区之外,每次查询都要重新读盘,反而误伤。
2.3 flush 列表:和 LRU 并行的另一条主线
LRU 负责管“哪些页在内存里”,但脏页还有一个独立的管理结构叫 flush 列表。它按修改顺序(也就是 oldLSN 的顺序)记录所有脏页。刷盘的时候,InnoDB 并不按 LRU 的淘汰顺序刷,而是按 flush 列表排好的顺序,从最老的脏页开始往下刷。
这个区别很重要。因为最早修改的页面,往往是崩溃恢复时要回放 redo log 的起点,它们越早落盘,系统里需要容忍的 redo 就越短,checkpoint 也能推进得更快。你在SHOW ENGINE INNODB STATUS里看到的Modified db pages就是当前 flush 列表里的脏页数量。如果这个数字长期居高不下,说明刷脏速度跟不上生产速度,接下来磁盘 IO 和响应时间大概率都会恶化。
3. 参数调优实操:给多少内存、分多少实例、要不要预热
3.1 buffer_pool_size:先算机器再说“多多益善”
很多人一看innodb_buffer_pool_size是缓存,就直接往最大值怼。这其实很危险。bufferpool 是 InnoDB 私有的,可 MySQL 本身还有词法缓存、表缓存、连接线程、临时表等其他内存消耗,操作系统也要留 page cache 给它自己用。
我的参考基准是:如果这台机器是专用 MySQL 服务器,bufferpool 可以给到总内存的 50% 到 70%;如果机器上同时还跑着应用服务、监控 agent、日志收集器,那就要再保守一些。举个例子,64GB 内存、只跑 MySQL 实例,给 40GB 到 48GB 比较合理。留出的空间要给 OS page cache,因为 MySQL 读 binlog、读表空间文件的预读也需要 OS 缓存帮忙,一点内存都不留很危险。
还有一点,MySQL 8.0 的innodb_buffer_pool_size已经支持在线调整了,不用重启实例。但动态扩容时内存是按 chunk 分配的,chunk 大小默认 128MB,所以你在设置值时,最好设置在 128MB 的整数倍附近,避免内部做额外的对齐工作。这块没有太多性能上的坑,主要是不明不白的内存碎片看着心烦。
3.2 buffer_pool_instances:分片不是越多越好
innodb_buffer_pool_instances的作用,是把一个大缓冲池拆成多个小缓冲池实例,每个实例有自己独立的 LRU 列表和相关锁,从而减少高并发下的锁竞争。这个设计本质上是分片思想,跟 Redis 的 hash slot 分区有点类似。
但分片数量不是越大越好。实例太多,每个实例管理的页数变少,某些热点页所在实例的竞争反而可能更集中,而且维护结构的开销也上去了。官方默认规则是:bufferpool 大于等于 1GB 时,默认实例数为 8。我个人的习惯是,32GB 的池子拆 8 个实例,64GB 拆 16 个,不再多拆。如果你拿不准,让系统默认就好,这个参数一般不会成为瓶颈。
CPU 核数也可以作为参考:实例数尽量别超过 CPU 核数的一半,否则线程切换成本比锁竞争还高。这个偏方不是官方建议,是我自己压测对比下来的经验,放出来供参考。
3.3 预热三件套:让重启不再“冷启动”
数据库重启后最怕什么?bufferpool 空空如也,所有请求都要从磁盘读,磁盘 IO 瞬间拉满,服务雪崩。以前很多团队不敢随便重启 MySQL,就是因为这个“冷启动阵痛期”。
InnoDB 提供了预热机制,简单说就是把当前 bufferpool 中的页描述信息在关库时 dump 到磁盘文件,启动时再 load 回来。注意它预热的不是页数据本身,而是一份“哪些页值得加载”的清单,真正加载时还是要从表空间读取数据。相关参数有四个:
innodb_buffer_pool_dump_at_shutdown:关库时导出页信息,建议 ON。innodb_buffer_pool_load_at_startup:启动时自动加载,建议 ON。innodb_buffer_pool_dump_now:手动立即导出,适合在线维护前用。innodb_buffer_pool_load_now:手动立即加载,适合刚启动完、业务流量上来之前用。
我上线前常见的操作是:先SET GLOBAL innodb_buffer_pool_dump_now=ON,等innodb_buffer_pool_dump_status显示 completed,再做维护动作。重启完成后执行SET GLOBAL innodb_buffer_pool_load_now=ON,马上观察innodb_buffer_pool_load_status。实测下来,配置了预热之后,重启对业务的影响窗口能缩小到原来的三分之一甚至更少。
4. 脏页刷新和 checkpoint:后台那台“洗碗机”怎么转
4.1 脏页为什么可以赖在内存里不走
先解释一个概念:脏页就是被修改过、但还没写回磁盘的页。很多人一开始会困惑:为什么不每次修改都立即写盘?因为立即写盘会把随机小写入放大,磁盘 IO 会瞬间被打爆。InnoDB 的做法是:数据页先在内存里改,同时生成 redo log 并落盘,这样即使数据库崩溃,也能通过 redo log 找回修改。脏页本身可以慢悠悠地攒着,等后台线程批量刷盘。
这个设计很像洗盘子:吃完一顿饭不急着洗每一个盘子,先泡在水池里,等攒够一池了再开洗碗机洗一批。只要洗碗机的处理速度能跟上你吃饭的速度,就不会出问题。如果盘子生产速度长期高于洗碗机吞吐量,那问题就大了——水池满了,水槽堵了,厨房就要闹水灾。
4.2 四类刷新来源,对应四套参数
脏页刷盘不是只有一个入口,至少有四类情况会触发:
- LRU 淘汰触发:新页要进来,但 LRU 尾部的页是脏页,必须先刷掉才能复用。
- 后台刷新线程:InnoDB 有一组后台线程,专门盯着脏页比例和 redo 生成速度,动态调整刷盘节奏。
- 脏页比例过高:当脏页占比超过
innodb_max_dirty_pages_pct时,会触发强制刷盘,默认值是 90。 - 关闭实例:干净关闭时要把脏页全部刷完,所以重启操作往往伴随一次比较长的“收尾刷盘”。
调优时最值得动的是innodb_io_capacity。这个参数表示 InnoDB 认为磁盘系统每秒能处理多少次 IO,默认只有 200,这个值在现代 SSD 上明显偏保守。如果磁盘是 SATA SSD,我一般把它设到 500 到 1000;如果是 NVMe SSD,1000 到 2000 很常见。设得太低,后台刷新线程会觉得自己能力有限,磨洋工,脏页越积越多;设得太高,又可能让后台刷盘抢掉前台查询的 IO。
另外,innodb_max_dirty_pages_pct我个人不建议为了“干净”而调很低。比如有人把它调成 50,结果脏页刚到一半就开始疯狂刷盘,磁盘写入频繁增加,整体吞吐反而下降。这个值的意义是设一个安全上限,不是让你去追求内存里没脏页。数据一致性由 redo 保证,脏页占比高点不丢数据。
4.3 checkpoint 和崩溃恢复是一条绳上的
checkpoint 简单说,就是告诉系统“redo log 里截止到哪个 LSN 的修改,对应的脏页已经安全落盘了”。checkpoint 位置越靠前,说明需要回放的 redo 越少,崩溃恢复时间越短。
InnoDB 用的是模糊检查点,不是把所有脏页都刷完才记账,而是选定一个 LSN 位置,确保它之前的部分脏页已经落盘,然后推进 checkpoint。刷盘越快,checkpoint 越能跟上 redo 的推进,redo log 文件也能更早地循环复用。如果刷盘能力跟不上,redo log 会频繁切换文件,严重时还会触发日志等待,把所有写入卡住。所以调节 dirty page 相关参数,本质是在“刷盘能力”和“redo 推进速度”之间找平衡。
5. 监控 bufferpool:状态变量和现场翻车实录
5.1 两分钟看懂 SHOW ENGINE INNODB STATUS
很多 DBA 知道开这个命令,但不知道看哪一行。BUFFER POOL AND MEMORY 那段有几项很关键:
| 字段 | 含义 | 我的关注点 |
|---|---|---|
| Buffer pool size | 缓冲池总页数 | 乘以 16KB 就是当前缓冲池大小 |
| Free buffers | 空闲页数 | 长期偏低是正常的,长期偏高说明池子给大了 |
| Database pages | 已使用页数 | 和 Free buffers 加起来接近总页数 |
| Old database pages | old 区页数 | 如果异常高,可能有大扫描正在流经冷区 |
| Modified db pages | 脏页数量 | 如果持续走高,检查刷盘节奏 |
| Buffer pool hit rate | 命中率(千分比) | 低于 950 就该警惕了 |
这里要注意,Buffer pool size显示的是页数,不是字节数。比如它显示 262144,那缓冲池大小就是 262144 × 16KB = 4GB。很多新手第一次看会把页数当字节数,然后激动地说“我的缓冲池怎么有几百万 GB”。
5.2 命中率计算公式别记反
通过状态变量算整体命中率,公式不复杂:(Innodb_buffer_pool_read_requests - Innodb_buffer_pool_reads) / Innodb_buffer_pool_read_requests × 100%。其中read_requests是逻辑读请求次数,reads是真正从磁盘读页的次数。逻辑读次数通常比物理读次数大几个数量级。
但有个细节要注意:Innodb_buffer_pool_reads包含首次读入和淘汰后再读入的所有磁盘读。如果命中率看着不低,但磁盘 IO 还是很高,那可能是Innodb_buffer_pool_read_ahead预读页数太多。预读也算物理读,但它读进来的页面不一定马上被用上,这时命中率和用户体验会出现“脱节”。所以别只看命中率一个数字,要把预读数据也捞出来看。
读取这些变量用一句 SQL 就够:
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_%';我习惯把结果配合SHOW ENGINE INNODB STATUS的Modified db pages一起看,能快速判断问题是“池子小”还是“刷盘慢”。
5.3 一次典型的“缓存命中率骤降”排查
说个我印象很深的实际案例。有一个业务库,平时命中率稳定在 99% 左右,但某段时间每天下午都会出现半小时左右的接口变慢,磁盘读飙升。当时 QPS 没有明显增长,排除流量突增之后,我把目光放到了 bufferpool 上。
第一步看状态变量,发现Innodb_buffer_pool_read_requests没怎么变,但Innodb_buffer_pool_reads在午后明显攀升,命中率从 990‰ 掉到 920‰ 附近。第二步看SHOW ENGINE INNODB STATUS,Old database pages数量比平时高了一大截,基本可以断定有大查询正在“灌入”冷区。第三步查慢日志和 processlist,果然发现一个每天定时执行的报表任务,凌晨启动了全表扫描,但由于innodb_old_blocks_time用的默认值,再加上扫描过程持续了一段时间,部分扫描页还是成功晋升到了热区,把真正的高频热点挤了出去。
处理方式分两步:把innodb_old_blocks_time从 1000 调到 3000,同时给报表查询加了一个专门的 MySQL 实例,避免它继续影响在线业务。调整后,命中率恢复到了 99.3%,那段接口变慢的时间窗口从此消失。这个案例给我的最大启发是:很多“数据库问题”其实不是 SQL 写得烂,而是 bufferpool 的隔离机制没做好,让冷数据把热数据挤掉了。
6. 新手最容易踩的坑清单
6.1 全表扫描正在污染你的热数据
这个问题我在前面反复强调过,因为它是 bufferpool 世界里最常见的“隐形杀手”。尤其是那些只跑一次的分析型查询、COUNT(*)大计数、备份导出任务,它们扫过的页面如果不加约束,会严重干扰热区。
除了调大innodb_old_blocks_time,还可以在应用层面把这类查询引导到只读从库上,或者直接加更合理的索引,让执行计划从全表扫变成索引范围扫。还有人会问,能不能在 SQL 层面对某个查询强制“不走缓存”?MySQL 里没有真正的“SQL_NO_CACHE”可用,那是 MySQL 8 之前的老语法,现在已经没这功能了。所以治理全表扫描,主要靠参数和应用拆分,别指望一句 SQL 就能隔离。
6.2 预读不是越多越好
InnoDB 有顺序预读和随机预读两种机制。顺序预读默认开启,当检测到顺序扫描超过一定阈值时,会提前把一个范围的页读入 bufferpool。这个机制在经典的大表场景下能显著提升效率,但也存在副作用:如果只是偶尔一次全表扫,预读会额外占用大量内存和 IO。
随机预读默认是关闭的,理论上能利用磁盘的随机读取能力,但在现代 SSD 上收益有限,而且很容易引入无谓的预取。我的习惯是保持随机预读关闭,顺序预读阈值innodb_read_ahead_threshold保持默认。除非你非常清楚自己的业务模式,否则预读类参数不建议乱动,默认值在绝大多数场景下是平衡得最好的。
6.3 几个关于内存分配和压缩表的冷知识
最后补充几个容易被忽略的点。第一,buffer pool的统计内存里,除了页数据本身,还包括控制块、哈希索引、锁信息等元数据,所以实际占用会比innodb_buffer_pool_size设置值略大。在计算整体内存规划时,我一般会预留 10% 的富余量。
第二,如果你用了压缩表,内存里既要放压缩页也要放解压页,unzip_LRU 会额外占内存。有时候memory allocated显示值高于buffer_pool_size设置值,不要恐慌,先确认是不是压缩表的锅,然后再决定是调小压缩表还是调大缓冲池。
第三,MySQL 5.7 和 8.0 的系统表空间、自适应哈希索引等结构也跟 bufferpool 有交互,但大部分细节不需要你深入理解。真正要做到的,是在调参之前先留下完整的状态数据,别凭感觉盲目加大内存。没有监控数据支撑的调优,约等于闭眼开车。
我在实际运维中还有一个习惯,就是每次重启数据库之前,先执行一次SET GLOBAL innodb_buffer_pool_dump_now=ON,确认导出完成后再关库。重启后也别急着把流量瞬间放大,等innodb_buffer_pool_load_status显示加载完成,再逐步放开。这套流程我用了很多年,基本没因为“冷启动”翻过车。
说回 bufferpool 本身,它就是数据库性能最核心的那块压舱石。很多时候我们讨论 SQL 优化、索引优化、分库分表,但真正拦住性能大坝的却是这块内存怎么管、怎么刷、怎么保护热数据。你越了解它,越会发现:数据库优化不是玄学,而是对每一个后台机制的精准理解。哪怕今天只搞懂一个old_blocks_time,明天的排查效率都会不一样。