凌晨两点四十,手机震动把我从梦里拽出来。监控大屏显示生产库 CPU 使用率已经冲到 99%,慢查询数量每分钟超过 200 条,紧接着客服群开始刷屏:订单列表接口超时,用户陆续反馈下单失败。那一瞬间我反而冷静下来——这种场景在 MySQL 运维里不算罕见,但是每一次都需要非常清晰的排查思路,否则极容易在慌乱中做出错误操作,把一个小问题放大成事故。这篇文章我想完整复盘这次排查过程,把我当时看的每一个指标、执行的每一条命令、所做的每一步判断都记录下来。它不只是一份教程,更是一份实战排查笔记,适合正在负责 MySQL 运维的 DBA、后端开发,以及所有想建立系统化排查思路的同学。
1. 故障现场:值班电话响起之后的第一轮判断
1.1 问题的表象与可能的走向
收到告警后,我没有立刻打开数据库客户端,而是先看了一分钟监控面板。CPU 99% 只是表象,真正的问号在于:这个压力是查询造成的,还是写入造成的?是突然出现的慢 SQL 拖垮了全局,还是连接数被打满导致服务雪崩?这几个问题决定了完全不同的处理路径。
先说结论性的经验:MySQL 性能故障的走向通常就几种。一是单条大查询吃掉 CPU,典型的深分页、全表扫描、大排序;二是并发连接数过多,线程上下文切换和锁等待把数据库拖死;三是锁竞争,比如长事务不提交导致 MDL 锁越积越多,后续所有 DML 全部排队;四是资源层面出问题,比如磁盘 IO 延迟飙升、swap 占用过高、内存不足。不同的走向,处理手段完全不一样,所以第一步不是动手,而是判断方向。
1.2 排查顺序:先系统层还是先数据库层
我个人的习惯是"先外后内、先系统后数据库"。先看操作系统层面:CPU 是用户态高还是内核态高?磁盘 IO 是否打满?内存有没有触发 swap?这些信息半分钟内就能拿到,却能帮你快速排除掉一大半干扰项。
不要直接去 kill 会话或者重启数据库——这是很多新手最常犯的错误。现场还没保留,直接把问题会话杀掉了,后面想定位根因就难了。正确的做法是先通过系统命令采集现场数据,再深入数据库内部。当时我执行的命令组合是这样的:
bash复制top -bn1 | head -20
iostat -x 1 3
free -h
sar -q 1 3
top 看 CPU 和负载均值,iostat 看磁盘 IO 的 util 和 await,free 确认内存余量,sar -q 看运行队列长度。那一轮的输出显示:CPU 用户态飙到 95% 以上,sys 态只有 5%,磁盘 IO 负载不算高,内存充足。这个结果基本把问题锁定在数据库的计算层——大概率是 SQL 执行层面的问题,而不是 IO 瓶颈。
1.3 数据库层的第一眼:连接数与会话状态
系统层判断完,我立刻进入数据库,先看两个东西:连接数快照和当前活跃会话。
sql复制SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Max_used_connections';
SHOW VARIABLES LIKE 'max_connections';
SHOW PROCESSLIST;
Threads_connected 是当前连接数,Max_used_connections 是历史峰值,max_connections 是上限。如果当前连接数已经接近上限,且大量会话处于 Sleep 状态,那问题可能不是单条 SQL,而是连接池配置或者连接泄漏;如果大量会话处于 Query 或 Sending data 状态,那大概率是某类 SQL 在批量执行。
当时我看到 Threads_connected 只有 120 左右,距离上限 500 还很远,但其中有 40 多个会话处于 Sending data 状态。这是一个非常重要的信号——连接数没有满,但是大量线程同时在执行查询,说明是同一类慢查询并发堆积,而不是连接耗尽。接下来要做的,就是从这堆会话里找到它们共同指向的那条 SQL。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 processlist 里锁定真凶:会话视角还原现场
2.1 看懂 show processlist 的每个字段
SHOW PROCESSLIST 看起来简单,但很多人不会细看。它返回的字段包括 Id、User、Host、db、Command、Time、State、Info,其中真正有价值的是 State 和 Info。
Command表示会话在做什么:Query表示正在执行 SQL,Sleep表示连接空闲中。Time是当前状态持续的时间,单位是秒。State是 SQL 执行过程中最关键的中间状态节点,反映当前线程正在等待什么或正在做什么操作。Info显示正在执行的 SQL 语句内容,注意它只显示前 100 个字符。
当时我执行的是 SHOW FULL PROCESSLIST,因为完整版才会显示完整的 SQL 文本。输出里出现了大量长这样的记录:
sql复制| 83472 | app_user | 10.20.30.41:53622 | order_db | Query | 58 | Sending data | SELECT * FROM orders WHERE user_id = 1024 ORDER BY create_time DESC LIMIT 100000, 20 |
Time 58 秒、State 是 Sending data、SQL 看起来是一个订单分页查询。40 个会话里,有 30 多个的 Info 都是同一条 SQL 的不同参数版本。到这里,元凶基本浮出水面了,但"找到慢 SQL"只是开始,真正的问题是搞清楚它为什么慢。
2.2 state 列才是判断瓶颈的钥匙
很多人在 processlist 里看到 Sending data 就以为是在网络传输数据,这是一个非常普遍的误解。实际上,Sending data 这个状态覆盖了从存储引擎读取数据、在 Server 层过滤、计算、排序的整个阶段。它出现的时间越长,通常意味着 SQL 在存储引擎层扫描的数据量越大,或者 Server 层做了大量额外计算。
我整理过一份常用的 State 状态速查表,排查时比对着看非常高效:
| State 状态 | 通常含义 | 排查方向 |
|---|---|---|
| Sending data | 正在读取和处理数据,可能是全表扫描或大范围扫描 | 分析 SQL 执行计划,检查索引 |
| Sorting result / Using filesort | 正在排序,且排序的数据量较大 | 检查 ORDER BY 是否走索引,优化排序逻辑 |
| Waiting for table metadata lock | 等待 MDL 锁,通常是被未提交的长事务阻塞 | 查 innodb_trx 找长事务 |
| Waiting for handler commit | 提交阶段等待,通常和磁盘刷盘相关 | 查 redo log 配置、磁盘 IO |
| Waiting for table flush | 等待 FLUSH TABLE 完成 | 检查是否有 DDL 在排队 |
| Statistics | 正在计算统计信息 | 偶尔出现正常,持续出现要查表统计信息是否过期 |
这个表不是教条,而是帮你在看到异常状态时,脑子里立刻建立起一条因果链。比如看到 Waiting for table metadata lock,你不应该去优化 SQL,而应该去找那个一直没提交的事务。
2.3 慢查询日志与 processlist 的配合
processlist 的价值在于"当下这一刻的快照",但它有个缺点:只显示当前正在执行的 SQL。如果慢 SQL 是间歇性出现的,你可能蹲半天也看不到它。这时候就需要慢查询日志来还原更长的时间窗口。
我平时的习惯是长期开启慢查询日志,long_query_time 设置成 2 秒,生产环境绝不关。用 mysqldumpslow 或者直接查 mysql.slow_log 表做聚合分析,可以快速看到某段时间内 Top N 慢 SQL。当时我查到慢日志里那条订单查询累计出现了上千次,平均执行时间 4.2 秒,最大执行时间 9.8 秒。这里有一个容易被忽略的细节:不要把慢日志表当普通表直接全表扫,数据量大了以后查日志表本身都会变成慢查询。正确姿势是先按时间段过滤,再按执行次数或总耗时排序。
3. 慢 SQL 根因剖析:深分页、隐式转换和索引失效
3.1 一个典型的深分页慢查询
那条 SQL 长这样:
sql复制SELECT * FROM orders
WHERE user_id = 1024
ORDER BY create_time DESC
LIMIT 100000, 20;
从业务逻辑看再正常不过——订单列表第 5000 页。但从 MySQL 执行的角度看,这条 SQL 有一个致命问题:LIMIT 100000, 20 需要先读取前 100020 行,然后丢掉前 100000 行,只返回最后的 20 行。也就是说,扫描越到后面页,需要丢弃的行越多,消耗的 IO 和 CPU 越大。
更深层的坑在 ORDER BY create_time DESC。如果 user_id 上有索引,MySQL 会先通过索引找到 user_id = 1024 的所有记录,再对这些记录排序。而如果联合索引设计得不合理,排序就无法用到索引,只能动用 Using filesort。我用 EXPLAIN 验证了一下:
sql复制EXPLAIN SELECT * FROM orders
WHERE user_id = 1024
ORDER BY create_time DESC
LIMIT 100000, 20;
执行计划里 type 是 ALL,也就是全表扫描,扫描行数预估 524160 行,Extra 列明确写着 Using filesort。这说明 orders 表上根本没有合适的索引,每次查询都要全表扫一遍,再在内存里做一次大排序,然后丢弃大部分结果。这已经不只是"慢"的问题,而是设计层面缺失索引导致的系统性隐患。
3.2 explain 执行计划的关键列怎么看
既然讲到这里,我顺便把 EXPLAIN 的核心列完整说一遍,这是所有 MySQL 性能排查的地基。新手最容易犯的错误是只盯着 type 列看,其实每一列都有自己的价值。
type:访问类型,从好到坏依次是system、const、eq_ref、ref、range、index、ALL。ALL是全表扫描,index是全索引扫描,这两种都需要重点警惕。key:实际用到的索引。NULL说明没有走任何索引。rows:预估扫描的行数。这个值和实际值差距越大,统计信息越不准。Extra:额外信息。Using filesort和Using temporary尤其需要关注,前者意味着排序没走索引,后者意味着产生了临时表。filtered:经过条件过滤后剩余行的百分比。值越低,说明扫描的行里大部分都被丢掉了,优化空间越大。
当时那条 SQL 的 rows=524160,但 filtered=1.25,意味着扫描了 52 万行后,最终只用到几千行,其他全被丢弃。这种 SQL 无论怎么调系统参数都不可能快起来,唯一的出路就是改 SQL 或者加索引。
3.3 隐式类型转换:varchar 字段查询时的隐蔽陷阱
排查完深分页后,我顺手把慢日志里其他几条高频 SQL 也过了一遍,其中一条非常典型,是用户在列表页按手机号搜索的接口:
sql复制SELECT * FROM members
WHERE phone = 13800138000
LIMIT 10;
phone 字段在表结构里是 varchar(20),但查询条件里传的是整数 13800138000。MySQL 在比较时会把字段值转换成数字类型,导致字段上的索引无法直接用于查找,索引失效,变成全表扫描。
这类问题非常隐蔽,因为业务代码里如果参数类型控制不严,很容易出现。排查手法也很简单,对 SQL 执行 EXPLAIN,看 type 是否变成 ALL,看 key 是否变成 NULL。修复方式就一句话:查询参数保持和字段类型一致,写成字符串:
sql复制SELECT * FROM members
WHERE phone = '13800138000'
LIMIT 10;
改完以后 type 变成 const,扫描行数从十万级降到 1 行。这个案例价值不在于技术含量,而在于它提醒我们:排查性能问题时,不能只看执行计划,还要回查字段定义,把 SQL 的条件值和字段类型做对比。
3.4 函数、前缀模糊查询与索引失效
还有一类常见问题,是对索引列做函数运算。比如:
sql复制SELECT * FROM orders
WHERE DATE(create_time) = '2025-01-01';
create_time 上明明有索引,但一旦套上 DATE 函数,MySQL 就无法高效地使用索引进行范围匹配。改法非常固定:把函数运算从列上挪走,挪到常量里去:
sql复制SELECT * FROM orders
WHERE create_time >= '2025-01-01 00:00:00'
AND create_time < '2025-01-02 00:00:00';
LIKE '%关键词%' 的前缀模糊查询同理,无法利用 B+ 树的排序特性,索引会直接失效。如果业务确实需要全文搜索,那就老老实实引入全文索引或者搜索引擎,而不是试图用一个 LIKE 硬扛。
这里我想给一个非常实用的优化路径,当一条慢 SQL 定位到是索引问题时,按这个顺序尝试:
- 先确认字段类型和查询参数类型一致。
- 检查 WHERE 条件列是否被函数、运算包装。
- 看联合索引的字段顺序是否符合最左前缀原则。
- 最后才考虑重写 SQL 或者改业务逻辑。
4. 锁与长事务:连接卡死背后的元凶
4.1 MDL 锁等待的排查链路
处理完慢 SQL 后,我盯着 processlist 又发现了一些不一样的会话:它们不是 Sending data,而是大量处于 Waiting for table metadata lock 状态。而阻塞它们的源头,是一条已经执行了 21 分钟的事务——一个后台批量任务开了事务,却一直没有提交。
MySQL 的元数据锁(MDL)机制规定:对一个表执行 DDL 之前,要等所有涉及该表的事务结束;而 DML 在执行过程中又会被未提交的 DDL 阻塞。当一个长事务拖了 21 分钟,所有对这个表的读写操作就会被堵在门口,连接越积越多,最终表现为业务接口大面积超时。
定位阻塞源头,我用了这条 SQL:
sql复制SELECT * FROM sys.schema_table_lock_waits\G
它会非常直观地列出谁在等、谁在阻塞。不过我还会配合 information_schema 里的三张表做交叉确认:
sql复制SELECT * FROM information_schema.innodb_trx\G
SELECT * FROM information_schema.innodb_lock_waits\G
SELECT * FROM information_schema.innodb_locks\G
innodb_trx 能看到所有当前事务,重点看 trx_started 字段,时间越长越可疑;innodb_lock_waits 能看到锁等待关系,requested_lock_id 对应等待者,blocking_lock_id 对应持有者。查到 trx_id 后,再通过 sys.session 或 processlist 找到对应的会话 ID,确认是不是业务后台任务。
4.2 行锁等待与死锁的实战处理
处理锁问题要非常谨慎。我当时确认了那个长事务是某个批处理脚本忘记提交,在业务低峰期把它 kill 掉是安全的。但 kill 之前我保留了一条完整的会话快照,确认 kill 的目标确实不是正在执行的重要写入请求,才执行:
sql复制KILL 83472;
之后 Waiting for table metadata lock 的会话在几秒内全部恢复,连接数迅速回落。这里有一个血泪教训:不要一次性 kill 所有卡住的会话。应该先 kill 阻塞源头,让队列自然消化。如果你同时杀掉几十个会话,上游应用会因为瞬间的大量连接错误产生新的雪崩,反而更难恢复。
行锁等待的排查逻辑和 MDL 类似,但场景不一样。最常见的行锁冲突场景是两条 SQL 更新同一组记录但顺序不同,比如事务 A 先更新 record_1 再更新 record_2,事务 B 先更新 record_2 再更新 record_1,两者互相等对方释放锁,形成死锁。遇到死锁,MySQL 会自动回滚代价较小的事务,但业务层会收到死锁报错。处理方案是:检查代码里多记录更新的顺序是否一致,尽量缩短事务时间,避免在事务里做耗时过长的查询后再更新。
4.3 减少锁冲突的日常设计规范
锁问题的排查链路并不复杂,真正难的是提前预防。我在多次踩坑后总结出几条非常实用的规范:
- 事务里只放必要的语句,绝对不要把跨网络的 RPC 调用嵌在数据库事务里。
- 多条记录的更新操作,在应用层统一按主键排序后再执行。
- 大事务拆小事务,比如批量更新 10 万条,拆成每 500 条提交一次。
- DDL 尽量安排在业务低峰期,并用在线 DDL 工具。
- 给表加上合理的索引,防止更新操作因索引失效从行锁升级为表锁。
5. 从参数配置到硬件压力:系统层优化与容量评估
5.1 连接数打满的处理与容量评估
处理完锁等待后,我回过头来重新审视这次故障里暴露出的另一个隐患:连接数虽然没有打满,但线程堆积已经非常严重。这背后的根源是慢 SQL 拖长了单个查询的响应时间,请求在数据库层排队,应用连接池也随之被占满。
MySQL 的连接数配置里,max_connections 是最直观的参数,但它不是越大越好。每个连接都需要线程栈空间和内存,连接数过大反而会增加上下文切换成本,降低整体吞吐。我通常会结合历史监控来评估容量:如果 Max_used_connections 长期在 max_connections 的 70% 以下,配置就相对安全;如果经常逼近上限,首要任务不是调大参数,而是排查连接池配置和慢 SQL。
应用层的连接池配置同样关键。Java 领域的 HikariCP、Druid 都建议根据数据库吞吐设置合理的 maximum-pool-size。网上很多推荐值是"CPU 核数 x2+1",但真实场景里我更倾向于先压测,搞清楚数据库在健康状态下的最大并发处理能力,再反向设置连接池上限。
5.2 buffer pool 与缓存命中率调优
这一次故障里,innodb_buffer_pool_size 的配置也值得反思。故障期间我查看了缓存命中率:
sql复制SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads';
计算逻辑很简单:(read_requests - reads) / read_requests。read_requests 是总读取请求次数,reads 是实际从磁盘读取的次数。当时这个值只有 96%,对于订单这类高频访问的核心表来说偏低,说明缓冲池容量或数据访问模式有问题。
innodb_buffer_pool_size 一般建议设置为物理内存的 60% 到 70%,但要注意机器上是否有多个 MySQL 实例,以及其他进程是否占用大量内存。调整完参数后,我会观察一段时间内 Innodb_buffer_pool_reads 和 Innodb_buffer_pool_read_requests 的变化,确认命中率是否提升到 99% 以上。这个参数见效慢,不会像 kill 阻塞会话那样立竿见影,但长期价值非常高。
5.3 日志刷盘、临时表等容易被忽视的配置
除了 buffer pool,这次排查还让我注意到几个平时容易被忽视的配置项。
innodb_log_file_size 如果设置得太小,会导致 redo log 频繁切换和刷盘,写入性能大幅下降。生产环境一般建议至少 1GB 以上,具体根据写入量来定。判断标准是:如果系统状态里 innodb_log_waits 数值持续增长,就该考虑调大。
tmp_table_size 和 max_heap_table_size 影响内部临时表的大小。GROUP BY、ORDER BY、DISTINCT 这些操作如果产生的临时表超过阈值,会从内存临时表转为磁盘临时表,性能断崖式下跌。我当时就发现一条带 GROUP BY 的统计 SQL 在 Extra 列里出现了 Using temporary,排查后确认是临时表溢出了。适当调大 tmp_table_size 到 64MB 后,这条 SQL 的执行时间从 1.8 秒降到了 0.4 秒。
sort_buffer_size 是另一个有争议的参数。它针对每个会话分配,设置过大反而浪费内存。默认值 256KB 通常够用,遇到 Using filesort 的问题,优先想办法让排序走索引,而不是盲目调大排序缓冲。
6. 排查工具沉淀与复盘清单
6.1 日常巡检的 SQL 清单
这次故障结束后,我把排查过程中用到的定位语句整理成了一份日常巡检清单,固化到脚本里定期执行。这里分享给各位:
sql复制-- 1. 当前活跃会话数及状态分布
SELECT command, state, COUNT(*)
FROM information_schema.processlist
GROUP BY command, state
ORDER BY COUNT(*) DESC;
-- 2. 执行时间最长的 20 个会话
SELECT id, user, host, db, command, time, state, LEFT(info, 200) AS sql_text
FROM information_schema.processlist
WHERE command != 'Sleep'
ORDER BY time DESC
LIMIT 20;
-- 3. 当前未提交的长事务
SELECT trx_id, trx_state, trx_started, trx_rows_modified,
LEFT(trx_query, 200) AS query_text
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 30 SECOND;
-- 4. 锁等待关系
SELECT * FROM sys.innodb_lock_waits\G
-- 5. 慢查询聚合 Top 10
SELECT schema_name, LEFT(sql_text, 100) AS sql_sample,
COUNT(*) AS exec_count,
ROUND(SUM(timer_wait)/1000000000000, 2) AS total_seconds
FROM performance_schema.events_statements_summary_by_digest
WHERE last_seen > NOW() - INTERVAL 1 DAY
ORDER BY total_seconds DESC
LIMIT 10;
这些 SQL 覆盖了会话、长事务、锁等待、慢查询四个核心维度。每天定时跑一遍,绝大多数性能隐患都能提前发现。
6.2 监控指标与告警水位
有了清单还不够,监控告警才是防止事故扩大的第一道防线。我会在监控系统里重点盯这几个指标,并设置合理的水位:
| 指标 | 健康水位 | 告警水位 | 说明 |
|---|---|---|---|
| CPU 使用率 | 低于 70% | 连续 5 分钟高于 85% | 排查慢 SQL 和连接数 |
| Threads_connected | 低于 max_connections 的 60% | 连续 3 分钟高于 80% | 检查连接池和长事务 |
| 慢查询数量 | 每分钟低于 5 条 | 每分钟高于 50 条 | 打印慢日志分析 |
| Buffer pool 命中率 | 高于 99% | 低于 95% | 调整 buffer pool 或优化访问模式 |
| 活跃会话数 | 低于线程池上限 | 持续高于阈值 | 通过 processlist 定位 |
| 锁等待次数 | 极少出现 | 持续增长 | 检查 innodb_trx 与死锁日志 |
告警不是越多越好,过于敏感的告警反而会导致"狼来了"效应。我设置的原则是:连续多次采样都突破阈值才触发,避免瞬时尖峰误报。
6.3 复盘记录模板
最后我想说一个常被忽略的环节——事故复盘。每次故障处理完,我都会按固定模板写一份复盘报告,内容包含:故障发生时间、持续时间、影响范围、根因分析、处理过程、优化措施、后续检查项。这份记录的价值不在当下,而在于半年后再遇到类似问题时,能直接翻开当时的过程和结论,少走很多弯路。
这次故障的根因链,一句话总结就是:线上订单表缺少合适的联合索引,加上应用层深分页写法,导致单条 SQL 扫描量过大;慢 SQL 把线程资源耗尽后,一个未提交的长事务又触发了 MDL 锁等待,最终让整个库的服务能力雪崩。修复动作拆成三部分:第一,紧急加索引并优化深分页 SQL,恢复线上服务;第二,kill 长事务,解除锁等待;第三,调整 buffer pool 和临时表参数,防止同类问题反复。
排查 MySQL 性能问题这几年,我最大的体会是:它从来不是单点问题,而是一条因果链的传导结果。你看到的 CPU 飙升、连接堆积、接口超时都是末端症状,真正的根子可能在 SQL 写法、索引设计、事务模型或者配置参数上。只要顺着"请求到会话、会话到 SQL、SQL 到执行计划、执行计划到锁和资源"这条线一层层剥开,绝大多数问题都能定位到。每一次排障都在积累经验,但经验再多,也不如把方法论固化下来——这也是我写这篇复盘文章的初衷。
