1. 从一次诡异的“内存泄漏”说起:分清操作系统缓冲池与数据库缓冲池
先讲个我上周遇到的事。群里有人发截图,说Windows 11任务管理器显示“非分页缓冲池”占用高达4GB,怀疑数据库把内存泄漏了,疯狂重启MySQL,问题依然存在,都准备重装系统了。
这个场景太典型了。很多人把“缓冲池”三个字直接等同于数据库的Buffer Pool,一看到系统监控里出现“非分页缓冲池”“分页缓冲池”就慌了。实际上这是两套完全不同的东西。
非分页缓冲池(Nonpaged Pool)和分页缓冲池(Paged Pool)是Windows操作系统内核的内存区域,用来存放内核对象、设备驱动、文件系统过滤驱动(比如杀毒软件、备份Agent)等不能或者可以被换出到磁盘的数据。数据库的缓冲池是用户态进程自己的内存,压根不归这里管。真正导致非分页缓冲池暴涨的,通常是网卡驱动、杀毒软件冲突、某个存储驱动的BUG,和MySQL、PostgreSQL关系不大。
我当时的排查建议是:打开任务管理器→性能→内存,看“提交”和“已缓存”两个值;再用PoolMon(Windows驱动工具包自带)或者RAMMap看具体是哪个tag占的。如果是NDIS、CM、FMfn这些tag,基本就是网络驱动和文件系统过滤驱动的问题,优先更新网卡固件、卸载不兼容的杀毒软件,而不是去调数据库参数。
不过这个案例也折射出一个深层次问题:很多人对数据库缓冲池的内部运作机制并不真正了解,遇到内存类问题才会两眼一抹黑。这个系列前面两篇讲了缓存替换策略和淘汰算法,这篇我打算换个角度,专门聊聊我在实际项目里最常用到的几个缓冲池运维场景:如何判断InnoDB缓冲池是否需要调大、怎么预热、脏页刷盘节奏怎么控制、以及那些容易被误诊为“缓冲池问题”的并发和死锁故障。
这一篇比较适合正在做数据库调优的DBA、后端开发,以及那些想弄明白“为什么我配了10G缓冲池,线上还是不快”的人。看的时候建议打开自己的数据库对照着查,比单纯看文字有效得多。
2. 实战第一步:正确评估当前缓冲池状态
2.1 别只看命中率,这个指标会骗人
我见过太多人拿着show global status like 'Innodb_buffer_pool_read_requests'和Innodb_buffer_pool_reads算命中率,算出99.99%就欢天喜地。但是我要泼一盆冷水:缓冲池命中率在高并发读场景下极具迷惑性。
原因在于Innodb_buffer_pool_read_requests统计的是逻辑读次数,也就是InnoDB层发起的页面读取请求,页面可能来自缓冲池,也可能来自磁盘。而Innodb_buffer_pool_reads只统计从磁盘实际读取的页面次数。如果业务是典型的“热点数据高度集中”模式——比如只有几百条商品SKU被疯狂查询——就算缓冲池只有1GB,命中率也能跑到99%以上。但实际上你可能把90%的冷数据都漏在了外面,一旦热点发生偏移(比如大促换了活动商品),性能马上崩。
我更推荐的做法是结合几个维度一起看:
- 每秒磁盘读次数:
Innodb_buffer_pool_reads除以采样间隔,长期高于几十次/秒,说明有大量页面在磁盘和缓存之间来回搬家。 - 每秒逻辑读次数:
Innodb_buffer_pool_read_requests除以采样间隔,这个值反映业务真实读压力。 - 脏页比例:
Innodb_buffer_pool_pages_dirty除以Innodb_buffer_pool_pages_total,超过75%就要小心刷盘跟不上。 - 缓冲池命中率趋势:看至少一周的曲线变化,而不是只看某个时间点的值。
如果命中率高但磁盘IO仍然很忙,通常是以下几种情况在作怪:
- 存在全表扫描,一次性把大量页面拉进缓冲池把热点挤出去;
- 临时表走磁盘,频繁使用文件排序;
- 自适应哈希索引失效,导致频繁索引下探;
- 日志写入(redo log)和双写缓冲占用了IO资源。
2.2 用performance_schema深度定位页面访问
想要真正定位哪些表在频繁读取页面,MySQL 8.0的performance_schema是个好东西。schema_tables这张表可以查到每个表的IO统计,但直接看原生表比较吃力,我习惯把常用查询存成视图,方便日常巡检。
-- 查看每个表的缓冲池逻辑读与物理读情况 SELECT OBJECT_SCHEMA, OBJECT_NAME, SUM(COUNT_READ) AS total_logical_reads, SUM(SUM_NUMBER_OF_BYTES_READ) AS total_bytes_read, SUM(COUNT_FETCH) AS total_page_fetches FROM performance_schema.table_io_waits_summary_by_table WHERE OBJECT_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys') GROUP BY OBJECT_SCHEMA, OBJECT_NAME ORDER BY total_page_fetches DESC LIMIT 20;注意COUNT_READ是逻辑读累计值,COUNT_FETCH是物理读(从磁盘拿页面)累计值。如果某张表COUNT_FETCH和COUNT_READ的比值偏高,意味着这张表的数据经常“不在缓存里”,要么是缓冲池装不下,要么是缓冲池被其他表挤占了。
另一个容易被忽略的信息源是sys库的innodb_buffer_stats_by_schema视图,它会按schema统计缓冲池中页面的分布占比。假设你的业务库占90%,日志库占8%,系统库占2%,那说明缓冲池大小和业务访问模型基本匹配。如果某个归档库、日志库占了40%以上的缓冲池空间,问题大概率出在“缓存污染”——低频数据把高频数据挤出了缓冲池。
针对缓存污染,我常用的两个手段:
- 开启
innodb_buffer_pool_size自动扩展(MySQL 8.0.30+支持动态调整),按业务高峰期动态加内存,低峰期回收。 - 适当调低
innodb_old_blocks_time,这个参数控制数据页进入old sublist后需要多久才能“转正”到young sublist。默认值1000毫秒,如果大量大表扫描导致缓存污染,可以提高到2000毫秒甚至5000毫秒,让全表扫描进来的页面快速被淘汰掉。
2.3 InnoDB缓冲池的内存占用到底怎么算
很多人设置innodb_buffer_pool_size=8G,然后发现MySQL进程实际占用内存超过10G,有点慌。这是正常的,因为InnoDB缓冲池并不是一整块裸内存,它由多个部分组成:
- 数据页(page):默认16KB,这是主体;
- 索引页:同样在缓冲池里,和数据页共用空间;
- 自适应哈希索引(AHI):占用一部分额外内存;
- 锁信息(lock info):每个页面会有对应的锁结构;
- 变更缓冲区(change buffer):占用缓冲池的一部分,默认最多25%;
- 数据字典缓存:表结构、字段元数据;
- 各种控制块(control block):每个页面在缓冲池里有一个对应的控制结构,约数百字节。
所以实际内存占用公式大概是:buffer pool大小 + 5%~10%的额外开销 + 连接线程栈 + 排序缓冲 + 连接缓冲。如果开了performance_schema,还要额外加10%左右的开销。
我见过一个配置不当的案例:服务器物理内存16G,MySQL配置了12G的缓冲池,还开了performance_schema,结果OOM killer直接把mysqld进程杀了。所以我的建议是缓冲池保守设置为物理内存的50%~60%,并预留足够给OS page cache,因为逻辑备份、临时表、文件排序同样会吃page cache。
3. 缓冲池的大小与预热:从“足够”到“精准”
3.1 到底应该设多大,先跑一周再决定
很多优化文章喜欢给一个固定比例,比如“缓冲池设为内存的70%”。我认为这种经验值只能作为起点,真正合理的大小要看你业务数据的热点集有多大。
判断方法是:连续一周采集Innodb_buffer_pool_pages_free和Innodb_buffer_pool_pages_total的比例,也就是空闲页占比。
-- 查看缓冲池空闲页比例 SELECT @@innodb_buffer_pool_size AS buffer_pool_size, (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_free') AS free_pages, (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_total') AS total_pages, ROUND((SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_free') / (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_total') * 100, 2) AS free_ratio;如果free_ratio长期高于20%,说明缓冲池还有冗余,不需要扩容;如果长期低于5%且磁盘IO繁忙,说明缓冲池不够用了。这里要特别注意,空闲页比例低并不绝对等于不够用——有些高并发系统刻意让缓冲池保持高利用率,因为热数据确实很多。你需要结合磁盘读延迟和TPS来看。如果空闲页低但磁盘IO压力不大,可以观察;如果空闲页低同时Innodb_data_reads飙升,那就必须扩容了。
在MySQL 8.0中,可以用SET GLOBAL innodb_buffer_pool_size = xxx动态调整,不需要重启。但要记住,调整时会触发整个buffer pool的resize操作,会阻塞当前请求。官方实际上是把旧的buffer pool实例逐步替换成新的实例,过程会对DML有短暂影响。我一般选择业务低峰期调整,或者直接改配置文件后计划内重启。
3.2 使用innodb_buffer_pool_dump_now快速预热
这是MySQL自带的一个“热启动”能力,原理是记录当前缓冲池中的页面编号和LRU列表位置,在数据库重启后自动加载这些页面。
-- 手动触发dump SET GLOBAL innodb_buffer_pool_dump_now = ON; -- 查看dump进度 SHOW STATUS LIKE 'Innodb_buffer_pool_dump_status'; -- 重启后加载进度 SHOW STATUS LIKE 'Innodb_buffer_pool_load_status';innodb_buffer_pool_dump_at_shutdown和innodb_buffer_pool_load_at_startup默认都是OFF,生产环境我建议开启。但有一个坑必须提醒:不要把innodb_buffer_pool_dump_pct设到100。它控制dump时记录多少比例的页面,默认25%,实际上记录25%的LRU头部页面已经能覆盖绝大多数热数据。如果设100%,dump文件会巨大,加载时间会很长,等于启动变慢。
还有一个经验:大促前如果重启过数据库,可以手工SET GLOBAL innodb_buffer_pool_load_abort = OFF;等待加载完成后再接流量返回,或者在应用层做流量预热——启动后先放少量请求,让热点页面逐步load进内存,而不是一股脑放全量流量进去。
3.3 预热不是万能药,配合LRU策略才是完整方案
预热只能解决“重启后快速恢复缓存”的问题,不能解决“热点数据本身不集中”的结构性问题。
如果你的业务有大量低频大表扫描,这些扫描进来的页面会把真正高频的热数据挤掉。这时候应该考虑设置innodb_old_blocks_time。我推荐一个比较通用的起始值:innodb_old_blocks_time=1000是MySQL默认值,但如果你的场景是“定期跑报表、批量任务和在线业务共存”,可以调到2000~5000,防止报表扫描污染主业务缓存。
如果调整之后依然严重,就要考虑在SQL层面做控制了:
- 大表的报表查询走只读备库,不要和在线交易共用同一个实例;
- 用
SELECT ... INTO OUTFILE代替大量SELECT *回表; - 分批处理大范围查询,避免一次性扫描几百万行。
4. 脏页刷盘与IO抖动:比命中率更影响体验的环节
4.1 脏页比例过高为什么会让性能“抽风”
脏页是缓冲池中被修改但尚未写入磁盘的数据页。InnoDB为了保证持久性,会在适当时候把脏页刷到磁盘,这个过程叫flush。如果脏页太多,或者刷盘过于集中,磁盘IO会突然飙到接近百分之百,导致当时正在执行的查询全部变慢——这就是典型的IO抖动。
Innodb_buffer_pool_pages_dirty状态值可以实时查看脏页数量。还有一个重要指标是Innodb_buffer_pool_wait_free,这个值表示“server因为找不到干净页而等待刷盘”的次数。如果这个值在监控里不断增长,说明刷盘速度跟不上脏页产生速度。
影响因素有这几个:
innodb_max_dirty_pages_pct:默认值是75%(MySQL 8.0中改成了动态调整),表示脏页占比达到这个阈值时,会加大刷盘力度。调低可以让刷盘更频繁、更平均,代价是磁盘IO总开销变大。innodb_io_capacity:表示InnoDB期望的磁盘IOPS上限。如果设太低(比如默认200),脏页刷盘会被限速,脏页越积越多。如果设太高,可能把磁盘IO全吃光。机械盘建议200~400,SATA SSD建议1000~2000,NVMe SSD可以到5000以上。innodb_flush_neighbors:如果设置为ON,刷盘时会顺带把相邻页面一起刷出去,适合机械盘(因为顺序IO比随机IO快得多),但对SSD来说纯属浪费,建议关闭。
我之前处理过一个客户案例:他们的监控看板每10分钟采样一次Innodb_buffer_pool_pages_dirty,发现每天都在凌晨2点左右飙到80%以上,紧接着IO延迟从2ms涨到200ms,持续半个多小时。最后定位到是定时任务在凌晨两点触发了一个超大批量UPDATE,几百万元数据连续修改,产生了大量脏页。
当时做了这三个改动:
innodb_io_capacity从200调到2000(服务器是SSD);innodb_max_dirty_pages_pct从75%调到50%,让刷盘提前开始、分散压力;- 定时任务改成小批量循环,每批5000行,
sleep 0.1秒再继续,避免一次性制造海量脏页。
改动之后,凌晨的IO抖动基本消失,任务总时长增加了17%,但主业务完全不受影响。
4.2 redo log容量:脏页刷不过来的隐形瓶颈
很多人会忽视redo log的大小。实际上,redo log空间决定了数据库能容纳多少未刷盘的数据变更。如果redo log太小,InnoDB还没来得及把脏页刷到磁盘,redo log就被写满了,这时候会强制同步刷脏页,表现为“一卡一卡”的周期性停顿。
MySQL 8.0.30之前,redo log文件数量和大小的控制参数是innodb_log_file_size和innodb_log_files_in_group;8.0.30之后改成了innodb_redo_log_capacity,默认100MB,动态调整。
我建议至少设置1GB~4GB(取决于写入压力)。判断当前redo log是否够用,可以查:
-- 查看redo log总量和已使用量(MySQL 8.0.30+) SELECT innodb_redo_log_capacity AS total_capacity, innodb_redo_log_current_use AS current_use, ROUND(innodb_redo_log_current_use / innodb_redo_log_capacity * 100, 2) AS use_ratio;如果current_use长期高于75%,说明redo log容量偏小,需要扩容。扩容操作在8.0里是动态的:SET GLOBAL innodb_redo_log_capacity = 4G;,不需要重启。
4.3 使用双写缓冲(doublewrite)会降低性能,但别随便关
这个点特别容易踩坑。InnoDB的doublewrite机制是为了解决“部分页面写入”(torn page)问题——如果数据库在写页面到磁盘的途中断电,16KB的页面可能只写了一半,重启后无法恢复。doublewrite会把页面先写入一个连续的doublewrite buffer区域,再写入实际位置,保证极端情况下的可恢复性。
innodb_doublewrite默认ON。很多性能优化文章建议关闭它以节省磁盘IO,但这是极其危险的做法。尤其在云主机、SSD、RAID卡带电池保护的环境下,看起来关闭后性能有5%~10%的提升,但一旦发生断电或者主机crash,数据库可能直接损坏无法启动。
我的经验是:生产环境永远不要关闭doublewrite。如果确实在意性能,可以考虑把doublewrite文件放在更快的存储上(比如独立的NVMe盘),或者购买支持原子写(Atomic Write)的企业级SSD后,才可以考虑关闭。
注意:使用ZFS文件系统时,由于ZFS本身在块级别有校验和机制,关闭doublewrite是相对合理的。但如果你不是存储专家,别玩这个。
5. 连接池与并发锁:那些被误诊为“缓冲池不足”的故障
5.1 MySQL连接池:缓冲池再大也扛不住连接风暴
接下来从缓冲池往外走一步,聊聊另一个高频名词——连接池。在热词里出现“MySQL数据库连接池”,这跟缓冲池是两件事,但故障表现很相似:CPU不高、内存不高,但业务就是慢,查询全部堆积。
根本原因通常是应用层连接池配置不合理。连接池并不是越大越好,每个连接都会占用线程栈(默认2MB)、排序缓冲、临时表内存,同时每个会话的查询都会消耗缓冲池的页面访问配额。如果并发从100涨到500,即使缓冲池足够大,锁等待和上下文切换也会拖垮性能。
这是我常用的连接池配置原则:
- 连接池上限 = 服务器核心数 × 2 + 磁盘IO并发数(有效磁盘IO并发通常很小,大概2~4);
- 初始连接数不要设太高,让连接池按需缓慢增长;
maxWait(获取连接的最大等待时间)不要设成无限,否则线程会在获取连接时全部阻塞,最终拖垮应用服务器;- 空闲连接回收时间要短于数据库
wait_timeout,否则会积累大量无用的半开连接。
举个例子:一台32核的数据库服务器,应用并发需要300个连接,合理配置是核心数×2=64,再加上少量预留,取80~100。如果你配到500,InnoDB内部的row lock waits和meta data locks冲突概率会成倍增加。
5.2 死锁和锁等待:缓冲区越大,锁冲突暴露越快
死锁和锁等待也是热词里的高频话题。有一个容易被忽视的规律:当缓冲池足够大、磁盘IO不再是瓶颈时,锁冲突反而会成为新的瓶颈。因为所有查询都在很快地读取数据并尝试获取锁,锁的竞争频率就变高了。
排查死锁的标准姿势是打开死锁日志:
SHOW ENGINE INNODB STATUS;关注LATEST DETECTED DEADLOCK部分,它会显示正在执行的SQL、持有的锁、等待的锁,以及被回滚的事务。常见的死锁模式是:
- 两个事务以不同顺序更新同一组记录(比如先更新A再更新B,另一个先更新B再更新A);
- 间隙锁与插入意向锁冲突(RR隔离级别下最典型);
- 唯一键冲突导致事务回滚时锁的释放顺序不当。
解决手法通常是这几个:
- 确保所有事务以相同顺序访问资源(先A后B);
- 尽量减小事务体,让持有锁的时间更短;
- 如果只是排查,可以用
innodb_lock_wait_timeout控制等待时长,默认50秒太长了,线上我一般设5秒,宁可让超时报错也不让线程无限挂起; - 定期用
performance_schema.data_lock_waits视图查看锁等待关系,比死锁日志更直观。
另外一定要配好监控告警。我见过凌晨三点被电话叫起来处理死锁,当时数据库里积压了几千个等待锁的会话,所有应用线程卡死,缓冲池命中率跌到60%——并不是缓冲池问题,而是横跨多个业务模块的各个事务互相等锁导致的连锁阻塞。因为每个事务在等待锁时,会占着事务开启时的读视图,导致undo log无法purge,版本链越来越长,查询越跑越慢,恶性循环。
5.3 大事务是缓冲池的“慢性毒药”
大事务的影响很难被直观发现,但它会同时拉低命中率、放大锁竞争、拖慢purge。一个UPDATE影响100万行的超大事务,在做回滚或者提交前,它会一直持有对这些行的锁,其他事务的更新全部排到它后面。
更隐蔽的是,大事务产生的undo log不会在事务提交后立刻被purge,而是要等待所有读视图(ReadView)过期后才清理。如果有个长连接一直不提交查询,那么它持有的读视图会让undo log无限增长,最终把整个undo tablespace撑大,缓冲池还要为这些undo页腾出空间。
判断是否有大事务的方式:
-- 查看当前事务的执行时间(MySQL 8.0) SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds, trx_mysql_thread_id FROM information_schema.innodb_trx WHERE trx_state = 'RUNNING' ORDER BY duration_seconds DESC LIMIT 10;超过60秒的事务就要警惕了,生产环境的大事务我一般要求控制在几秒内完成。如果需要对大批量数据操作,用分批循环替代单条大事务,例如每次UPDATE 1000行,循环执行。
6. 工具链与生态:那些被缓冲池掩盖的日常操作问题
6.1 数据库工具的选择:管理、同步、迁移
热词里出现了一堆工具名,dbx数据库工具、数据库同步软件、excel导入数据库、Navicat连接不了等等。这些看似和缓冲池没关系,但实际运维中,工具选择不当直接影响你能否正确判断缓冲池状态。
我平时用的工具有这么几类:
- 命令行优先:
mysql客户端 +sys库的视图,这是最准确的; - 图形化工具:Navicat、DBeaver、DataGrip都行。Navicat胜在界面顺手,DBeaver开源免费且支持几乎所有数据库类型;
- 性能监控:Prometheus + mysqld_exporter + Grafana是我最推荐的组合,可以持续收集
Innodb_buffer_pool_*系列指标,画出趋势图。
至于“Navicat连接不了数据库”,大多数情况下不是数据库本身的问题,而是这几类原因:
- 用户权限不对,账号只授权了localhost;
- MySQL 8.0默认认证插件是caching_sha2_password,旧版Navicat不支持(11.x以下);
bind_address没配置允许远程连接;- 防火墙(云安全组)没有放行3306端口。
其中第2条特别坑。如果你的MySQL是8.0以上、Navicat是老版本,连接时直接报Authentication plugin 'caching_sha2_password' cannot be loaded。解决办法是改用户的认证插件:
ALTER USER 'your_user'@'%' IDENTIFIED WITH mysql_native_password BY 'your_password';注意,这会有安全性降级,MySQL 8.0官方推荐的是caching_sha2_password。如果Navicat版本够新,直接升级客户端就好。
6.2 数据库同步与数据迁移的缓冲池效应
热词里经常有人搜“数据库同步工具”“数据库同步软件”。做同步时一个常见误区是:认为同步就是把数据导过去,和缓冲池没有关系。实际上同步过程中对源库的SELECT压力非常大,如果同步工具写得很粗糙,不加limit、不走索引,很容易触发全表扫描,进而污染源库的缓冲池,把热数据挤掉。
我踩过一个大坑:用某同步工具从生产MySQL实时同步数据到另一个MySQL做统计,该工具每5秒执行一次全量轮询,单表几百万行,每次扫描都会把源库的缓冲池LRU列表刷一遍,导致真正的在线交易查询全部miss,IO飙高,业务响应时间从10ms涨到500ms。
后续方案是:
- 同步改基于binlog的增量同步(比如canal、Maxwell、Debezium),而不是轮询全表;
- 如果只能用轮询,查询加上
WHERE id > last_max_id,配合索引; - 源库设置
innodb_old_blocks_time=5000,让这类扫描页面进不了young区域。
6.3 其他数据库的缓冲池:顺便看懂PostgreSQL与SQLite
热词里出现PostgreSQL、SQLite。补充一下缓冲池概念在它们那边的对应物,方便你横向理解。
PostgreSQL没有严格意义的“缓冲池”概念,它的Shared Buffers是共享内存里的页缓存,默认只有128MB。相比MySQL,PostgreSQL更依赖操作系统page cache,所以优化手段不太一样。调整shared_buffers到物理内存的15%~25%比较合适,同时要确保effective_cache_size反映的OS缓存容量要够大,否则优化器会错误地认为索引读取很贵而选择错误的执行计划。
SQLite是个极致的单文件数据库,它实际上把整个数据库文件当作一个内存映射来处理。如果你用SQLite做高并发应用,一定会碰上“数据库被锁定”的问题——这不是缓冲池不够,而是它根本不支持高并发写入。SQLite适合做本地缓存、嵌入式存储,不适合做服务端主库。工具层面用DB Browser for SQLite就够用。
6.4 TDengine等时序数据库的写入路径
热词里出现了TDengine、taos_stmt_prepare、C++绑定写入。TDengine的模型和MySQL很不一样,它以“超级表-子表”的方式组织时序数据,写入走的是追加模型,天然对缓存友好。对于TDengine我用C++绑定写入时,特别注意taos_stmt_prepare配合参数绑定,能显著减少SQL解析开销。这与MySQL的prepared statement思路类似,都是为了降低每次写入的解析和规划成本。
如果你也在做TDengine的C++写入,一个重要心得是:尽量使用批量写入(一次写入多行),而不是一条一条插入。批量写入可以在网络层和存储层都获得显著收益。我见过有人用循环单条insert写100万条数据,耗时十几分钟,改成批量2000条之后,几十秒就完成了。
7. 常见问题与排查技巧实录
7.1 问题速查表
我把这些年遇到的高频问题整理成一个表格,方便你对照排查:
| 现象 | 可能原因 | 排查命令 | 解决思路 |
|---|---|---|---|
| 内存持续上涨且不回落 | 缓冲池中脏页刷不出去,或pfs内存开销过大 | SHOW ENGINE INNODB STATUS看脏页与log | 调整io_capacity、清理performance_schema,检查大事务 |
| 磁盘IO周期性飙高 | 脏页集中刷盘,或redo log太小触发同步刷 | SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_wait_free' | 调低max_dirty_pages_pct、调大redo log容量 |
| 重启后前几分钟特别慢 | 缓冲池冷启动,热点页面没加载 | 查Innodb_buffer_pool_load_status | 开启dump_at_shutdown/load_at_startup |
| 命中率很高但查询慢 | 锁等待、临时表、大事务 | performance_schema锁等待视图 | 检查大事务、慢SQL |
| 查询全部卡死 | 连接池耗尽或死锁风暴 | SHOW PROCESSLIST | 调连接池上限、死锁SQL优化 |
| 错误日志出现“out of memory” | 内存分配超额 | 系统日志 | 调innodb_buffer_pool_size,预留OS page cache |
| 单表数据量极大但热点很少 | LRU被冷数据污染 | sys.innodb_buffer_stats_by_table | 调高innodb_old_blocks_time,业务隔离 |
7.2 踩过的三个坑
第一个坑:千万不要盲目相信“经验值”。网上很多人说缓冲池设物理内存的70%,我照着配过一台16G的服务器,结果系统OOM。后来发现服务器上还跑着监控Agent、日志采集、ELK的Beat进程,这些加起来吃掉了3G内存。配置缓冲池前,一定要先了解这台机器上所有常驻进程的内存占用。
第二个坑:innodb_buffer_pool_instances不是越大越好。在MySQL 5.7时代,缓冲池拆分成多个实例可以减少并发访问时的锁竞争,默认是CPU核心数。但拆分的本质是每个实例有独立的LRU链表和mutex,实例过多会导致内存碎片化,并且某些场景下跨实例的并发访问反而增加开销。在MySQL 8.0中,如果缓冲池小于8G,建议保持默认1个实例;大于8G后,再按每实例不低于1G来设置。
第三个坑:使用动态调整缓冲池大小时务必在业务低峰期执行。我曾经在下午三点执行SET GLOBAL innodb_buffer_pool_size=16G,结果所有查询阻塞了将近10秒。虽然官方文档说8.0支持动态调整,但它的实现方式是逐步把旧缓冲池中的页面迁移到新缓冲池,期间有锁和内存拷贝。如果对停机敏感,就别在高峰期玩这个。
7.3 一套我自己在用的巡检SQL
最后分享一套我日常巡检缓冲池的SQL片段。这套SQL会输出一组关键指标,我习惯把它存成Shell脚本,每天早上10点跑一次,输出到日志里。
#!/bin/bash mysql -uroot -p****** -e " SELECT NOW() AS sample_time, @@innodb_buffer_pool_size / 1024 / 1024 AS buffer_pool_mb, (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_pages_total') AS total_pages, (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_pages_free') AS free_pages, (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_pages_dirty') AS dirty_pages, ROUND((SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_pages_free') * 1.0 / (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_pages_total') * 100, 2) AS free_pct, ROUND((SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_pages_dirty') * 1.0 / (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_pages_total') * 100, 2) AS dirty_pct, (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_wait_free') AS wait_free_count, (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_reads') AS physical_reads, (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_read_requests') AS logical_reads; "输出里如果free_pct长期低于10%,dirty_pct长期高于60%,wait_free_count在增长,就要警惕了。
8. 最后分享一点个人体会
写到这里,数据库缓冲池该聊的实操层面内容基本都聊到了。从这么多年处理各种生产事故来看,我觉得最核心的一句话是:缓冲池优化不能脱离业务访问模式去谈。命中率、脏页比例、redo log容量、LRU策略、连接并发,这些指标从来不是孤立存在的。你只有把系统的全貌搞清楚,从SQL到执行计划,再到缓冲池的页面分布,最后到磁盘IO的节奏,才算真正在调优,而不是在盲目调参。
我在实际项目中后期几乎不会再去调大缓冲池了,反而会花更多时间审视业务SQL、索引设计和事务边界。很多时候你以为缺内存,实际缺的是一把好用的索引;你以为要扩缓冲池,实际要砍掉的是那个每天凌晨三点全表扫描的定时任务。
最后再分享一个小技巧:给缓冲池做一个每月一次的压力测试。找一台测试机,灌入和线上差不多大的数据量,然后模拟在线流量和批量任务的混合负载,用dstat和pt-query-digest观察指标,看看在脏页率60%的时候系统是否还能稳定扛住。这种提前演练,比等到线上出问题再抢救要省心得多。数据库调优这件事没有终点,你只能不停地把系统往前推一点,再多推一点。