上周接了一台 MySQL 8.0 服务器的内存告警,free -h一看 used 已经冲到 91%,mysqld 一个进程的 RSS 就占了 4.7GB,机器是 8G 内存的小规格,OOM Killer 随时可能动手。查 MySQL 占用内存过大这类问题,我处理过不止一二十次,后台也经常有人贴同样的求助截图——大多数人第一反应是调小innodb_buffer_pool_size,但很多时候真正的问题根本不在 buffer pool。这篇把我实际排查的一套流程完整梳理出来,从系统层取证到 MySQL 内部定位,再到改配置和验证,适合被 OOM、内存告警困扰的 DBA、运维和后端开发照着走一遍。
先说个反直觉的结论:MySQL 吃掉大半内存,在很多情况下是正常设计,不是故障。真正要排查的是“超出合理基线的部分”从哪里来。
1. 先把“内存过大”这件事定义清楚:MySQL 正常就该吃这么多
很多人看到 mysqld 占了物理内存的 60% 以上就慌了,其实 InnoDB 的设计目标就是把热数据尽量留在内存里,innodb_buffer_pool_size你给多大它就能用多大,这是性能保障,不是内存泄漏。所以排查内存问题前,先分清楚你现在看到的“内存占用高”属于哪一类。
1.1 从free输出判断真实压力
Linux 下别只看 used 那一列,重点看 available。看一个典型输出:
$ free -h total used free shared buff/cache available Mem: 7.6G 6.9G 142M 4.0M 583M 440M这里used 6.9G包含了 MySQL 的 buffer pool 预分配,也包含了 page cache。available 440M才是真正能给新进程用的内存。如果 available 长期低于总内存的 10%,或者开始频繁使用 swap,那才是真的紧张。
注意一个常见误判:mysqld 刚启动时 RSS 可能只有 1G,跑几天后涨到 4G,这不代表泄漏。buffer pool 是启动后慢慢预热、慢慢填满的,属于正常过程。真正要盯的是两个值:
- 总内存里
available是否持续走低 dmesg | grep -i oom有没有 OOM Kill 记录
只要这两个没问题,MySQL 吃再多内存你也别管它。
1.2 MySQL 内存到底分成哪几块
我在排查的时候喜欢把 MySQL 内存拆成两大块看:
- 全局内存:所有线程共享。主要就是
innodb_buffer_pool_size、innodb_log_buffer_size、key_buffer_size(MyISAM 用,8.0 下基本是摆设)、表结构缓存、性能库占用的内存。 - 会话内存:每个连接独享。排序缓冲
sort_buffer_size、连接缓冲join_buffer_size、顺序读缓冲read_buffer_size、随机读缓冲read_rnd_buffer_size、网络缓冲net_buffer_length等。
全局内存是“定点定量”的,配置多少就占多少,比较好算。会话内存是“按需分配”的,连接建立时不会一次全部分配,只有真正执行到排序、join、顺序扫描时才会临时向内存要。这两个特性决定了排查思路完全不同:全局内存超了,去查配置;会话内存爆了,去查连接数和 SQL。
1.3 先估算这台机器的合理基线
拿 8G 内存、只跑 MySQL 单实例的场景举例。我给的一个粗略基线:
- 操作系统保留:1G 左右
- InnoDB buffer pool:3G~4G(占总内存 40%~50% 相对稳妥)
- 其它全局缓存 + 线程内存 + 各种临时开销:500M~1G
- 峰值预期的会话缓冲:500M~1G
所以一台 8G 专机,MySQL 的 RSS 在 4.5G~5.5G 之间都算正常。如果超过了这个范围,或者 available 已经见底,才需要进入下一步排查。我这次遇到的情况是 4.7G RSS,从数字看似乎还好,但 8G 的机器上总内存已经接近打满,明显还有可压缩的空间,于是开始一步步往里查。
2. 现场取证三步走:系统层、配置层、内部层各看什么
排查内存问题最忌讳上来就改参数。我习惯按“系统层 → 配置层 → 内部层”的顺序取证,每一步都有明确目标,避免瞎猜。
2.1 系统层:先确认 mysqld 的真实占用
用几条命令把底摸清:
# 确认 mysqld 的 PID 和 RSS $ pidof mysqld 23456 # 查看这个进程的内存占用 $ ps -o pid,rss,vsz,cmd -p 23456 PID RSS VSZ CMD 23456 4812456 29237824 mysqld # 或者按内存从高到低看全部进程 $ ps aux --sort=-rss | head -20RSS 是常驻物理内存,VSZ 是虚拟内存,MySQL 的 VSZ 动辄 20G+ 很正常,不要被它吓到。真正跟 OOM 相关的是 RSS。
也可以实时观察内存变化:
$ pidstat -r -p 23456 1 10如果 RSS 在持续上涨,涨的方向比涨的值更重要。比如涨到一定水位就稳定,那是缓存预热;如果沿着一条陡峭的线无限向上,那大概率是会话内存泄漏或者 SQL 异常。
2.2 配置层:把关键内存参数一次拉全
逐个查变量太慢,我用一段 SQL 把所有内存相关的配置一次拿出来:
SHOW VARIABLES WHERE VARIABLE_NAME IN ( 'innodb_buffer_pool_size', 'innodb_log_buffer_size', 'key_buffer_size', 'max_connections', 'sort_buffer_size', 'join_buffer_size', 'read_buffer_size', 'read_rnd_buffer_size', 'net_buffer_length', 'thread_stack', 'thread_cache_size', 'table_open_cache', 'table_definition_cache', 'performance_schema', 'performance_schema_max_memory_classes', 'tmp_table_size', 'max_heap_table_size', 'internal_tmp_mem_storage_engine', 'temptable_max_ram' );这一步是在建立“理论内存上限”的概念。每个会话缓冲都能算出一个最坏情况,比如max_connections是 800,sort_buffer_size是 2M,理论上最坏全部连接同时排序就能吃掉 1.6G 只用于排序——虽然是极端情况,但它决定了内存天花板在哪里。
2.3 内部层:用 performance_schema 定位内存大头
配置层只能看到“可能用多少”,看不到“实际用了多少”。排查大头必须看 performance_schema 的内存统计。两张表核心,先看全局汇总:
SELECT EVENT_NAME, CURRENT_NUMBER_OF_BYTES_USED FROM performance_schema.memory_summary_global_by_event_name ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC LIMIT 20;更省事的是直接查 sys 库的封装视图:
SELECT * FROM sys.memory_global_by_current_bytes ORDER BY current_alloc DESC LIMIT 20;这个视图会按“内存分类”帮你聚合,输出里memory/innodb_buf_buf_pool就是 InnoDB buffer pool 的实时占用,memory/performance_schema是性能库自己吃掉的内存,memory/sql/...下面各个条目对应 SQL 层的缓存。拿到这张表,内存大头基本就能锁定了。
3. 排查实录一:8.0 默认开启的 performance_schema 吃掉了 1.2G
我这次排查的机器只跑了一个 MySQL 8.0.33,连接数峰值也就 100 出头,业务不重,但 RSS 一直压在 4.7G 下不来。配置层看了一圈:innodb_buffer_pool_size配置的是 3G,理论上 3G + 系统其它开销应该在 4G 左右才合理,多出来的接近 1G 让我起了疑心。
3.1 定位过程:sys 视图里的大头一目了然
执行SELECT * FROM sys.memory_global_by_current_bytes ORDER BY current_alloc DESC LIMIT 20;之后,前几行的输出大概是这种感觉:
| 事件名称 | 当前分配 |
|---|---|
| memory/innodb_buf_buf_pool | 3.0 GiB |
| memory/performance_schema/mutex_alloc | 412 MiB |
| memory/performance_schema/events_statements_history_long | 390 MiB |
| memory/performance_schema/events_statements_summary_by_digest | 231 MiB |
| memory/mysys/IO_CACHE | 86 MiB |
performance_schema相关的几项加起来超过了 1.2G。这个很典型——MySQL 8.0 的performance_schema默认就是打开的,而且默认会开启大量 instrumentation,包括针对语句的完整历史记录(events_statements_history_long),这部分会在内存里保留每条 SQL 的完整执行信息。
为什么 8.0 这么容易踩这个坑?5.7 时代performance_schema虽然也默认开,但默认的消费项相对克制。8.0 为了让新用户开箱即用就能看全诊断信息,默认设置的采集维度更全、保存行数更多,代价就是内存吃得更猛。很多云厂商的 RDS 默认关掉一部分 instrument,正是为了省这点内存,但自建实例可没人帮你调。
3.2 处理手段:关掉不需要的保存逻辑
这里要区分两个层次:instrument(采集什么事件)和 consumer(采集后是否保存)。
临时验证可以先全局关闭所有采集,看内存能不能降下来:
UPDATE performance_schema.setup_instruments SET ENABLED = 'NO', TIMED = 'NO';不过这种“一刀切”会影响你后续排查问题的能力,我不建议长期用。更推荐按需保留:
-- 关闭 statement 的完整历史记录,只保留当前语句 CALL sys.ps_setup_disable_consumer('events_statements_history_long'); -- 缩小历史记录的保留行数,降低内存上限 SET GLOBAL performance_schema_events_statements_history_long_size = 1000;改完后观察 mysqld 的 RSS:
$ ps -o rss -p 23456我的实测结果是:从 4.7G 降到 3.5G 左右,掉了超过 1G。对被内存告警逼疯的人来说,这 1G 是很可观的。如果你不需要数据库层面的深入性能诊断,甚至可以直接在my.cnf里加一行performance_schema=OFF,最省心。但代价是以后排查慢 SQL、锁等待的时候少了一双眼睛,取舍要自己权衡。
提醒一下:
performance_schema的参数有些是只读的,改完必须重启 MySQL 才生效。改之前先看SHOW VARIABLES LIKE 'performance_schema%';里的变量是不是动态可调。
4. 排查实录二:100 个“看似正常”的连接,如何偷偷吃光内存
降完 performance_schema,我又复现了一次高峰期的内存走势,发现一个更微妙的问题:RSS 会在某个时刻突然再往上跳 600M~800M,过一会儿又回落。这种“尖峰型”的内存增长,和 buffer pool 那种“填满后稳定”完全不一样,指向的是另一个元凶——会话级缓冲。
4.1 per-thread buffer 的分配时机
MySQL 对每个连接会分配一组私有的内存缓冲,主要包括:
| 缓冲名称 | 默认值(8.0) | 主要用途 | 分配时机 |
|---|---|---|---|
| sort_buffer_size | 256K | 排序操作 | 执行 ORDER BY / GROUP BY 时 |
| join_buffer_size | 256K | 无索引 join 缓冲 | 执行 JOIN 且没有可用索引时 |
| read_buffer_size | 1M | 顺序扫描表 | 全表扫描时 |
| read_rnd_buffer_size | 256K | 排序后的随机读取 | 使用 filesort 时 |
| net_buffer_length | 16K | 客户端连接收发缓冲 | 连接建立时(初始) |
| thread_stack | 256K | 线程栈 | 线程创建时 |
关键点在最后两列:这些缓冲大多数不是连接建立时就真金白银地吃内存,而是等到执行到对应操作时才从内存里分配。所以连接数高本身不可怕,可怕的是大量连接同时在做排序、join、全表扫描。
4.2 一个案例:连接池风暴如何制造内存尖峰
我排查的这台机器配置里sort_buffer_size=2M、join_buffer_size=2M、read_buffer_size=1M、read_rnd_buffer_size=2M,都不算夸张,但乘上并发数就很可观。
高峰期我用下面的 SQL 查过 Threads:
SHOW GLOBAL STATUS LIKE 'Threads_connected'; SHOW GLOBAL STATUS LIKE 'Max_used_connections';Threads_connected一度到了 280,而业务侧有一个定时任务会同时跑一批报表 SQL,这批 SQL 大量使用多表 JOIN 和 ORDER BY。假设其中有 100 个线程同时触发排序,那么:
- sort_buffer 部分:100 × 2M = 200M
- join_buffer 部分:100 × 2M = 200M
- read_buffer + read_rnd_buffer:100 × 3M = 300M
- 再加上 net_buffer、临时表开销、线程栈等零碎
一次并发高峰轻松多出 700M~1G 的内存占用。这就是我观察到的那个 600M~800M 内存尖峰的来源。它不会像泄漏那样一直涨,但高峰期一旦和系统其它进程抢内存,很容易把 available 打到个位数。
4.3 用 performance_schema 验证会话内存
你可以按线程看内存占用,把大户捞出来:
SELECT THREAD_ID, EVENT_NAME, CURRENT_NUMBER_OF_BYTES_USED FROM performance_schema.memory_summary_by_thread_by_event_name ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC LIMIT 20;再关联performance_schema.threads表看看这个线程对应哪条连接、在跑什么 SQL。如果发现大量线程的memory/sql/sort_buffer、memory/sql/join_buffer都很高,那答案基本就坐实了。
4.4 怎么压内存,又不伤正常查询
这里有个取舍问题。有人一看到内存高就把sort_buffer_size从 2M 降到 256K,这其实有风险。如果业务里确实有需要排序的大查询,buffer 太小会导致 MySQL 频繁把中间结果刷到磁盘临时文件,排序变慢,IO 飙升。最后的结局是内存省下来了,SQL 慢到用户投诉。
我的建议是分三步走:
- 先用连接池把应用侧并发压住。大多数“连接数暴涨”都不是业务真的需要这么多并发,而是连接池配置太奔放,或者存在连接泄漏。把
max_connections从 800 调到 300,同时让应用侧连接池最大连接数和它对齐,效果立竿见影。 - 缩小单连接上限,而不是直接砍半。比如
sort_buffer_size2M 降到 1M,影响通常可控。 - 给不产生明显收益的缓冲设置更小值。
read_buffer_size用于全表扫描,如果业务 SQL 基本走索引,它大多数时候用不上,从 1M 降到 512K 问题不大。
注意:MySQL 8.0 的这些 per-thread buffer 是按需分配、用完即释放的。你看到连接数高不用担心,但要盯住“同时执行排序/join 的线程数”。这比单纯看连接数更有价值。
5. 大排序 / 大 JOIN / 临时表:SQL 层面的内存尖峰容易误判
排查完连接和 performance_schema,我还遇到过一个更难定位的场景:内存占用总体不高,但会周期性出现非常尖锐的暴涨,持续时间几秒到几十秒不等。最后查下来,既不是配置问题,也不是连接风暴,而是一条 SQL 的临时表把内存顶上去的。
5.1 内存临时表是怎么产生的
MySQL 在执行GROUP BY、DISTINCT、ORDER BY、子查询、部分UNION时,可能需要在内存里建立临时表来存放中间结果。这个临时表的上限受两个参数共同限制:
tmp_table_size:默认约 16M~64M,取决于版本和发行版max_heap_table_size:默认 16M~64M
实际生效的上限是两者取较小值。超过上限之后,内存临时表会转到磁盘临时表(8.0 默认引擎是 InnoDB on-disk temporary table)。
这里还有一个 8.0 特有的参数容易被忽略:internal_tmp_mem_storage_engine。8.0 默认是TempTable,对应的内存上限不是单纯由tmp_table_size控制,还受temptable_max_ram限制。这个值的默认是 1G,也就是说 8.0 的 TempTable 引擎最多能用约 1G 内存来缓存临时表数据,超过部分会转入内存映射文件或磁盘临时表。
5.2 一条 GROUP BY 把内存干上去的现场
我之前处理过一个案子:某张日志表有 3000 万行,业务侧跑了一个定时统计:
SELECT user_id, COUNT(*) FROM order_log WHERE create_time BETWEEN '2024-01-01 00:00:00' AND '2024-01-31 23:59:59' GROUP BY user_id;这个 SQL 要扫描的数据量非常大,分组结果可能有几十万行。执行时中间结果会先放进内存临时表,如果统计的维度再复杂一点,内存占用直接奔着几百 M 去。如果同一时间这类 SQL 有好几条并发,内存尖峰立刻出现。
排查的时候可以看状态变量:
SHOW GLOBAL STATUS LIKE 'Created_tmp%';输出示例:
| 变量名 | 值 |
|---|---|
| Created_tmp_tables | 145231 |
| Created_tmp_disk_tables | 2034 |
Created_tmp_disk_tables / Created_tmp_tables如果比例太高,说明很多临时表落盘了,是性能问题;这个比例如果接近 0%,则说明临时表都在内存里完成,内存压力就可能从这里来。
5.3 SQL 侧和配置侧的协同优化
我的处理经验是:先优化 SQL,再考虑调参数。同一个报表需求,如果加上合理的索引让GROUP BY走覆盖索引,临时表的数据量会小一个数量级。比如上面的例子,给create_time建索引、把user_id加入索引做覆盖,MySQL 扫描的行数会显著降低,临时表内存自然就下来了。
如果 SQL 已经没法改,再考虑配置侧:
tmp_table_size = 32M max_heap_table_size = 32M # 8.0 专用:控制 TempTable 引擎最大内存 temptable_max_ram = 1G把tmp_table_size调大能减少落盘、提升性能,但代价是单个大查询可能吃更多内存;调小则反之。这里没有绝对正确的值,取决于你的业务是 OLTP 还是偏 OLAP。我的偏好是:在 OLTP 系统里保持 16M~32M,让超限的大查询尽早走磁盘临时表,宁可慢一点,别让一个查询把实例内存打爆。
6. 重建内存基线:一套可直接抄的调优方案
排查到这里,这台 8G 机器的问题基本定位完了:performance_schema 贡献 1.2G、会话缓冲在高峰期贡献 600M~1G、临时表偶尔来一个尖峰。接下来是重新建立内存基线,把配置落到一个“够用且稳”的状态。
6.1 参考配置块
针对一台 8G 物理内存、跑 MySQL 8.0 单实例、连接数峰值 200 以内的业务场景,我给出的一个可直接参考的配置:
[mysqld] # InnoDB 核心 innodb_buffer_pool_size = 3G innodb_log_buffer_size = 16M innodb_buffer_pool_instances = 4 # 连接与线程 max_connections = 300 thread_cache_size = 64 sort_buffer_size = 1M join_buffer_size = 1M read_buffer_size = 512K read_rnd_buffer_size = 1M # 表缓存 table_open_cache = 1024 table_definition_cache = 1024 # 临时表 tmp_table_size = 32M max_heap_table_size = 32M internal_tmp_mem_storage_engine = TempTable temptable_max_ram = 1G # 性能库:按需保留,避免默认全量采集 performance_schema = ON performance_schema_events_statements_history_long_size = 1000这套配置跑下来,RSS 稳定在 3.5G~4G 之间,available 长期保持在 1.5G 以上,OOM 警报彻底解除。
6.2 改完如何验证不反弹
改配置不是“改完重启就完事”,至少要观察一个业务周期。我习惯盯三个指标:
# 1. 内存总体情况 watch -n 5 'free -h' # 2. mysqld 本身 ps -o rss -p $(pidof mysqld) # 每分钟记录一次,看是否稳定/持续上涨 # 3. 连接数和临时表 mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_connected'; SHOW GLOBAL STATUS LIKE 'Created_tmp%';"如果 RSS 稳定在一个水位线,不再往上爬,说明基线定住了。如果还是在缓缓上涨,那一定还有没查到的部分,回去继续看sys.memory_global_by_current_bytes。
6.3 几个容易被忽略的坑
改完参数忘记看有没有!includedir。my.cnf或者my.ini里经常有一行!includedir /etc/my.cnf.d/,各发行版会把额外的配置拆到单独文件里。如果你只在主配置里改,拆分布置文件里还有一份旧配置覆盖了你,导致修改不生效,排查半天还以为自己记错了。
tuning-primer.sh 和 mysqltuner 给的建议要带着判断看。这两个脚本能帮你快速列出配置风险,但它们的建议是基于“最大化性能”写的,比如动辄建议把innodb_buffer_pool_size调到总内存的 70% 以上。如果你的机器还要跑监控 agent、日常备份任务、偶尔有人上去查数据,全给 MySQL 会适得其反。
不要在高峰期直接改全局动态参数。比如SET GLOBAL sort_buffer_size = XXX这类操作,影响的是之后新建的连接,老连接还在用旧值。而且sort_buffer_size调小后,正在执行的大排序可能瞬间出现大量磁盘 filesort,性能抖动比内存告警还难受。改配置要挑业务低峰期,改完观察一个周期再决定下一步。
根据我个人处理这类告警的经验,MySQL 内存问题的排查框架永远是“先算基线、再判类型、后动配置”。90% 的内存告警要么是 baseline 定错了,要么被 performance_schema 和会话缓冲这种“看不见的内存”带偏。把本文这套命令在出问题的机器上完整跑一遍,你大概率也能像我一样,在不牺牲核心性能的前提下把内存压回合理区间。