MySQL内存排查与调优:从RSS分析到参数优化实战
1. 排查前的第一课搞懂MySQL内存都去哪了先说一个我遇到的真实场景。某个订单中台系统的MySQL实例配置是32G内存平时内存占用稳定在70%左右。突然有一天晚上监控告警显示内存使用率连续半小时超过95%swap分区开始被吃掉应用侧陆续报出数据库连接超时。我登录机器一看free -h里used内存已经飙到28GMySQL单个进程的RES占了24G。按照很多人的第一反应这时候就该去调innodb_buffer_pool_size了。但那次排查到最后发现buffer pool根本没有变化问题出在一个绝大多数人不会第一眼注意到的组件上。这个经历其实说明了一个问题做MySQL内存排查第一步不是调参而是搞清楚内存到底被谁吃了。MySQL的内存消耗远不止一个buffer pool它的构成相当复杂而且在不同的版本、不同的负载模型下内存分布差异极大。如果你一上来就盯着最显眼的那几个参数动刀很可能事倍功半甚至把一个健康实例调出问题。1.1 一条命令看懂内存画像RSS、共享内存与performance_schema先明确一个概念我们说的MySQL占用内存过大通常以操作系统视角看到的结果为准也就是进程的RES常驻内存集。但RES本身是一个混合体它包含三块内容私有内存包括buffer pool、各种cache、线程栈、排序缓冲区、临时表堆等这部分是MySQL自己管理、自己持有的。共享内存主要是InnoDB buffer pool在部分配置下可能使用的大页HugePage映射以及一些共享库的映射页。内存映射文件比如临时表落盘后的临时文件映射、binlog cache等。如果你只是看到RES涨了而不去看内部分布就有很大概率被假象误导。这里我建议用performance_schema直接拉一个内存消耗排行不依赖外部工具一条SQL就能看清SELECT EVENT_NAME, CURRENT_NUMBER_OF_BYTES_USED AS current_bytes, HIGH_NUMBER_OF_BYTES_USED AS peak_bytes FROM performance_schema.memory_summary_global_by_event_name ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC LIMIT 20;EVENT_NAME会给你很清晰的分类比如memory/innodb/buffer_pool、memory/sql/THD、memory/performance_schema/table_instances等等。看到哪一类消耗异常高排查方向就基本锁定了。需要注意performance_schema本身也要消耗内存来记录这些统计信息所以它既是你排查的工具也可能是内存泄漏的嫌疑对象之一。1.2 两个时间维度缓慢爬坡与瞬间飙升的差异化定位排查内存问题时时间维度是第一个分岔路。你在监控图上看到的内存曲线决定了后续排查方向完全不同。缓慢爬坡型的特点是内存占用持续上升两三天内从60%涨到90%中间没有明显回落。这种情况通常是累积型内存消耗常见原因包括表缓存table cache持续增长、线程连接数缓慢堆积、performance_schema的统计信息膨胀、prepared语句没有被及时释放、某个应用连接泄漏导致会话资源无法回收。排查这类问题重点看连接数趋势、SHOW GLOBAL STATUS LIKE Threads_connected的历史曲线以及performance_schema.memory_summary_global_by_event_name中哪一项的HIGH_NUMBER_OF_BYTES_USED一直在创新高。瞬间飙升型则是几分钟内内存直接冲顶这种情况多半与并发大查询、大批量导入、临时表空间暴增有关。比如某条分析SQL对几百万行做了GROUP BY和排序临时表内存不够就会转磁盘但转磁盘前内存中仍然可能先分配了相当大的排序缓冲区多个这类查询并发出现内存就会像气球一样被吹起来。对于这类问题用SHOW PROCESSLIST配合EXPLAIN分析正在跑的SQL通常比查内存统计更直接。把时间维度和内存分布结合起来你就能先判断这是谁的问题而不是眉毛胡子一把抓。下一节我会把最常用的四条排查路径逐个展开每一套都是可以直接复制到终端执行的。2. 定位内存过大的四条排查路径2.1 先看操作系统层top与free的正确读法我见过太多人看free -h的时候只盯着used和available两列然后对着available很低就断定内存不够。实际上在Linux环境下free输出的buff/cache那部分是文件系统缓存在内存紧张时是可以被内核自动回收的不能直接算作进程占用。你更需要关注的是available一列它才是内核预估的可分配给新程序的内存量。另一个常见误区是直接看top里MySQL进程的%MEM然后简单乘以总内存得出mysqld占了多少G。这个数字通常是RES但RES里面包含共享页在多个mysqld实例共存或者使用了大页映射的场景下会偏大。我的习惯是先跑两条命令# 查看内存整体水位和swap使用情况 free -h # 按内存占用排序找出排在前面的进程 top -b -n 1 -o %MEM | head -25如果发现swap已经开始有使用量说明物理内存确实不够了内核已经不得不把部分内存页换出到磁盘。此时要注意MySQL这类对延迟敏感的服务一旦发生swap性能会急剧下降因为磁盘IO比内存慢几个数量级。你还可以用pidstat -r -p mysqld_pid 1持续观察mysqld进程的RSS变化判断它是稳定、缓慢增长还是快速膨胀这对应着上一节说的两类问题。2.2 钻进实例内部用performance_schema按线程定位会话操作系统层只能告诉你mysqld这个进程很大但到底是哪个会话、哪条SQL在消耗内存必须进入实例内部才能看到。如果你已经开启了performance_schemaMySQL 5.7.9之后默认开启可以直接按线程维度查内存SELECT t.THREAD_ID, t.PROCESSLIST_ID, t.PROCESSLIST_USER, t.PROCESSLIST_DB, t.PROCESSLIST_COMMAND, t.PROCESSLIST_TIME, es.BYTES_CURRENT FROM performance_schema.threads t JOIN ( SELECT THREAD_ID, SUM(CURRENT_NUMBER_OF_BYTES_USED) AS BYTES_CURRENT FROM performance_schema.memory_summary_by_thread_by_event_name GROUP BY THREAD_ID ORDER BY BYTES_CURRENT DESC LIMIT 10 ) es ON t.THREAD_ID es.THREAD_ID ORDER BY es.BYTES_CURRENT DESC;这个查询把内存占用最高的十个线程拉出来并关联到processlist信息。拿到PROCESSLIST_ID之后再去information_schema.processlist里看这条连接正在执行的SQLSELECT * FROM information_schema.processlist WHERE id 上面的PROCESSLIST_ID;如果是一条正在执行的大查询它的TIME会很大INFO里能直接看到SQL文本。这时候你基本可以确认内存飙升和这条SQL直接相关。接下来就是分析SQL、加索引、改写语句或者限流的问题了。值得一提的是performance_schema.memory_summary_by_thread_by_event_name这张表记录的是线程生命周期的累计内存分配如果线程已经退出但内存没有释放干净也说明可能存在连接回收问题。2.3 检查最大内存消耗对象从buffer pool到临时文件如果按线程查下来没有明显的单个大会话问题就更多出在全局性内存对象上。这里我建议按顺序排查三个地方第一是InnoDB Buffer Pool。它通常是MySQL最大的内存消费者查它的使用情况和命中率SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_pages_%; SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read_requests; SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_reads;命中率 Innodb_buffer_pool_read_requests / (Innodb_buffer_pool_read_requests Innodb_buffer_pool_reads)。如果命中率非常低说明buffer pool配置偏小大量查询走了磁盘IO反之Buffer Pool可能配得过大或者热数据占比很低。第二是临时表和临时文件。查看临时表创建情况SHOW GLOBAL STATUS LIKE Created_tmp_disk_tables; SHOW GLOBAL STATUS LIKE Created_tmp_tables;如果Created_tmp_disk_tables占比很高说明很多SQL生成的临时表超过了tmp_table_size和max_heap_table_size的限制被迫从内存落到磁盘。注意这些参数是每个线程独立的并发量大的时候每个线程都可能各自分配一块很大的临时内存总量非常可观。第三是外部文件缓存。操作系统层面常常会忽略的一个细节是MySQL对大表的全表扫描、索引预读也会申请额外的内存。你可以查看SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read_ahead%如果预读次数很高说明存在大量全表扫描或范围扫描这也是一种隐藏的内存压力。2.4 连接数这把隐形刀每条连接都在悄悄吃内存这条路径放在最后说是因为它最容易被低估。很多人会算max_connections * 某个缓冲区大小但实际上一根MySQL连接的内存消耗远不止一个参数那么简单。一条空闲连接的基本成本包括线程栈thread_stack默认256KB、连接对象本身THD结构、各种缓冲区net_buffer、sort_buffer、join_buffer等。这些缓冲区虽然很多是按需分配的但一旦某个连接执行过复杂查询分配出来的内存可能长时间不会归还给操作系统而是一直保留在进程的堆里备用。我做过一个粗略实测一个MySQL 8.0实例thread_stack256K、sort_buffer_size2M、join_buffer_size2M在300个并发连接全部执行过中等复杂度查询之后仅线程相关的内存就可以轻松超过2GB。如果你还把max_connections配成2000、3000即使大部分连接空闲内存上限也会被拉得很高。所以排查时一定要看两个数值的乘积效应SHOW VARIABLES LIKE max_connections; SHOW GLOBAL STATUS LIKE Threads_connected;Threads_connected如果一直逼近max_connections内存占用的天花板就非常高。这种情况下除了降低连接数更要检查应用层的连接池配置——很多内存问题其实是连接池最大连接数设置过大加上连接长期不释放造成的。3. 我踩过的三个内存假象看起来是内存问题实际另有原因这一节我想专门讲几个曾经把我带偏过的案例。它们共同的特征是现象清一色是内存占用大但根因完全不在直觉所在的位置。3.1 第一坑performance_schema采集失控内存被监控吃掉文章开头我提到的那个24G内存的案例根因就是performance_schema。那时实例跑在MySQL 8.0.28上默认开启了performance_schema同时又因为业务监控需要应用侧创建了大量的临时表、执行大量预处理语句。performance_schema会把每个线程、每类事件的统计信息都记录下来其中memory/performance_schema/table_instances和memory/performance_schema/events_statements_summary_by_digest这些表占用的内存会随着运行时长和SQL指纹数量持续膨胀。那次我执行SHOW GLOBAL STATUS LIKE Performance_schema%发现仅performance_schema的各类内存账目合计就已经接近8GB。整个实例24G内存接近三分之一被监控数据本身吃掉了。而它从外部看起来就像是MySQL内存泄漏因为它不会因为连接退出而释放只会越积越多。处理方案也分两步。第一步是裁剪采集项改配置文件[mysqld] performance_schema ON performance_schema_max_table_instances 1000 performance_schema_max_thread_instances 500 performance_schema_max_digest_length 64 performance_schema_events_statements_history_size 0 performance_schema_events_statements_history_long_size 0注意performance_schema各采集项之间有关联如果业务监控依赖statement级别的历史记录关闭events_statements_history_long_size之前先确认好监控系统的数据采集方式。第二步是确定不需要之后直接关闭performance_schema OFF关掉之后实例内存直接降了几个G但代价是后续无法再使用前面的内存定位SQL。所以更稳妥的做法是保留开关只裁剪掉不需要的采集维度。3.2 第二坑临时表与排序缓冲区大查询把内存当中转站另一个容易被误判为内存泄漏的情况是内存会在某次大查询后快速涨上去查询结束后又跌不回来。原因是MySQL的线程内存分配器不会立刻把内存归还操作系统而是保留在进程堆里作为预留供后续复用。从应用角度看就是RES一直居高不下但如果用performance_schema去看当前线程的内存消耗可能已经降下来了。这种场景的典型特征是内存峰值和某条重型SQL的执行时间高度吻合。排查时不要只盯数值要结合慢查询日志。我记得有一次线上跑了个报表SQLGROUP BY加ORDER BY涉及近千万行执行时临时表大小瞬间超过max_heap_table_size的默认值16MB先内存后落盘执行期间内存顶上去了好几个G。SQL执行完之后内存虽然回落了一些但由于连接一直存在那批临时内存并没有完全释放。针对这类问题优化手段依次是给SQL涉及的字段添加合适的索引减少排序量把tmp_table_size和max_heap_table_size调到一个合理值比如64M~128M但不要盲目调大否则大量并发排序会把内存撑爆最后考虑把大查询拆分为分批执行。3.3 第三坑表缓存与元数据锁堆积最后一个容易忽略的是表缓存。MySQL为了加快表打开速度会把已打开的表的元数据和文件描述符缓存起来。table_open_cache默认值是2000或4000不同版本不同每张被缓存的表都会占用一部分内存包括表结构、行格式信息、统计信息等。如果业务库里有几十万张表分库分表场景很容易出现缓存膨胀的速度会非常惊人。更隐蔽的是元数据锁堆积问题。当某个长事务持有表的元数据锁metadata lock时后续所有访问这张表的连接都会被阻塞持续堆积的新连接会不断创建新的线程对象内存自然水涨船高。你在processlist里会看到大量Waiting for table metadata lock状态的会话。这种内存上涨是并发连接堆积导致的跟SQL本身无关根因往往在前端某个没提交的事务。处理办法是找到持锁的长事务并kill掉同时规范应用层的事务提交逻辑。-- 查看是否存在大量元数据锁等待 SELECT * FROM performance_schema.metadata_locks WHERE LOCK_STATUS PENDING LIMIT 20;表缓存本身可以设置合理值我一般建议用SHOW GLOBAL STATUS LIKE Open_tables和SHOW GLOBAL STATUS LIKE Opened_tables来评估Open_tables接近table_open_cache上限而Opened_tables仍然快速上涨说明缓存不够如果Open_tables长期远小于上限则说明table_open_cache设得偏大白白占着内存。4. 落到配置与参数一套可抄作业的调优动作清单排查看清楚之后真正动手调参时要克制。很多参数牵一发动全身我建议按下面这个顺序执行每一步都做记录和验证不要一口气全改。4.1 先说最重要的事innodb_buffer_pool_size到底该设多大innodb_buffer_pool_size是MySQL内存配置的定海神针。常见的做法是物理内存的60%~70%但这个比例只适用纯数据库服务器的场景。如果你的机器上还跑着应用服务、监控agent、日志采集器就必须给它们留足空间。更稳妥的评估方式是看命中率。你可以执行SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read_requests; SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_reads;用前面提到的公式算命中率。如果命中率已经高于99%说明buffer pool勉强够用再加大它的意义不大如果低于95%说明热数据已经频繁被挤出缓存此时可以逐步加大。8.0版本支持动态调整SET GLOBAL innodb_buffer_pool_size 16 * 1024 * 1024 * 1024;注意InnoDB在调整buffer pool大小时会触发页面重分配期间可能出现短暂性能波动。线上环境建议在低峰期操作并且每一次调整的步长不要超过当前值的50%。4.2 按实例规格配好这几个小参数下面这张表是我在排查和调优中总结出来的常用参数清单每一项都标注了需要注意的地方。注意数值只是经验参考不同业务模型差异很大落地时要以实际观察为准。参数默认值经验参考值说明innodb_buffer_pool_size128M物理内存的50%~70%按下文方法验证最大内存消费项调大要谨慎performance_schemaON8.0按需裁剪或OFF可能吃掉数GB内存见3.1max_connections151按连接池实际并发评估一般500以内连接数乘以单连接内存成本是硬天花板table_open_cache2000~4000按Open_tables与Opened_tables评估表数量大时易膨胀sort_buffer_size256K建议不超过2M每个连接独享全局影响巨大join_buffer_size256K建议不超过2M同上tmp_table_size16M64M~128M视查询复杂度超过后会转磁盘临时表max_heap_table_size16M与tmp_table_size保持一致限制内存临时表最大值thread_stack256K默认即可不要调大每线程独享这里特别说一下sort_buffer_size和join_buffer_size。这两个往往被新手误调大觉得内存反正够用给大点查询快。实际上它们是每个连接独立分配的一旦并发200个连接同时做排序2M的sort_buffer就能贡献400M内存如果每个连接又嵌套多层执行消耗还会成倍放大。从我的经验看这两个值保持默认或者稍微调小配合索引优化让数据库尽量走有序扫描远比盲目调大缓冲区值更有效。4.3 修改参数的落地顺序与重启验证参数修改一定要先动态、再持久化。比如你想调整max_connections先执行SET GLOBAL max_connections 500;观察一段时间确认稳定后再把max_connections 500写进my.cnf。如果反过来先改配置文件再重启一旦参数有问题你甚至没有机会在运行态快速回滚。对于innodb_buffer_pool_size这类需要重启才能完全生效的场景虽然8.0支持动态调整但重启后会按新配置重新分配我建议按照改配置-准备回滚方案-低峰期重启-观察内存曲线的顺序来做。重启验证是最关键的一步。重启后一小时内不要急着下结论因为数据库需要重新加载缓冲池、预热热数据。你可以观察下面几个指标的变化SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_pages_free; SHOW GLOBAL STATUS LIKE Threads_connected;重启后如果内存仍然快速上涨到之前的高水位说明你之前定位的根因不对或者还有第二个内存大头没找到如果内存稳步回落且保持稳定说明调优方向正确。另外如果原来的实例确实用了很多内存重启后要留意应用的连接重建风暴最好配合应用侧的连接池预热策略分批次放流量。5. 长期治理把内存过高变成可预警、可追溯的事件排查和调优做完并不代表事情结束了。MySQL的内存特性决定了它天生就倾向于把可用内存都用于缓存所以高内存使用率本身不一定异常你需要的是建立一套判断正常高和病态高的监控体系。5.1 监控指标与预警阈值我建议至少盯住这几类指标并设置分层阈值MySQL进程RSS第一层预警设为物理内存的70%第二层80%超过90%必须立即处理。swap使用量只要swap从0变成大于0就是一个强预警信号说明物理内存已经出现压力。Threads_connected按max_connections的30%、60%、90%做三级监控超过90%时不仅内存压力大连接排队也会造成应用超时。临时表落盘率Created_tmp_disk_tables / Created_tmp_tables超过30%就要检查慢SQL和临时表相关配置。Buffer Pool命中率长期低于95%时优先自查SQL和索引而不是直接加内存。监控工具方面如果团队已经有Prometheus和Grafana可以使用社区常见的mysqld_exporter以上指标基本都有现成的采集项。如果不想引入额外组件我自己会用一个简单的定时脚本配合告警平台#!/bin/bash MYSQL_CMDmysql -u监控账号 -p密码 -h127.0.0.1 -P3306 RSS$(ps -o rss -p $(pgrep mysqld) | awk {print $1/1024/1024}) THREADS$($MYSQL_CMD -N -e SHOW GLOBAL STATUS LIKE Threads_connected; | awk {print $2}) MAX_CONN$($MYSQL_CMD -N -e SHOW VARIABLES LIKE max_connections; | awk {print $2}) # RSS超过物理内存80%则告警 # 这里可以用curl或其他方式推送到告警平台 echo $(date %F %T) RSS_GB$RSS THREADS$THREADS MAX_CONN$MAX_CONN脚本只是一个骨架生产环境一定要把数据库密码改成交钥匙或参数注入避免把账号密码直接写在脚本里。同时脚本本身的执行频率建议是每分钟一次告警阈值要避免过于灵敏防止告警疲劳。5.2 应急处理预案什么时候重启什么时候不能重启最后聊一个很多运维同学都会纠结的问题内存告警了到底要不要重启MySQL我的建议是区分场景。如果内存告警伴随服务不可用、应用报错而且你确认没有正在执行的关键大事务重启是止损的一种手段。但重启不是根治手段而且代价很高——buffer pool里的热数据全部失效之后一两个小时数据库读性能会明显下降高峰期重启等于人为制造一次缓存雪崩。如果内存只是高但服务还能跑建议严格按先定位、再处理的路径走查内存分布、查连接数、查正在执行的SQL找到大头以后针对性处理而不是直接重启。我曾经处理过一个实例内存高到只剩2G可用但因为定位到是某个连接池参数配置错误导致连接数爆炸直接在应用层改了连接池配置几分钟后内存就自然降下来了完全不需要重启数据库。要特别留意一种情况内存持续增长且重启后不久又涨回去。这可能意味着有依赖外部行为持续产生的内存消耗比如某个监控工具高频采集、某个定时任务批量导入。这种问题重启解决不了必须在业务层找出持续触发的东西并治理掉。从整个排查链路回头看MySQL内存问题往往是技术问题与使用方式问题的交织。很多情况下参数本身是健康的问题出在应用层的一次错误连接配置、一条没加索引的查询、或者一个设计不合理的表结构。这也是为什么我一直强调动手调参前先花时间定位内存排查的本质不是调几个数字而是理解你的数据库到底在做什么、为什么做、怎么做才能更省。