刚休完假回来,监控告警直接从黄色跳成红色:free -h一看,32G 的机器可用内存不到 1G,swap 已经被吃掉 6G+,SSH 敲命令都开始卡顿。这种“MySQL 占用内存过大”的告警,做运维和后端的应该都不陌生。mysql占用内存过大问题排查,核心不是无脑加内存,也不是遇到高占用就重启数据库,而是搞清楚内存到底被谁吃了、为什么吃、能不能释放、下次怎么预防。下面这篇文章,我会用一次真实的线上排查链路来复盘,从 OS 层快照、performance_schema、连接会话、参数配置四个层面一步步定位元凶,最后给出可落地的调优和监控方案。适合正在被 MySQL 内存问题折磨的开发、运维和全栈 DBA 阅读,尤其是线上内存长期紧张、时不时触顶的团队。
1. MySQL 的内存账本:先理解它通常把内存花在哪
1.1 最大头:InnoDB Buffer Pool 和它的单位坑
几乎所有 MySQL 内存排查的第一步,都绕不开innodb_buffer_pool_size。它是数据库里最大的内存消费者,负责缓存数据页、索引页、undo 页等,目标就是减少磁盘 I/O。一个 32G 的实例如果配了 8G 缓冲池,光这一项就占物理内存四分之一;要是叠加其他内存开销,内存触顶只是时间问题。
很多朋友排查内存时只看两个数:一个是SHOW VARIABLES LIKE 'innodb_buffer_pool_size',另一个是free -h,中间的过程全部是黑盒。这里有个非常常见的坑:innodb_buffer_pool_size返回的单位是字节,如果直接拿来和理解中的“GB”对比,经常会差 1024 倍。比如配置写的是 8589934592,那才是 8G。
另外要明白:Buffer Pool 不是启动时就全量占用的。实例冷启动后,它会随着数据页逐步加载、频繁查询而慢慢爬升,最终稳定在一个高位。所以内存曲线缓慢上升后走平,是正常现象;真正需要警惕的是持续上涨、阶梯式跳变、以及 swap 增长。不要一看到 buffer pool 吃掉了大半内存就觉得异常,你得先判断它有没有超出配置水位。
1.2 隐形大头:会话连接、排序/join 缓冲、临时表和 performance_schema
Buffer Pool 只是明面上的大头,真正的麻烦往往藏在暗处。以 MySQL 5.7/8.0 为例,内存账本至少包含以下几块:
- 连接线程与会话缓冲:每个连接都有独立的 thread stack、sort_buffer_size、join_buffer_size、read_buffer_size、read_rnd_buffer_size、net_buffer_length、binlog_cache_size。单个值可能只有几十 KB 到几 MB,但乘上几百个连接就非常可观。
- 临时表内存:内部临时表在内存中存储时受
tmp_table_size和max_heap_table_size控制。复杂查询一旦涉及排序、去重、GROUP BY,或者 SELECT 出的中间结果集很大,内存临时表可能瞬间膨胀到上 GB。 - performance_schema:MySQL 5.7 开始默认开启,它会为每个等待事件、每个统计维度维护大量内存结构。实例运行越久、事件越多,占用越大,几百 MB 到 1GB 以上并不罕见。
- 表缓存与字典缓存:
table_open_cache和table_definition_cache决定打开表结构和 .frm/.ibd 元数据的缓存数量。库表多、表结构复杂时,这部分的占用会比预想更大。 - 二进制日志缓冲和网络发送缓冲:主从复制出现大事务、应用查询返回超大结果集时,连接发送缓冲和 binlog 缓存也会肉眼可见地增长。
上述每一项单独拎出来都不至于致命,可怕的是同时活跃。我见过一个更夸张的案例:buffer pool 只配了 8G,最终 mysqld 的 RSS 却涨到 22G,原因就是连接数达到 600、临时表频繁创建、performance_schema 长期运行且参数未收敛。
1.3 内存过高背后常见的三种“剧情”
把大量案例归纳起来,MySQL 内存异常通常绕不开下面三条路径:
- 配置先天超标:比如 16G 的机器给 buffer pool 配了 12G,再叠加连接、临时表、performance_schema 开销,内存总和已经超过物理内存,这是迟早要出事的。
- 工作负载短期冲击:连接数激增、慢查询堆积、大事务没有及时提交、排序或 GROUP BY 没有索引支撑,都会让内存出现瞬时飙升。
- 内存“被留下”但没有归还 OS:glibc 和部分内存分配器的策略,会导致线程释放的内存没有还给操作系统,表现为 MySQL 的 RSS 很高但业务并发并不大。这不是传统意义的泄漏,但容易被误判成泄漏。
理解这三条路径,后面的排查才有方向:先排除配置问题,再抓工作负载,最后再讨论内存释放策略。直接看第 2 节的操作链路。
2. 排查链路实操:从告警到锁定元凶的五个关键动作
2.1 先拍现场:OS 层快照和“是否真缺内存”的判断
收到内存告警,第一件事不是去看 MySQL,而是先在 OS 层拍一张现场快照,防止信息丢失。我会执行这一组命令:
free -h top -o %MEM -b -n 1 | head -30 ps aux --sort=-%rss | head -15 cat /proc/meminfo | grep -E 'MemTotal|MemFree|MemAvailable|Committed_AS|HugePages'很多人看到free -h里 used 很高就慌了。这里必须说一个高频误判:used 包含 page cache。page cache 是内核缓存文件内容的内存,可以被系统自动回收,不应该直接算成“业务占用”。真正需要盯的是MemAvailable和 swap 的趋势。如果 available 跌到极低、swap 持续增长,才能说明内存真的不够用。
随后通过ps aux找出 RSS 最高的进程。RSS 是实际物理内存占用,VSZ 虚拟内存参考价值很低。例如下面这行:
mysql 3185 12.5 65.5 4238704 21420100 ? Ssl ... /usr/sbin/mysqld65.5% 是%MEM,说明 mysqld 一个进程吃掉了机器超过一半的内存,这基本上就是排查目标。注意 mysqld 是多线程进程,ps里只能看到一个主进程,内存是所有线程的汇总值,不要理解成单线程内存。
2.2 确认 mysqld 的物理占用:RSS、VSZ、以及被误解的 free
OS 层确认 mysqld 是最大消费者后,紧接着要确认它的 RSS 趋势。ps aux只能看到当前值,最好连续采样几次:
for i in {1..5}; do ps -C mysqld -o rss=,vsz=,pcpu=,pmem=; sleep 2; done如果 RSS 持续上涨,配合时间点就能判断是“业务高峰引发”还是“操作后引发”。例如白天 10 点涨一次、下午 3 点涨一次,多半是流量高峰;如果凌晨突涨,那就是批处理或者定时任务在跑大查询。
这里我要提醒一个容易误解的细节:free 里的 used 高不完全等于 MySQL 高。有时候 mysqld 只用了 12G,但是其他 Java 应用、Redis、监控 agent 以及 page cache 一起把内存吃光了。所以在排查时,我会把/proc/meminfo里的 Committed_AS 和实际进程汇总一起看,避免在错误的怀疑对象上浪费时间。
2.3 用 performance_schema 拆开 MySQL 的内存黑盒
确认 mysqld 之后,下一步就应该进入 MySQL 内部,看内存到底分配在哪一个模块。MySQL 5.7+ 默认开启 performance_schema,执行这个查询可以按事件类型汇总当前内存占用:
SELECT event_name, ROUND(CURRENT_NUMBER_OF_BYTES_USED/1024/1024, 2) AS current_mb, ROUND(HIGH_NUMBER_OF_BYTES_USED/1024/1024, 2) AS high_mb FROM performance_schema.memory_summary_global_by_event_name ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC LIMIT 20;如果当前版本字段名不兼容,也可以使用 sys 库的视图,比如sys.memory_global_by_current_bytes,查询逻辑是一样的。
这个查询直接告诉你当前哪个类别的内存占用最大。常见结果会出现memory/innodb/buf_buf_pool、memory/sql/thd::main_mem_root、memory/innodb/row0sel、memory/performance_schema/*。我解释一下常见的几项:
memory/innodb/buf_buf_pool:InnoDB 缓冲池,和配置强相关,正常应贴近 buffer pool 大小。memory/sql/thd::main_mem_root:会话主内存根节点相关分配,数值越大说明连接会话或单会话内动态分配越多。memory/innodb/row0sel:InnoDB 查询行选择相关内存,典型的大量扫描聚集。memory/performance_schema/*:performance_schema 自身的内存,如果占比畸高,要考虑调低相关上限参数。
如果memory/performance_schema/*占了非常大比例,说明 performance_schema 自己在吞内存,可以考虑调低performance_schema_max_*参数,但不要轻易关掉它——很多等待分析和 SQL 审计还靠它。
2.4 按线程和会话定位:谁在连接、谁在累积
事件类型能看出内存方向,但定位到具体会话还要靠线程维度。用下面这个查询找到占用内存最多的线程:
SELECT ps.thread_id, ps.processlist_id, ps.user, ps.host, ps.db, ps.command, ps.time, ROUND(mb.current_allocated/1024/1024, 2) AS current_mb FROM sys.memory_by_thread_by_current_bytes mb JOIN performance_schema.threads ps ON ps.thread_id = mb.thread_id ORDER BY mb.current_allocated DESC LIMIT 15;实际操作中,我会把这个结果和SHOW FULL PROCESSLIST配合起来看。一个很反直觉的经验是:内存占用最高的线程,不一定是正在执行的查询,而可能是大量空闲连接残留的 sort_buffer、join_buffer 没有释放。因为 MySQL 连接在事务结束后,控制块会保留一部分内存供下次复用。如果 PROCESSLIST 里 time 列全是几百秒甚至几小时的空闲连接,就要怀疑连接池配置是不是太“猛”了。
2.5 状态值与关键参数的对比:理论水位和实际情况差多少
最后,把核心参数都拉出来,用估算公式算一下“理论水位”。我平时会执行:
SHOW VARIABLES WHERE Variable_name IN ( 'innodb_buffer_pool_size','innodb_log_buffer_size', 'max_connections','thread_cache_size', 'table_open_cache','table_definition_cache', 'tmp_table_size','max_heap_table_size', 'sort_buffer_size','join_buffer_size', 'read_buffer_size','read_rnd_buffer_size', 'key_buffer_size' );拿到这些值后,可以粗估基线内存。参考示例:
假设 32G 实例,buffer pool 8G、log buffer 16M、table cache 400 个表 × 平均 200KB、300 个连接 × 平均 3MB、其他元数据 2G,那么粗估约 11G~12G。
然后把粗估值和实际 RSS 对比。如果实际 RSS 长期比估算值高出 2G 以上,多半是临时表、连接缓冲或 performance_schema 在偷偷放大。这一环节的核心思想是:把黑盒模型变成一个可计算的白盒模型,数字对不上,才有继续挖的价值。
3. 一次线上内存异常完整复盘:连接积压和临时表失控的组合拳
3.1 从告警到现场:第一张快照透露的信息
一次真实案例,MySQL 5.7 实例跑在 32G 内存、16 核物理机上,业务以电商后台为主。平时可用内存在 8G 左右,周五早高峰 10 点监控直接告警:可用内存仅剩 800M,swap 摸到 12G,业务方反馈几条报表查询出现超时。
我的第一反应不是去 kill 大查询,而是先拉现场快照。free -h看到 used 25G,ps aux看到 mysqld 的 RSS 是 21G。这里有个关键细节:mysqld RSS 21G 并不是一下子涨上来的,而是最近一小时从 15G 持续爬升到 21G。这能说明两件事:不是冷启动后被 buffer pool 慢慢吃满,而是短时间内有活跃连接或临时表在膨胀。
接着看vmstat 1 5,发现 si/so 一直在持续换页,wa也偏高。这种状态下业务响应已经变慢,但 MySQL 还没有 hang 死,属于典型的“内存余量耗尽、靠 swap 续命”阶段。
3.2 深入 MySQL 内部:performance_schema 与线程排行的线索
在业务侧配合止血(先让报表查询限流)的同时,我执行了第 2 节的 SQL。memory_summary_global_by_event_name的前几行非常有意思:
memory/innodb/buf_buf_pool:8.3G,和 buffer pool 配置一致,正常。memory/sql/thd::main_mem_root:5.1G,这一项高度异常,说明会话相关分配很大。memory/innodb/row0sel:3.4G,说明 InnoDB 层有大量行扫描类操作。memory/performance_schema:1.3G,偏高但不算致命。
接着用sys.memory_by_thread_by_current_bytes查线程排行,发现排第二的线程PROCESSLIST_TIME已经 1200 秒,状态为Sending data。对应到SHOW FULL PROCESSLIST,这条 SQL 是一个四表 JOIN + GROUP BY 的报表查询。再看这个线程的事件明细,memory/sql/Query_expression、memory/sql/temptable、memory/sql/sort_buffer三项加起来超过 2G。
到这里,方向基本清晰了:有一条长时间运行的复杂查询,在内存临时表、排序缓冲和 JOIN 缓冲上消耗了巨额内存。
3.3 根因确认:参数放大 + 连接翻倍 + 一条报表查询
继续查连接状态,Threads_connected显示 480,比平日的 180 翻了两倍多。information_schema.processlist里大量来自同一个应用账号的空闲连接,处于Sleep状态,time 高达几百秒。这些空闲连接本身吃掉了一部分内存,同时也把线程相关的内存统计堆了起来。
但这个案例真正的坑,是tmp_table_size和max_heap_table_size都被设置成了 1G。思考一下就知道问题在哪:从开发角度看,调大临时表上限能避免磁盘临时表,提升 SQL 响应速度;但从内存视角看,这等于给了复杂查询一张“上不封顶”的内存信用卡。
再加上sort_buffer_size设置为 8MB、join_buffer_size设置为 8MB,多个连接同时跑复杂查询时,内存根本兜不住。三个因素叠加起来:报表查询大、参数放大大、连接翻倍大,内存内存自然踩爆。
3.4 修复顺序与效果验证:为什么不能只改一个配置
修复时我没有只改参数,而是按“连接、SQL、参数”三个顺序一起处理:
- 治连接:联系应用方把连接池 maxPoolSize 从 80 压到 30,并开启连接泄漏检测。手动清理空闲超过 600 秒的连接。
- 治 SQL:给四表 JOIN 的报表查询加了复合索引,把 GROUP BY 改到结果集更小的子查询里,执行时间从 6 秒降到 0.3 秒。这一步能直接斩断内存临时表大头。
- 治配置:把
tmp_table_size和max_heap_table_size从 1G 调回 128M,sort_buffer_size调回 2M,join_buffer_size调回 2M,同时把max_connections从 1000 改为 500。
调整后两小时,mysqld 的 RSS 从 21G 逐步回到 13G,swap 没有再增长,可用内存稳定在 7G 以上。这里必须解释一个反直觉的点:把tmp_table_size调小,不代表性能一定下降。结果集超出阈值后会生成磁盘临时表,但配合好的索引,绝大多数 SQL 根本不会触达 128M 上限。磁盘临时表反而帮内存兜了底。这就是“看起来在帮性能,实际在害内存”的典型决策错误。
4. 落地优化与长期预防:参数治理、监控告警和排障习惯
4.1 参数设置的原则:按并发估算,不要让单值膨胀成乘数
内存相关参数没有万能推荐值,但有一条黄金铁律:所有可能被放大的参数,都要按最大并发去估算,而不是按单查询估算。举个例子,sort_buffer_size虽然是会话级参数,如果 100 个并发排序查询同时存在,内存里就会同时出现 100 份排序缓冲。
我的团队后来给线上实例建立了一个简单的“内存预算表”,每改一个参数前都先做估算。对于一个 32G 的实例,下面的参考配置经过多轮压测后相对稳定:
| 参数 | 参考值 | 说明 |
|---|---|---|
| innodb_buffer_pool_size | 8G~12G | 建议为物理内存 50%~70%,但要预留连接等开销 |
| tmp_table_size / max_heap_table_size | 64M~128M | 不要调成 1G,等于给内存埋雷 |
| sort_buffer_size | 2M~4M | 排序频繁优先加索引,而不是加内存 |
| join_buffer_size | 2M~4M | 同样优先优化 JOIN 策略和索引 |
| max_connections | 300~500 | 配合连接池压测确定,不是越大越好 |
| thread_cache_size | 16~32 | 只是避免频繁建线程,过大意义不大 |
| table_open_cache | 2000~4000 | 表很多才需要调大,否则浪费内存 |
| performance_schema | ON | 生产建议保留,但可以限制最大事件数量 |
提示:MySQL 8.0 里 Innodb buffer pool 可以动态调整,但每次调整都会涉及缓冲池重定位。线上操作要在业务低峰期进行,分多次小步调整,避免一次操作引起明显抖动。
4.2 监控指标选择:不要等 swap 归零才告警
很多团队的监控面板只看used百分比,或者只在 swap 大于 0 时发告警。这个思路有问题:服务器默认swappiness为 30,可能内存还剩好几 G 就开始往 swap 里写,等 swap 爆了,业务早就卡死了。
我建议盯三个指标:
- MemAvailable:低于物理内存的 10% 或绝对值低于 2G 时直接告警。
- swap 变化速率:swap-used 持续上涨比短暂升高更危险,说明内存压力在累积。
- Threads_connected 和 Threads_running:连接堆积往往比内存飙升出现得更早,盯住连接趋势能提前干预。
如果手头没有专业的监控系统,也可以用 crontab + 一行脚本实现基础采集:
#!/bin/bash LOG=/var/log/mysql_mem_monitor.log { date '+%F %T' free -m | awk '/Mem:/{print "MemAvailable_M:"$7}' ps -C mysqld -o rss= | awk '{printf "mysqldRSS_M:%d\n", $1/1024}' mysql -uroot -p'password' -N -e " SHOW STATUS LIKE 'Threads_connected'; SHOW STATUS LIKE 'Threads_running';" } >> $LOG脚本虽然简陋,但配合awk做差值,足以在异常出现的第一时间留下数据。
4.3 容易忽视的坑:HugePages、容器限制、多实例叠加
最后再说几个我在实战里踩过、也看别人踩过的坑:
- HugePages 的影响。如果启用透明大页,内存会以 2MB 甚至更大页面分配,
free和ps的统计口径可能让你看到“进程 RSS 正常但系统内存明显不够”的怪象。排查时要把HugePages_Total一起看。 - 容器场景没配
--memory限制。不加限制的话,容器内看到的 free 是宿主视角,MySQL 和宿主机其他进程疯狂抢内存,你根本没法定位是谁的问题。Docker 部署 MySQL 时,一定要同时设置内存 limit 和 swap limit。 - 多实例部署按峰值叠加。两台 MySQL 共享一台 16G 机器,每台的 buffer pool 配 6G,叠加连接、临时表、系统开销后就是 14G+,看起来没超,但一到高峰期就双双 OOM。多个实例的内存预算,必须按“每实例理论高位相加”,而不是按平均值相加。
- 分配器问题。部分 glibc 版本下,线程内存释放后不会立刻归还 OS,RSS 会出现一个“虚高”平台。这种情况可以评估引入 jemalloc,但 jemalloc 不是银弹,如果业务端本身内存使用健康,换完观察到的下降是有限的。
4.4 个人排障习惯:现场快照、数据对比、事后复盘
处理完那次故障后,我给自己定了三个规矩:第一,任何内存告警先拍现场快照,不派人、不改配置之前,先把 free、ps、status 留档;第二,线上任何参数调整都先在测试环境做并发压测,用“内存预算表”验证后再上;第三,每次故障都要形成复盘记录,把现象、根因、修改、验证完整写下来。
这套习惯看起来很朴素,但确实帮我避开了很多“头痛医头”的坑。现在团队遇到疑似 MySQL 内存问题,都会先跑一遍固定的采集脚本,然后再讨论,整个排障时间从过去的半天压缩到半小时左右。这种“先分类、再定位、后治理”的思路,也适用于大部分数据库性能问题。
5. 常见误区与最终心得:为什么调 buffer pool 不万能
5.1 误区一:内存占用大就是 buffer pool 配大了
这是一个非常常见的惯性思维。遇到内存告警,第一反应就是把innodb_buffer_pool_size调小。但从前面的案例可以看到,很多时候内存是被连接数、临时表、排序缓冲和 performance_schema 吃掉的。盲目调小 buffer pool,会导致数据页和索引页命中率下降,磁盘 I/O 反而飙升,业务响应变得更慢。
所以正确做法是:先用 performance_schema 定位谁是最大消耗项,再决定动哪个参数。如果memory/innodb/buf_buf_pool正常但整体 RSS 很高,那你动 buffer pool 毫无意义。
5.2 误区二:重启 MySQL 就能一劳永逸
重启确实能让内存瞬间归零,因为所有连接、缓存、临时表都会释放。但若根本原因是连接池配置过大、SQL 没有索引、参数不合理,重启后几小时内存又会原样涨回来,而且重启本身会造成主从延迟、缓存预热和业务短暂中断。把重启当成“解决方案”,等于把定时炸弹的引爆时间往后挪了一格。
5.3 误区三:RSS 高一定等于业务内存异常
这个前文已经提过。RSS 高可能来自内存分配器的 arena 缓存,也可能来自 performance_schema 的统计结构,甚至可能来自透明大页的统计口径。判断是否“异常”,要有对比基准:和配置估算值对比、和业务高峰趋势对比、和内存分配器类型对比。没有基线的“高”,只是数字,不是结论。
5.4 我的最终心得:先分类、再定位、后治理
经过多次内存排障,我总结出一套判断口诀,已经作为资料传给团队里的新人:
先尽量区分配置型、负载型、分配器型三类问题;再用 OS 快照、performance_schema、会话列表、参数估算四步定位;最后按连接、SQL、参数顺序治理。
如果下次你也遇到同样的告警,我希望你不要第一时间搜“mysql占用内存过大问题排查”然后跟着一些帖子把innodb_buffer_pool_size往死里调小。那只能算拆东墙补西墙,很可能导致磁盘 I/O 上涨、响应变慢,反而引发新的故障。把本文的排查链路完整走一遍,结合自己的监控数据做判断,MySQL 内存问题并没有想象中那么神秘。
我个人在实际操作中的体会是:内存排障七分靠数据,三分靠经验。数据采集不全,再多的经验都只能靠猜;数据到位,哪怕经验少一点,也能一步一步把元凶揪出来。所以如果你看完这篇文章只记住一个动作,那就是:下一次内存告警来临时,先别动任何配置,先把现场快照留全。有了记录,问题就已经解决了一半。