半夜两点被报警电话叫起来,说线上订单接口超时,监控面板一片红。这种场景干过后端的人都不陌生。我当时的处理顺序很简单:先看慢查询日志,再看执行计划,最后才是改代码或者加索引。因为MySQL性能优化这件事,最怕的不是问题难,而是你连问题出在哪都不知道就开始瞎调。今天这篇就把这套定位方法完整拆开讲清楚,全程围绕慢查询和执行计划这两个核心工具展开,适合刚接触性能优化、或者被线上性能问题折磨过但一直没系统梳理过的朋友。
1. 性能问题定位的整体思路:先别急着加索引,先回答三个问题
1.1 一个真实场景:线上系统突然变慢,你会怎么做
先还原一下我经历过的典型场景。某天晚上,运营反馈后台列表页打开要十几秒,用户端下单也偶发超时。这时候团队里通常会出现三种反应:第一种人直接去服务器上看CPU和内存,发现占用不高就束手无策;第二种人凭直觉给相关表的几个字段加了索引,结果没效果;第三种人更干脆,先重启数据库试试。
这三种做法的问题都一样,跳过了“定位”这一步。MySQL性能优化本质上是一个排障过程,你得先回答三个问题:系统到底哪里慢?是某几条SQL慢还是整体都慢?这些SQL为什么慢?这三个问题没搞清楚之前,所有优化动作都是盲目的。我在实际工作中总结过一条原则:性能优化的第一步永远不是优化,而是量化。你得先用数据证明瓶颈确实存在、确实在数据库这一层,再往下钻。
为什么这么说?因为“系统变慢”这个表象背后,可能的根因太多了。应用服务器Full GC、网络抖动、Redis缓存穿透、数据库连接池打满、某条SQL走了全表扫描、甚至磁盘IOPS被打满,任何一种情况都能让你觉得“数据库好像不太行”。如果不先定位,你花半天时间调的参数可能根本不是关键路径上的问题。
1.2 定位问题的核心链路:慢查询日志加执行计划,一个都不能少
经过前面那步,确认瓶颈大概率在数据库后,接下来就用两条核心链路往下钻。
第一条链路是慢查询日志,它回答的是“哪些SQL慢”。MySQL会把执行时间超过阈值的SQL原样记录下来,包括执行耗时、锁等待时间、返回行数、扫描行数等关键指标。有了这份日志,你就能在一个时间段内找到真正拖垮数据库的那几条“罪魁祸首”,而不是大海捞针。
第二条链路是执行计划,它回答的是“这些SQL为什么慢”。对慢查询日志里挑出来的SQL执行EXPLAIN,MySQL优化器会告诉你它打算怎么执行这条SQL:是全表扫描还是走索引、预估扫描多少行、需不需要临时表、要不要文件排序。所有性能问题的根因,最后几乎都能在执行计划里找到答案。
这两条链路的关系,我习惯用一个比喻:慢查询日志是医院的“分诊台”,帮你快速锁定哪些科室有问题;执行计划是“CT机”,帮你看到病灶的具体位置和形态。只做分诊不做CT,你只知道某个器官不对,但不知道为什么不对;只做CT不看分诊报告,你不可能把几百张片子全扫一遍。所以下面的章节,我会先讲慢查询日志怎么用好,再讲执行计划怎么读透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 慢查询日志的开启与分析:系统慢SQL的“侦测雷达”
2.1 慢查询日志怎么开才不踩坑
慢查询日志的开启方式有两种,一种是临时生效,一种是写进配置文件永久生效。临时生效适合在排查问题时快速打开,不用重启数据库:
sql复制-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
-- 设置阈值,单位是秒,这里设置为1秒
SET GLOBAL long_query_time = 1;
-- 把没有使用索引的SQL也记录进来
SET GLOBAL log_queries_not_using_indexes = 'ON';
注意,long_query_time这个参数有个坑:它是“修改之后新建立的连接才生效”,不是立即对所有会话生效。也就是说,你用当前这个会话去执行一条超过1秒的SQL,它不会立刻被记录,得重新开一个连接执行的SQL才会被记。这个细节经常让第一次配置的人以为日志没生效,白白耗掉不少时间。
写配置文件的方式更稳妥,适合确定要长期开启的情况。在MySQL配置文件(Linux下通常是/etc/my.cnf或/etc/mysql/mysql.conf.d/mysqld.cnf)的[mysqld]段下加上:
ini复制[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
然后重启MySQL服务生效。这里有两个参数值得展开说一下。
slow_query_log_file这个参数指定慢日志的存放路径。我建议在生产环境里把它单独放到一个磁盘空间充裕的目录,别跟数据文件放在同一个分区。因为如果哪天真有一条失控SQL把日志刷到几个GB,日志所在分区被打满,整个数据库实例都有可能出问题。
log_queries_not_using_indexes这个参数值得单独琢磨。它会把所有没走索引的SQL都记下来,哪怕是执行时间只有1毫秒的查询。这个参数在优化初期很好用,能帮你发现很多“漏网之鱼”;但上线一段时间后,如果系统里有大量合理的小表查询没走索引,日志会飞快增长。我见过有人因为这个参数没关,半个月慢日志文件涨到30GB的。所以生产环境建议加上min_examined_row_limit参数,设置一个最小扫描行数,低于这个数值的不记录:
ini复制[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
min_examined_row_limit = 100
2.2 日志分析三板斧:手工、mysqldumpslow、pt-query-digest
慢查询日志开启之后,接下来就是怎么分析的问题。日志原始格式长这样:
code复制# Time: 2024-06-18T02:15:23.123456Z
# User@Host: app_user[app_user] @ [10.0.0.12] Id: 123456
# Query_time: 12.345678 Lock_time: 0.001234 Rows_sent: 20 Rows_examined: 2456789
SET timestamp=1718676923;
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 20;
这条记录里最有价值的信息是Query_time(执行总耗时)和Rows_examined(扫描行数)。245万行的扫描只返回20行,字面写着一个大问题:这条SQL大概率没走索引,或者索引设计不合理。
分析日志我分三个层级。第一层是直接用tail或grep看,适合日志量不大、只需要快速确认有没有慢SQL时:
bash复制# 看最近50条慢查询
tail -n 50 /var/log/mysql/mysql-slow.log
第二层是MySQL自带的mysqldumpslow工具,它能对日志做聚合统计。用法很简单:
bash复制# 按平均查询时间排序,显示前10条
mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.log
这个工具会把结构相似的SQL自动归组,比如只差具体数值的查询会被合并成一条并显示为N。这样一来你一眼就能看出哪些模板SQL是慢查询的“常客”。
第三层是Percona Toolkit里的pt-query-digest。这个工具比mysqldumpslow强大得多,会生成一份完整的分析报告,包含每个SQL模板的执行次数、总耗时、平均耗时、占比等信息,还能按执行总时间排序,帮你判断哪些SQL最值得优先优化:
bash复制pt-query-digest /var/log/mysql/mysql-slow.log
提示:优先优化的判断标准,不是单条耗时最长,而是“总耗时占比最高”。一条每天执行几万次、每次耗时0.5秒的SQL,比一条每天执行几次、每次耗时10秒的SQL更值得先处理。前者优化掉0.2秒,一天的收益就是几千秒的数据库时间。
2.3 慢日志里最容易骗人的三种情况
第一,定时任务或批处理卡点。如果慢查询集中的时间点正好是凌晨跑批或者整点定时任务,那这些慢SQL很可能不是用户请求,而是后台批处理。它们虽然慢,但用户无感知,优化优先级可以往后排。
第二,同一SQL的耗时方差极大。同一条SQL白天只要几十毫秒,凌晨却要几秒。这说明SQL本身大概率不是根因,问题更可能在锁竞争、磁盘IO波动或者缓存失效上。这时候盯着执行计划看没用,得去看数据库的锁等待和系统层指标。
第三,阈值设置过高掩盖问题。如果你把long_query_time设成5秒或10秒,很多“温水煮青蛙”式的慢SQL——比如单次1秒多、但高频执行的那种——会被完全过滤掉。这种SQL对系统的消耗是渐进式的,等它们把连接池占满时,问题才集中爆发。所以新建项目或新接手一个系统时,我建议先把阈值设成1秒,跑一周看情况再慢慢调。
3. EXPLAIN执行计划逐个字段拆解,读懂数据库的“内心戏”
3.1 执行计划长什么样,先看懂输出格式
慢查询日志帮你锁定目标SQL之后,下一步就是给这条SQL做“CT检查”。方法很简单,在SQL前面加一个EXPLAIN关键字:
sql复制EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 20\G
加上\G是为了让输出按行展示,不然列太多容易看得眼花。MySQL 8.0里的执行计划大概长这样:
code复制*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: orders
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 2456789
filtered: 10.00
Extra: Using where; Using filesort
这一行输出信息量非常大。type是ALL,表示全表扫描;possible_keys是NULL,表示这条SQL没找到任何可用索引;rows是245万,表示优化器预估要扫描245万行;Extra里出现了Using filesort,表示排序没有用到索引,得额外做文件排序。看到这个执行计划,一条SQL慢的根本原因就呼之欲出了:全表扫描加上文件排序,在数据量小的时候没事,数据量一上来必然出问题。
3.2 type列:你的SQL访问了表里的多少数据
type是执行计划里最先要看的字段,它直接反映SQL访问数据的方式。按性能从好到差排序,常见值有这些:
| type取值 | 含义 | 性能水准 |
|---|---|---|
| system | 表只有一行(系统表),基本不会出现在业务查询中 | 最好 |
| const | 主键或唯一索引等值匹配,最多返回一行 | 极好 |
| eq_ref | 被驱动表通过主键或唯一索引等值匹配,常见于Join | 很好 |
| ref | 通过普通二级索引等值匹配,返回多行 | 较好 |
| range | 索引范围扫描,如IN、BETWEEN、>、< |
不错 |
| index | 扫描整个索引树,比全表扫描好一点但有限 | 一般 |
| ALL | 全表扫描,最差 | 需要重点优化 |
我见过很多人在type=const或eq_ref时特别开心,觉得SQL没问题。但这里要泼一盆冷水:type只是告诉你访问方式,不告诉你真实代价。一条ref类型扫描几万行的SQL,不一定比一条ALL类型但只扫了几百行的小表SQL问题更严重。所以看type要结合rows一起看,一个字段定方向,一个字段估量级。
另外有个小技巧要提醒:如果一条SQL的type是ALL,但rows只有几百行,而且这个表本来就是几十行的小配置表,那这种全表扫描完全不用管。优化不是把所有ALL都消灭,而是把真正昂贵的那部分访问干掉。
3.3 key_len和rows:预估与实际的差距
key_len表示MySQL在索引里使用的字节数。这个值很有用,它能告诉你联合索引到底用了几个字段。举个例子,假设有联合索引idx_status_created(status, created_at),status是VARCHAR(20)且允许NULL,created_at是DATETIME:
- 如果
key_len只等于varchar(20)的长度加上变长字段的2字节和NULL的1字节,大约是20*4+2+1=83字节,说明优化器只用到了status这一列; - 如果
key_len包含了created_at的长度,说明两个字段都用上了。
通过key_len你就能判断SQL到底“吃”了索引的多少内容。这里有一个常见误区:很多人以为联合索引建了就会全部生效,其实只有当查询条件里包含联合索引的最左前缀列时,后面的列才会被用到。所以看到key_len比预期短,第一时间去查SQL的WHERE条件里有没有包含联合索引的第一个字段,以及有没有在索引字段上做函数运算或隐式类型转换。
rows是优化器预估的扫描行数。注意“预估”两个字,它不是实际值。优化器基于统计信息和一些启发式规则来估算,有时候偏差很大。如果你发现执行计划里rows严重偏离实际数据分布,可以先执行ANALYZE TABLE 表名;更新统计信息。如果更新完还是不准确,多半是数据分布本身有倾斜,比如某个字段的值90%都是同一个值,这时候即使走了索引,优化器算来算去也可能觉得全表扫描更划算。
3.4 Extra里的“危险信号”:filesort和temporary
如果说type和rows是执行计划的“明面信息”,那Extra列里就藏着很多“暗面信号”。这里面最需要警惕的是两个词:Using filesort和Using temporary。
Using filesort表示MySQL需要额外执行一次排序操作。这个“file”并不是指磁盘文件,它可能发生在内存里,也可能真的落到磁盘上。只要排序的数据量超过了sort_buffer_size参数设置的缓冲区大小,就会使用磁盘临时文件,性能直线下降。出现Using filesort的常见原因有两种:一是ORDER BY的字段没有索引覆盖;二是ORDER BY和WHERE里用的索引字段顺序不匹配,比如WHERE用了status,但ORDER BY用的字段不是联合索引里的第二列,还是没法利用索引排序。
Using temporary更严重,它表示MySQL得创建临时表来辅助查询,常见于GROUP BY、DISTINCT、子查询和某些UNION操作。临时表可能是内存临时表,也可能落盘成磁盘临时表(用Created_tmp_disk_tables状态变量可以观测),一旦落盘性能会急剧恶化。看到这个信号,优先考虑改写SQL,比如用JOIN替代复杂的子查询,或者调整索引让GROUP BY能走索引完成。
还有一个值得注意的Extra值是Using index condition,表示查询用到了索引下推(Index Condition Pushdown)。这不是坏信号,反而说明部分WHERE条件已经下推到存储引擎层过滤了,能少回表就少回表,一般是好现象。
注意:
Extra里的信号不是“见一个杀一个”,而是“结合场景判断”。比如Using where本身很常见,只是说明存储引擎返回数据后Server层又做了过滤,不一定是问题;但如果它跟ALL同时出现,那就要引起重视了。
4. 定位到问题之后:索引设计与SQL改写实战
4.1 从执行计划到索引设计:一个完整案例
纸上谈兵够了,来一个完整案例走一遍全过程。假设业务上需要查询“待处理订单里最近创建的20条”,SQL如下:
sql复制SELECT * FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 20;
慢查询日志显示这条SQL执行了3秒,EXPLAIN结果如下:
code复制type: ALL
possible_keys: NULL
key: NULL
rows: 2456789
Extra: Using where; Using filesort
全表扫描245万行,然后文件排序,再取20条,每一步都是实打实的成本。优化方案很直接,建一个联合索引:
sql复制ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);
再看执行计划:
code复制type: ref
possible_keys: idx_status_created
key: idx_status_created
key_len: 83
rows: 24567
Extra: Using index condition
优化器先通过status='pending'走索引找到符合条件的行,再利用联合索引里第二列的created_at天然有序的特性,直接按顺序取20条记录,Using filesort消失了。扫描行数从245万降到2万多,耗时从3秒降到30毫秒左右。这就是一个典型的“执行计划指导索引设计”的闭环。
这里值得展开的是联合索引字段顺序的设计逻辑。status的区分度其实很低,全表可能90%的订单都是pending状态,按常理说区分度低的字段放前面并不是最优选择。但在这个场景里,联合索引的重点不是为了通过status过滤掉多少数据,而是为了让created_at能参与排序。如果只给created_at建单列索引,MySQL确实能快速找到最新的20条,但还得再根据这20条去过滤status='pending',如果这20条里恰好没有pending状态的,就得继续往下扫,效率反而不稳定。所以这个场景把status放前面是合理的,过滤加排序两手抓。
4.2 联合索引的最左前缀原则到底怎么用
上一节涉及了联合索引的字段顺序问题,这里把最左前缀原则展开讲透。联合索引(a, b, c)等价于创建了(a)、(a, b)、(a, b, c)三个索引,能匹配这三种前缀组合。但很多人容易忽略的是:最左前缀不仅指字段顺序,还指查询条件的写法。
看几个例子。索引是idx_status_created(status, created_at):
WHERE status = 'pending' AND created_at > '2024-01-01':能用满索引,created_at的范围条件也能在索引里完成;WHERE created_at > '2024-01-01' AND status = 'pending':同样能用满索引。因为优化器会做条件重排,不一定按你写的顺序来,但前提是字段本身是等值条件加范围条件的组合;WHERE status = 'pending' ORDER BY created_at:能用满索引,排序直接走索引有序性;WHERE created_at > '2024-01-01':用不上索引。因为跳过了最左列status,只查第二列。
还有一种常见写法要特别小心:对索引字段做函数操作或者隐式类型转换。比如WHERE DATE(created_at) = '2024-06-18',即使created_at在索引里,函数操作会让索引失效。正确写法是WHERE created_at >= '2024-06-18 00:00:00' AND created_at < '2024-06-19 00:00:00'。
隐式类型转换的坑则更隐蔽。比如字段status是VARCHAR类型,但查询条件写成WHERE status = 1,MySQL会把字符串字段转成数字来比较,导致索引失效。排查这类问题时,看key_len会非常直观——如果该用索引却没用,key_len会是NULL,那就要怀疑是不是类型转换或函数操作把索引干掉了。
4.3 分页查询变慢的常用优化套路
跟分页相关的慢查询,在业务系统里出现的频率非常高,这里单独拿出来说。经典写法是:
sql复制SELECT * FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 10000, 20;
这条SQL的本质问题是:LIMIT 10000, 20意味着MySQL要先把前10020行全部查出来,然后丢弃前10000行,只返回最后20行。随着翻页深度增加,扫描的数据越来越多,性能越来越差。即使走索引,这个“扫了扔”的过程也省不掉。
常用的优化套路是延迟关联(也叫延迟连接)。先在一个子查询里用覆盖索引只查出目标行的主键ID,再用主键回原表取完整数据:
sql复制SELECT o.*
FROM orders o
INNER JOIN (
SELECT id
FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 10000, 20
) t ON o.id = t.id;
这样内层子查询只需要扫描索引,索引里包含id、status、created_at三个字段,是覆盖索引,不需要回表。扫描一百万行索引的数据量比扫描一百万行完整行记录小得多,性能自然好不少。如果业务上订单ID的分布比较均匀,还可以用基于游标的分页方式,利用WHERE id > 上一页最大ID来跳过前面的数据,但这需要业务配合改造,不是所有场景都适用。
5. 常见问题排查与避坑实录
5.1 问题速查表:从现象到根因的对照
这些年我帮团队排查过不少慢查询问题,整理了一张速查表,遇到类似现象可以直接对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 慢查询日志里没有记录,但系统明显卡顿 | 阈值设置过高,或慢SQL发生在未开启日志的会话中 | 调低long_query_time,确认会话级参数 |
| EXPLAIN显示用了索引,但SQL还是很慢 | 索引区分度太低,或rows预估与实际偏差大 |
查看key_len和rows,更新统计信息 |
| 同一条SQL时快时慢 | 锁等待、缓冲池命中率波动、磁盘IO抖动 | 看Lock_time、Innodb_buffer_pool_read_requests |
SQL简单但执行计划里出现Using temporary |
GROUP BY或DISTINCT导致临时表 | 尝试改写SQL,检查是否有合适的联合索引 |
| 查询条件都建了索引,但possible_keys为NULL | 索引列上有函数、隐式类型转换 | 检查SQL写法,确认字段类型匹配 |
| 慢查询集中在固定时段 | 定时任务、备份任务抢占IO | 错峰执行,调整优先级 |
这个表格只是一个起点,实际排查时往往需要交叉验证多个信息源。比如看到Lock_time特别高,不一定是SQL自己的问题,可能是有别的事务长时间持锁不放。这时候光看执行计划不够,得去information_schema.innodb_trx表查当前事务,或者开启innodb_lock_wait_timeout相关的日志才能定位。
5.2 几个真实踩坑案例
最后分享几个我实际踩过的坑,每一个都花过不少冤枉时间。
第一个坑是关于慢查询日志权限的。早期在一次生产环境排查中,我在配置里开了慢查询日志,但等了半天日志文件还是空的。排查了半天才发现是MySQL进程对日志目录没有写权限,错误信息被吞掉了。后来我在配置里加了log_error参数单独记录错误日志,Slow log的问题才暴露出来。所以开启慢日志之后,第一件事不是跑SQL,而是确认.frm文件或错误日志里没有报错,并且用SHOW VARIABLES LIKE 'slow_query_log%';确认参数真的生效了。
第二个坑是关于优化器选错索引的。有一次我已经给SQL建好了理想的联合索引,EXPLAIN里也显示走了这个索引,但线上实际执行还是慢。后来观察到,MySQL优化器在某些数据分布下会选择key_len更小、rows更多的其他索引,甚至偶尔退化到全表扫描。处理办法不是简单删掉旧索引,而是先ANALYZE TABLE更新统计信息,如果还不行再用FORCE INDEX或者调整optimizer_switch里的参数。但这里要提醒一句,FORCE INDEX是最后手段,因为它是写死在SQL里的,一旦数据分布变化,强制索引反而可能变成负优化。
第三个坑是关于**“优化好一条SQL就觉得万事大吉”**的心态。数据库的数据量是持续增长的,今天执行计划是range扫描,三个月后数据翻倍可能就变成ALL。我现在的习惯是每个月抽一次慢查询日志,对比同一个SQL模板的Query_time变化趋势。性能优化不是一个一次性任务,而是一个持续的监测闭环。慢查询日志和执行计划这两板斧,每一次循环都会用上。
我自己刚开始做性能优化时,特别迷恋那些“SQL技巧大全”和“参数调优秘籍”,后来发现真正救场次数最多的,反而是先把慢查询日志和执行计划这两个基本功练扎实。它们一个告诉你问题在哪儿,一个告诉你问题为什么发生,吃透了这两样,日常遇到的MySQL性能问题里至少有八成能找到清晰的解决路径。剩下两成涉及更深的锁分析、IO瓶颈、分库分表这些领域,就需要另开专题了,但“先定位、后优化”的思路,到了哪个阶段都不会变。
