MySQL内存占用过高,这活儿我前前后后接了几十次求助了。很多朋友装完MySQL顺手把配置一填,也不管默认参数适不适合自己机器,跑两天发现内存吃了好几个G,第一反应就是“MySQL是不是有毛病”。其实大部分时候MySQL挺冤的,它只是在认真执行你给它配的缓冲策略。要搞清楚它为什么占这么多内存,先别急着杀进程或者重启,你得先弄明白内存究竟花在哪儿了。这篇文章我就把整个排查思路、关键参数、实操命令和踩坑记录全部摊开讲,适合刚接手MySQL运维的同行,也适合那些被监控系统告警轰炸过的人参考。
1. 先搞清楚MySQL的内存到底花在哪里
1.1 全局缓冲:一上来就吃掉大半内存
MySQL里最占内存的头号大户,必须是innodb_buffer_pool_size。这个参数管的是InnoDB用来缓存数据页和索引页的内存池,它的设计初衷就是“尽量把热点数据留在内存里,少碰磁盘”。你可以把它理解成超市里的货架:进货越多,东西越齐,顾客(查询)拿得越快,但货架本身要占大量仓库空间。
默认情况下,MySQL 8.0安装完后innodb_buffer_pool_size是128M,说实话这数值在现在动辄64G内存的服务器上显得很抠门。但很多云厂商的镜像或者一键脚本会把innodb_buffer_pool_size直接推到物理内存的70%甚至80%,如果你的机器只有8G内存还跑着别的服务,那系统直接卡成PPT是完全可能的。
除了这个主角,还有一堆全局的缓存会占用内存:比如MyISAM引擎的key_buffer_size,虽然MyISAM用得越来越少了,但如果你还在用系统表或者老业务,它一样会按你配的数值吃内存。另外还有table_open_cache和table_definition_cache,这俩控制的是表描述符和数据字典相关对象的缓存数量。表一多,这两个值如果设得太大,光缓存的元数据就是几百M的级别。
还有一类容易被忽略的全局内存是Percona或者MariaDB里比较常见的performance_schema内存池,以及二进制日志相关的binlog_cache和binlog_stmt_cache。这些虽然不是大头,但累积起来你从ps里会看到真正的RES比预期高出一截。
1.2 线程缓冲:连接数一多,内存翻倍
如果说全局缓冲是“固定支出”,那线程缓冲就是“按人头算的弹性成本”。MySQL每建立一个连接,都会给这个连接分配一组私有的缓冲区。最典型的就是sort_buffer_size、join_buffer_size、read_buffer_size、read_rnd_buffer_size和net_buffer_length。
这里有一个特别容易被新手忽视的内存放大效果。假设你把sort_buffer_size设成8M,心想这不就8M嘛,没多少。但是连接数一旦涨到100,光是排序缓冲就占了800M,如果再叠加join_buffer_size的4M、read_buffer_size的256K,粗略算一下200个连接就是接近2.5G。更可怕的是,这些缓冲并不是说你不用它就省下来,MySQL往往是按配置值直接分配的,尤其sort_buffer_size这类在排序操作时才会真正用满,但连接栈本身依然会占用一定的虚拟内存。
所以我每次遇到“MySQL内存占用过大”的问题,第一件事除了看buffer_pool,就是去看max_connections和这几个*_buffer_size的值。很多人一台16G的机器,连接数上限配个1000,线程缓冲也配得很大,那内存真是有多少吃多少。
1.3 那些容易被忽略的“隐性内存”
还有一种情况是你看参数配置都没问题,但内存就是持续偏高。这时候要留个心眼,MySQL不是只有表数据和索引才会吃内存,下面这几类隐性开销很容易让人误判为“内存泄漏”。
首先就是Performance Schema。MySQL 5.7及8.0默认开启performance_schema,它会根据表的数量、连接数、语句数量动态扩展内存。它内部有大量的events_statements_summary_by_digest、memory_summary_by_thread这类统计表,监控项一多,内存就会明显上去。我见过一台MySQL在4G内存的机器上,光是performance_schema就用掉了500多M。
其次是预处理语句(Prepared Statement)和游标。如果业务代码里频繁使用prepare但没有释放,或者连接的max_prepared_stmt_count没限制,服务器端累积的预编译指令对象会一直吃掉内存,时间长了就会造成一个缓慢但持续的涨势。
再一个,某些存储引擎的内存表和临时表也会占内存。MEMORY引擎直接把数据放到临时表里(就是你建表时用的ENGINE=MEMORY),只要数据量控制不住,MySQL进程的RSS就会一路飙升。还有排序和分组操作,如果结果集大于tmp_table_size或者max_heap_table_size,MySQL会先在内存临时表里处理,实在放不下才转磁盘临时表,但在转之前内存已经实实在在用出去了。
2. 排查前的准备:先量化,再动手
2.1 从系统层面确认现状
在改任何参数之前,第一步永远是看系统当前的状态。我习惯先敲这么几条命令,把内存底数和MySQL进程占用量确认清楚:
free -h top -c ps aux --sort=-rss | head -10free -h能让你快速看到物理内存、已用、剩余和buff/cache的情况。这里有一个容易踩的坑:Linux的buff/cache是会被回收的,你看到free显示内存所剩无几,并不代表系统马上要崩,而是说明大量内存被当作文件缓存用掉了。但MySQL进程的RES才是真正主动占用的内存,不能跟buff/cache混淆。
接着用ps aux --sort=-rss把进程按照内存占用排序,找到mysqld进程的PID,再用pmap看看它的内存映射细节:
pmap -x <pid> | sort -k3 -n -r | head -20pmap输出的内容虽然琐碎,但你能看到heap、anon、mmap这些匿名内存映射的空间大小。如果发现某个大块匿名内存已经超过了buffer_pool的配置值,那就要怀疑是不是连接数、临时表或者performance_schema吃掉了。
2.2 用SQL和视图定位内存去向
系统命令只能看到进程整体的内存,真要拆到MySQL内部,还得靠它自己的统计视图。我先给出一套可以直接抄的定位SQL:
-- 查看全局内存相关变量 SHOW VARIABLES LIKE '%buffer%'; SHOW VARIABLES LIKE '%cache%'; -- 查看当前实际生效的连接数 SHOW STATUS LIKE 'Threads_connected'; SHOW STATUS LIKE 'Threads_running'; -- 查看InnoDB Buffer Pool的使用情况 SELECT pool_id, pool_size, free_buffers, database_pages, modified_database_pages FROM performance_schema.innodb_buffer_pool_stats; -- 按事件类型汇总内存消耗 SELECT event_name, current_alloc, current_number_of_bytes_used FROM performance_schema.memory_summary_global_by_event_name ORDER BY current_alloc DESC LIMIT 20;重点看第二个查询里的current_alloc字段。它会按照内存事件的名称把内存消耗列出来,比如memory/innodb/buf_buf_pool、memory/sql/THD、memory/performance_schema/events_statements_summary_long等等。这一下子就能看出来,到底是InnoDB的缓冲池占了大头,还是连接线程相关的内存占了大头。
还有一个超级实用的命令是SHOW ENGINE INNODB STATUS,里面有一段BUFFER POOL AND MEMORY,会直接列出Buffer pool size、Free buffers、Database pages这类关键数据。你可以判断buffer_pool是否已经满到需要频繁做页面淘汰。
再加上这几条,基本就能定位占内存的原因了:
-- 平均栈大小 SHOW STATUS LIKE 'Max_used_connections'; SELECT @@max_connections, @@thread_stack, @@thread_cache_size; -- 临时表使用情况 SHOW STATUS LIKE 'Created_tmp_disk_tables'; SHOW STATUS LIKE 'Created_tmp_tables';比值如果很高,说明SQL需要好好优化,也说明临时内存表在用完前可能出了大问题。
2.3 判断是“缓存策略”还是“异常泄漏”
排查到这一步,我倾向于先把问题定性。也就是说,要区分当前的内存占用是MySQL正常的缓存策略,还是逻辑错误导致的持续增长。
看RSS曲线的走势是最直观的方式。如果MySQL进程的内存在一周内保持平稳,只在业务高峰期波动,那基本属于正常范围,不需要紧张。真正要警惕的是两类曲线:一类是“楼梯型”上涨,比如每次批量任务跑完后内存就跳高一个台阶,且永远降不下来;另一类是“指数型”暴涨,十几分钟内存从3G飙到20G,多半是某个会话或者某条SQL在疯狂申请内存,比如超大的排序、JOIN缓冲或者内存临时表。
这种定性不用多么高深的技术,在Zabbix、Prometheus或者云监控里拉几条时间序列就行。关键是要在动手改参数之前,先确认“病”和“药”对得上。很多人一看到内存高就盲改innodb_buffer_pool_size,结果如果是连接数暴增导致的,你调buffer pool不仅没用,反而会让磁盘IO雪上加霜。
3. 核心参数调整与实操配置
3.1 重中之重:压对innodb_buffer_pool_size
从实际经验看,innodb_buffer_pool_size的调整是内存治理最核心的动作。它占据MySQL总内存的70%-80%是很常见的事,所以它的合理取值直接决定你的“内存预算”够不够用。
我的做法是这样的:先明确这台服务器是专用MySQL还是混布。如果是专用机器,且数据量和热数据量都很大,那可以把innodb_buffer_pool_size设置为物理内存的60%-70%;如果机器上还跑着应用服务、监控Agent或者日志采集组件,那Buffer Pool的比例就要降到50%以下,给系统留出足够的余量。
这个参数的合理设置有个数学直觉:如果你发现Innodb_buffer_pool_read_requests和Innodb_buffer_pool_reads两个状态值之间的缓存命中率长期低于95%,而内存却还有富余,那说明Buffer Pool偏低,可以调高一些。反过来,如果缓存命中率已经很高,再调高意义也不大,反而占内存。
在MySQL 8.0里,这个参数是支持动态调整的:
-- 动态修改为4G SET GLOBAL innodb_buffer_pool_size = 4294967296;但要注意,动态修改只能保证新的内存分配,不会立即释放已存在的页面。因为InnoDB是按照innodb_buffer_pool_instances来分池管理的,调整时要尽量在业务低峰期做,避免内存重新分配时短暂的抖动。
还有个经验是:Buffer Pool设得越大,InnoDB的预读和淘汰策略就越重要。一定要关注innodb_old_blocks_time,这个参数控制的是冷数据在旧子列表中停留的时间。我一般设置为1000毫秒,让全表扫描的大块数据不会轻易把真正的热数据挤出缓存,能减少无效的内存置换。
3.2 连接和线程缓冲:如何在性能和内存之间找平衡
max_connections是另外一个需要重点审查的参数。很多默认配置或者某些历史遗留下来的配置会把max_connections直接设成1000或者2000,但实际上日常连接数可能只有几十。问题在于,每次连接分配的那组线程缓冲都要吃内存,连接上限管得越宽,内存上限就越高。
我先教你一个计算连接线程内存总量的简化公式,这比记住一堆官方文档更实用:
每连接线程缓冲 ≈ sort_buffer_size + join_buffer_size + read_buffer_size + read_rnd_buffer_size + net_buffer_length + thread_stack举个实际例子:默认情况下sort_buffer_size=256K、join_buffer_size=256K、read_buffer_size=128K、read_rnd_buffer_size=256K、thread_stack=256K,保守算一个连接至少占用1M级别的分配。如果max_connections=1000,那理论上的线程缓冲上限就是1GB。当然实际并不会全部同时分配,但连接一爆炸起来,内存和CPU同时飙高是常有的事。
所以我一般建议把max_connections压到实际需要的1.5到2倍。比如业务高峰期连接数在200左右,那就设成400到500,别再高了。同时把sort_buffer_size这类参数控制在合适区间,宁可让排序做慢点,也别让内存崩掉。值越大并不代表SQL跑得越快,因为大排序缓冲意味着每个连接都会占用大量内存。
另外要设好wait_timeout和interactive_timeout,尽量避免空闲连接长时间占用线程资源。别忘了max_used_connections这个状态值,它记录的是历史最高的并发连接数,你可以用它来衡量自己设置的max_connections是否合理。
3.3 排序、临时表与堆外内存的治理
连接数控制住了,接下来处理临时表和排序相关的内存。这一块特别容易出现“看似什么都没配,内存却莫名其妙涨”的情况。
tmp_table_size和max_heap_table_size这两个参数,控制的是内存临时表的上限。默认值一个16M一个16M,但有些环境会被调得很大,比如256M甚至512M。问题来了:一条大SQL做GROUP BY或者ORDER BY时,如果结果集超过这个上限,MySQL会先把结果放到内存临时表,超过后再转磁盘。转磁盘的过程虽然不需要持续占内存,但内存临时表本身是一次性分配的。如果并发执行的多条SQL同时触发内存临时表,最后占用的内存就是吞吐量乘以单条临时表大小。
我见过一个真实案例,一条查询的执行计划生成一个约80M的内存临时表,正常情况下没问题,但是业务方同时开了几个窗口跑同类查询,瞬间内存就冲高了4-5G。解决思路不复杂:要么SQL层面优化掉临时表,如果暂时改不了SQL,就把tmp_table_size调到合理值,避免单条SQL吃掉太多内存。
顺带一提binlog_cache_size,它管的是事务在提交前暂存二进制日志变更的内存缓冲。如果业务有大量大事务,binlog_cache不够用就会创建磁盘临时文件,但那也不会明显减少内存占用。所以通常情况下保持默认16M/32M就够了,不要为了优化写入去调它,内存收益很低。
最后再提一个有点偏门但杀伤力很大的点:performance_schema。如果不需要细化到语句级的内存统计,可以把performance_schema里用不到的instrument和consumer关掉。具体操作是这样:
-- 关闭不必要的内存统计 UPDATE performance_schema.setup_consumers SET ENABLED = 'NO' WHERE NAME LIKE '%memory%';关闭之后,performance_schema的内存开销会明显下降。但注意,如果你正在排查内存问题,就别急着关,毕竟要靠它来定位。
3.4 动态调整与重启生效的选择
在MySQL 5.7和8.0中,大部分Buffer Pool相关的参数都能动态调整,但也有部分参数需要写进配置文件后重启才生效。我建议把修改分成两步走:先在线调整动态参数,应用一段时间观察状态,确认稳定后再把配置固化到my.cnf里,免得下次重启恢复原状。
需要写在配置文件里的典型参数包括innodb_buffer_pool_size、max_connections、performance_schema、table_open_cache。在线改的时候,也要注意SET GLOBAL只对新连接生效,已经存在的连接通常不会重新分配线程缓冲。所以改完连接相关参数之后,最好还是让连接平滑重建,比如在低峰期重启一下服务,或者让应用端重新建立连接池。
另外一个很容易被遗漏的操作:如果你调整了Buffer Pool大小,建议同时设置innodb_buffer_pool_dump_at_shutdown=ON和innodb_buffer_pool_load_at_startup=ON。这两个开关的作用是在关停时把Buffer Pool中的页面映射信息保存下来,启动时再快速预热。这样调整完配置重启MySQL,也不会经历一段“冷缓存”期导致性能骤降。
4. 常见问题与排查技巧实录
4.1 问题速查表
我把平时遇到过的MySQL内存问题整理成一张速查表,你在现场排查时可以直接对照找方向。
| 症状 | 可能原因 | 解决方向 |
|---|---|---|
| 内存持续缓慢上涨,重启才回落 | 预处理语句未释放、performance_schema统计累计、内存碎片 | 限制prepare数量、关闭不需要的内存consumer、定期整理碎片 |
| 内存突发暴涨,伴随CPU升高 | 大排序、大临时表、连接数瞬间增多 | 优化SQL、降低sort/join buffer、限制连接数 |
| 内存长期高占用,业务压力不大 | innodb_buffer_pool_size设置过大 | 按业务实际和命中率下调 |
| MySQL启动后内存就不小 | 全局缓冲整体配置过高 | 重新评估buffer_pool、key_buffer、table_cache |
| 空闲之后内存也不下降 | 连接被sleep线程占住、线程缓存过大 | 调整wait_timeout、interactive_timeout |
| 内存涨到接近上限,但不kill | 系统内存分配与MySQL内部参数叠加 | 检查max_connections与per-thread buff的乘积 |
| 物理内存有大量page cache | 正常缓冲,可回收 | 不用紧张 |
这张表不能覆盖所有场景,但至少能帮你把排查方向从“盲猜”变成“对照”。
4.2 实战案例一:内存持续增长,像极了“泄漏”
有一个印象很深的客户,MySQL版本5.7,内存从3G起步,两周时间涨到了7.8G,重启后又能回落。刚开始大家都以为是泄漏,但重新排查时我做了两件事。
第一件事,用performance_schema.memory_summary_global_by_event_name查看内存分配来源,发现memory/sql/Prepared_stmt_map和memory/sql/THD::main_mem_root占据的比例很高。这说明预处理语句没有正常释放。进一步查看performance_schema.prepared_statements_instances,果然找到一堆已经执行完毕但还挂在连接上的SQL语句。
第二件事,检查线上连接池配置,发现连接池里设置了prepStmtCacheSize和cachePrepStmts=true,但应用端没有正确检测MySQL连接失效机制,导致连接在服务端留下了大量未释放的预处理语句。解决方案是把服务端的max_prepared_stmt_count合理限制一下,同时推动应用侧增加连接有效性检查和变更限制。
这个案例告诉我:内存持续上涨不一定是“泄漏”,很可能是某个生命周期很长的对象在不断累积。MySQL的Prepared_stmt_map和events_statements_summary_long类结构不会在语句结束后清理干净,需要主动关注。
4.3 实战案例二:突发内存暴涨,撑爆服务器
另一个案例更加惊险。某个周六晚上10点,监控直接报警,MySQL服务器可用内存只剩下不到200M,同时负载飙到了20以上。我在控制台先查了一下Threads_running,发现瞬间近百个线程在跑。再看慢查询日志,发现同一时间有大量针对一张大表的ORDER BY和GROUP BY,每条SQL都要排序2亿行左右的数据。
那会儿sort_buffer_size被设成了4M,听着不算大,但上百个并发同时排序,光排序缓冲就冲到400M左右。再加上InnoDB的Buffer Pool和大临时表转换,总内存直接爆了。
当时我做了三步:第一,临时调低max_connections,同时把sort_buffer_size降到1M,先止住雪崩;第二,kill掉一部分长时间运行的排序查询;第三,优化掉那种全表排序的SQL,用索引来消除filesort。最终内存稳定在60%以下。这个案例里,参数本身不是罪魁祸首,SQL本身的设计才是,参数只是放大了问题。
4.4 避坑清单与行业习惯
最后分享几条基于长期踩坑的经验。
第一,不要一上来就动innodb_buffer_pool_size。我见过太多人看到内存光鲜就把Buffer Pool调高或者调低,结果要么性能下降要么问题依旧。判断依据必须是缓存命中率、磁盘IO、实际内存余量这几个指标。
第二,排查内存问题前先拍照记录。所有参数修改之前,用SHOW VARIABLES和SHOW GLOBAL STATUS把当前状态存下来,再结合监控记录做对比。改参数不记录,等于把自己丢进黑箱里,谁也帮不了你。
第三,重视监控的粒度。别只盯着MySQL进程的总内存,建议把Threads_connected、Created_tmp_disk_tables、Innodb_buffer_pool_reads、Prepared_stmt_count这些指标都配上告警。很多内存问题从来不是突然出现的,它早就在指标里露出了苗头。
第四,遇到Windows机器,别把问题都甩给业务。MySQL在Windows上的内存表现和Linux下会有些差异,但排查路径是一致的。顺便说一句,我见过有人在Windows装了MySQL后顺手把innodb_buffer_pool_size调到4G,可机器总共才8G内存,系统一开机就卡。这种情况下调整配置比优化SQL更紧迫。
在我实际排查这类问题的时候,最深的体会是:MySQL没有“无缘无故的大内存”。每一个看起来过高的内存占用,背后都有一个参数、一个连接、或者一条SQL在支撑。如果你愿意花半小时把一个进程的内存来源拆解清楚,再去动参数,基本就不会再做无用功了。最后再提醒一句,改完参数记得观察一整轮业务周期,确认稳定后再固化到配置里。内存治理是一个持续校准的过程,一次就能调到完美的情况很少见。