EXPLAIN与慢查询分析
开篇:慢查询是怎么拖垮整个系统的?
想象一下这个场景:周五下午五点,你的电商系统突然变慢。订单页面转了十几秒才打开,客服消息炸了群。运维一看监控------数据库连接池全满,几百个线程都在排队等着。
最后查出原因:一条没加索引的查询语句,平均执行要 3 秒。高峰期每秒来 50 个请求,数据库连接根本不够用。
这就是慢查询的可怕之处。它就像高速公路上的一起追尾事故------事故本身可能只涉及一辆车,但它把整条车道堵住了,后面几公里的车全走不了。一条慢 SQL 占着连接不释放,后面的请求只能排队,连接池被耗尽,所有查询都受影响。
所以,作为开发者,我们必须掌握两项核心技能:用慢查询日志找到问题 SQL,然后用 EXPLAIN 分析它为什么慢。这就是本文要从头到尾讲透的内容。
一、慢查询日志:找到问题 SQL
1.1 慢查询日志是什么
慢查询日志就像一个"测速雷达"------它会自动记录所有执行时间超过阈值的 SQL 语句。
默认情况下,MySQL 并不会开启慢查询日志,因为记录日志本身也有性能开销。但在调优阶段,它是定位问题的第一步------你不知道哪条 SQL 慢,后面的分析优化就无从谈起。
1.2 如何开启
方式一:临时开启(重启后失效,适合临时排查)
-- 查看当前状态
SHOW VARIABLES LIKE 'slow_query_log';
-- 开启慢查询日志(注意要加 GLOBAL)
SET GLOBAL slow_query_log = ON;
-- 设置阈值为 1 秒(默认 10 秒,实在太长了)
SET GLOBAL long_query_time = 1;方式二:永久开启(写入配置文件 my.cnf,需要重启 MySQL)
[mysqld]
slow_query_log = ON
slow_query_log_file = /var/lib/mysql/slow.log
long_query_time = 1
log_output = FILE
long_query_time设成多少合适?没有标准答案,取决于业务。一般互联网应用设为 1 秒,对延迟敏感的场景(如支付、秒杀)可以设为 0.5 秒。阈值设太高会漏掉问题 SQL,设太低会产生过多日志。
1.3 查看慢查询统计
-- 查看系统累计有多少条慢查询
SHOW GLOBAL STATUS LIKE 'Slow_queries';这个数字如果持续增长,说明系统中存在需要关注的慢 SQL。
1.4 用 mysqldumpslow 分析日志
慢查询日志可能有成千上万条记录,手动翻看效率太低。MySQL 自带了一个分析工具 mysqldumpslow,它会帮你把相似的 SQL 归类汇总:
# 按出现次数排序,取 Top 10(最频繁的慢 SQL)
mysqldumpslow -s c -t 10 /var/lib/mysql/slow.log
# 按平均耗时排序,取 Top 10(最慢的 SQL)
mysqldumpslow -s at -t 10 /var/lib/mysql/slow.log
# 按总耗时排序(找出"虽然每次不太慢但频次高、总耗时长"的 SQL)
mysqldumpslow -s t -t 10 /var/lib/mysql/slow.log优先处理什么?两个维度:频次最高的和单次最慢的。频次高意味着影响面大,单次最慢意味着每次都在拖后腿。两种都要治,但优先治频次高的------它对系统整体的负荷贡献更大。
1.5 慢查询日志里到底记了什么
打开慢查询日志,你会看到类似这样的内容:
# Time: 2024-06-04T12:00:00.123456Z
# User@Host: app_user[192.168.0.10]:3306
# Query_time: 2.345678 Lock_time: 0.012345 Rows_sent: 10 Rows_examined: 150000
SET timestamp=1717502400;
SELECT * FROM orders WHERE status = 'pending' ORDER BY create_time DESC;几个关键信息:
- Query_time:SQL 实际执行时间,2.3 秒
- Lock_time:等待锁的时间,0.01 秒(说明不是锁等待导致的慢)
- Rows_sent vs Rows_examined:返回了 10 行,但扫描了 15 万行------这就是典型的"大海捞针"式查询,说明缺少索引
二、EXPLAIN 详解
找到慢 SQL 之后,下一步就是搞清楚它为什么慢。这时候就要请出我们的核心工具------EXPLAIN。
EXPLAIN 就像给 SQL 做一次"体检"。它不会真正执行查询,而是告诉你 MySQL 打算怎么执行这条 SQL:走哪个索引、扫描多少行、是否需要排序等等。
用法很简单,在 SQL 前面加上 EXPLAIN 关键字即可:
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001
ORDER BY create_time DESC;执行后会返回一张表,每一行代表对一个表的访问方式。下面是各列的完整含义:
| 列名 | 含义 | 关注程度 |
|---|---|---|
| id | 查询的序号。id 相同则从上往下执行,id 不同则 id 大的先执行 | 一般 |
| select_type | 查询类型:SIMPLE(简单查询)、PRIMARY(最外层)、SUBQUERY(子查询)等 | 一般 |
| table | 当前正在访问的表(或别名) | 一般 |
| partitions | 涉及的分区 | 低 |
| type | 访问类型------最核心的性能指标 | 极高 |
| possible_keys | 可能用到的索引列表 | 高 |
| key | 实际选择使用的索引 | 极高 |
| key_len | 使用的索引长度(字节数),越短越好 | 中 |
| ref | 与索引比较的列或常量 | 中 |
| rows | 预估需要扫描的行数 | 高 |
| filtered | 经过 WHERE 条件过滤后保留的行百分比 | 中 |
| Extra | 额外信息------隐藏着很多优化线索 | 极高 |
在实际分析中,我们重点关注三个字段:type、key 和 Extra。id 和 select_type 在多表/子查询时帮助你理解执行顺序,rows 帮你评估扫描量。下面逐一拆解核心字段。
2.1 type 字段:从最好到最差
type 表示 MySQL 访问数据的方式,是判断一条 SQL 性能好坏最直观的指标。效率从高到低依次是:
system > const > eq_ref > ref > range > index > ALL用找人来打比方:
| type | 比喻 | 实际含义 | 出现场景 |
|---|---|---|---|
| system | 全校只有 1 个学生,直接叫名字 | 表只有一行数据 | MyISAM 系统表,极少见 |
| const | 按学号翻到那一页,一步到位 | 主键或唯一索引等值查询 | WHERE id = 1 |
| eq_ref | 每个班只有一个班长,按班号直接定位 | JOIN 时被驱动表用主键/唯一索引匹配 | JOIN ON a.id = b.id(b 的 id 唯一) |
| ref | 按姓氏查通讯录,可能查到多人 | 非唯一索引等值查询 | WHERE name = 'Tom'(name 有普通索引) |
| range | 翻通讯录某几页 | 索引范围查询 | WHERE age > 20、WHERE id IN (1,2,3) |
| index | 把整本通讯录从头翻到尾 | 全索引扫描(扫了整棵索引树) | 查询列被索引覆盖,但没有有效的 WHERE 条件 |
| ALL | 挨个教室、挨个座位找人 | 全表扫描 | 没索引、或优化器认为全扫更快 |
来看对应的 SQL 例子(假设有表 users 有主键 id、普通索引 idx_username(username)、联合索引 idx_age_name(age, name),nickname 无索引):
-- const:主键等值查询,一步定位
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type=const, key=PRIMARY
-- ref:普通索引等值查询
EXPLAIN SELECT * FROM users WHERE username = 'alice';
-- type=ref, key=idx_username
-- range:索引范围查询
EXPLAIN SELECT * FROM users WHERE age BETWEEN 20 AND 30;
-- type=range, key=idx_age_name
-- index:全索引扫描
EXPLAIN SELECT age, name FROM users;
-- type=index, key=idx_age_name
-- 查询的列刚好被联合索引覆盖,但没有 WHERE 条件,只能遍历整棵索引树
-- ALL:全表扫描
EXPLAIN SELECT * FROM users WHERE nickname = 'test';
-- type=ALL, key=NULL
-- nickname 没有索引阿里巴巴开发手册的要求:SQL 的 type 至少要达到 range 级别,要求 ref 以上,最好是 const。
很多人会问:type=index 不是用了索引吗,为什么还说它差?因为 index 只是说"在索引树上操作",但它扫描了整棵索引树。全索引扫描比全表扫描好的地方仅仅在于:索引树通常比数据表小(不包含整行数据)。真正高效的索引使用是 const/ref/range------它们只扫索引树的一部分。
2.2 Extra 字段:隐藏的优化线索
Extra 字段是 EXPLAIN 中信息量最大的一列,它告诉你 MySQL 在执行过程中做了哪些"额外操作"。有些是好消息,有些是需要立刻处理的红灯信号。
绿灯(好消息):
| Extra 值 | 含义 |
|---|---|
| Using index | 覆盖索引------只读索引就拿到了全部需要的数据,无需回表查聚簇索引 |
| Using index condition | 索引下推(ICP)------在存储引擎层就做了部分 WHERE 条件过滤,减少回表次数 |
黄灯(需要关注):
| Extra 值 | 含义 |
|---|---|
| Using where | 存储引擎返回数据后,Server 层再用 WHERE 过滤。说明索引没能完全覆盖查询条件 |
| Using where; Using index | 用了覆盖索引,但 WHERE 条件不符合最左前缀,做了全索引扫描后在 Server 层过滤 |
红灯(需要优化):
| Extra 值 | 含义 | 为什么慢 |
|---|---|---|
| Using filesort | 无法利用索引的有序性完成排序,需要额外排序操作 | 数据量大时会用磁盘临时文件做归并排序 |
| Using temporary | 创建了临时表来辅助完成操作 | 临时表没有索引,数据量大时写磁盘 |
| Using join buffer | JOIN 时被驱动表没索引,用了连接缓存 | 说明 JOIN 效率很低,需要给被驱动表加索引 |
双红灯组合:如果看到 Using temporary; Using filesort 同时出现,基本上是当前查询中性能最差的情况之一------先建临时表再做文件排序,两个耗时操作叠加。常见于 GROUP BY 和 DISTINCT 没走索引的场景。
来看几个 Extra 的实际例子:
-- Using index(覆盖索引,好)
-- 假设有联合索引 idx_abc(a, b, c)
EXPLAIN SELECT a, b, c FROM t WHERE a = 'test';
-- Extra: Using index(只查索引列,不用回表)
-- Using where; Using index(用了索引但做了全索引扫描)
EXPLAIN SELECT a FROM t WHERE b = 'test';
-- 不符合最左前缀,只能扫全索引
-- Using filesort(排序没走索引)
EXPLAIN SELECT * FROM orders WHERE status = 'PAID'
ORDER BY create_time;
-- 如果只有 idx_status 索引,排序字段 create_time 没索引可用
-- Using temporary; Using filesort(最差情况之一)
EXPLAIN SELECT COUNT(*), city FROM users GROUP BY city;
-- 如果 city 没有索引2.3 实战:读懂一条复杂查询的执行计划
假设我们有一个电商系统,需要查询已支付订单及对应的用户名:
EXPLAIN SELECT o.id, o.order_no, u.username
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'PAID'
ORDER BY o.create_time DESC
LIMIT 20;可能得到如下执行计划:
+----+--------+--------+------------+---------+------+----------+-----------------------------+
| id | table | type | key | key_len | rows | filtered | Extra |
+----+--------+--------+------------+---------+------+----------+-----------------------------+
| 1 | o | ref | idx_status | 4 | 5000 | 100.00 | Using where; Using filesort |
| 1 | u | eq_ref | PRIMARY | 4 | 1 | 100.00 | NULL |
+----+--------+--------+------------+---------+------+----------+-----------------------------+逐行分析:
第 1 行:orders 表(驱动表)
- type=ref:通过
idx_status索引匹配 status='PAID',效率不错 - rows=5000:预估有 5000 条已支付订单
- Extra=Using filesort:
ORDER BY create_time没法走索引,需要把 5000 行取出来后额外排序
第 2 行:users 表(被驱动表)
- type=eq_ref:通过主键逐行匹配,每次只查 1 行
- 这部分性能很好,无需优化
诊断:瓶颈在于 orders 表的排序。idx_status 只覆盖了 WHERE 条件,没有覆盖 ORDER BY。5000 行的排序虽然在内存中就能完成,但如果数据增长到几十万行,filesort 的开销会越来越大。
优化方案:建一个联合索引,同时覆盖 WHERE 和 ORDER BY:
ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);优化原理:联合索引 (status, create_time) 中,相同 status 的记录已经按 create_time 有序排列。所以 MySQL 可以直接从索引中按顺序取前 20 条,无需额外排序。
优化后再跑一次 EXPLAIN:
+----+--------+--------+-----------------+---------+------+----------+-------------+
| id | table | type | key | key_len | rows | filtered | Extra |
+----+--------+--------+-----------------+---------+------+----------+-------------+
| 1 | o | ref | idx_status_time | 4 | 20 | 100.00 | Using where |
| 1 | u | eq_ref | PRIMARY | 4 | 1 | 100.00 | NULL |
+----+--------+--------+-----------------+---------+------+----------+-------------+Using filesort 消失了,rows 从 5000 降到 20------因为有了索引的有序性加上 LIMIT 20,MySQL 只需要从索引中取前 20 条就够了。
2.4 key 有值但还是慢?小心 type=index 的陷阱
这是一个非常常见的误区:看到执行计划中 key 有值,就以为走了索引。但实际上,如果 type=index,说明虽然在索引树上操作,却是在扫描整棵索引树。
| type | key | Extra |
| index | idx_abc | Using where; Using index |这种情况通常是没有遵守最左前缀匹配。比如有联合索引 (a, b, c),查询条件却是 WHERE b = 'xxx'------b 不是前导列,MySQL 只能遍历整个索引来找满足条件的记录。
怎么区分"真正走了索引"和"全索引扫描"?
| 判断方式 | 真正走了索引 | 全索引扫描 |
|---|---|---|
| type | const / ref / range | index |
| Extra | Using index 或无特殊说明 | Using where; Using index |
| rows | 通常较小 | 接近表的总行数 |
解决办法:要么调整查询条件,让它符合最左前缀匹配;要么调整索引定义,把经常查询的字段放到前面。
2.5 id 和 select_type:理解多表查询的执行顺序
对于简单的单表查询,id 都是 1,不用太关注。但当查询涉及子查询、UNION 等复杂结构时,id 就很重要了:
EXPLAIN SELECT * FROM orders
WHERE user_id IN (
SELECT id FROM users WHERE city = 'Beijing'
);+----+-------------+--------+------+
| id | select_type | table | type |
+----+-------------+--------+------+
| 1 | PRIMARY | orders | ALL |
| 2 | SUBQUERY | users | ref |
+----+-------------+--------+------+- id=2 先执行(子查询),从 users 中查出北京的用户 ID
- id=1 后执行(主查询),用这些 ID 去 orders 中查数据
规则很简单:
- id 相同------从上到下依次执行
- id 不同------id 大的先执行
- id 相同的为一组,组间按 id 大小排序
2.6 filtered 字段:在 JOIN 中的隐藏价值
filtered 表示经过 WHERE 条件过滤后,预计保留多少比例的数据。对于单表查询,这个字段参考意义不大。但在多表 JOIN 时,它直接影响被驱动表的扫描次数:
实际传递给下一个表的行数 = rows x filtered%例如驱动表 rows=10000, filtered=10%,那么被驱动表需要匹配 1000 次。如果 filtered 很低,说明当前索引的过滤能力不够,可以考虑在联合索引中加入更多字段来提前过滤。
三、性能监控工具
EXPLAIN 告诉你 MySQL "打算怎么做",但有时候计划和现实会有差距------优化器的 rows 只是估算值,实际扫描行数可能差距很大。这时候需要一些能看到"实际执行情况"的工具。
3.1 SHOW PROFILE:看 SQL 把时间花在哪里
SHOW PROFILE 可以精确到每个执行阶段的耗时,告诉你这条 SQL 到底是慢在了锁等待、还是数据读取、还是排序:
-- 开启 Profiling(会话级别)
SET profiling = 1;
-- 执行查询
SELECT * FROM orders WHERE user_id = 1001;
-- 查看最近几次查询的概要
SHOW PROFILES;
+----------+------------+--------------------------------------------+
| Query_ID | Duration | Query |
+----------+------------+--------------------------------------------+
| 1 | 0.00328400 | SELECT * FROM orders WHERE user_id = 1001 |
+----------+------------+--------------------------------------------+
-- 查看某条查询各阶段的详细耗时
SHOW PROFILE FOR QUERY 1;
+----------------------+----------+
| Status | Duration |
+----------------------+----------+
| starting | 0.000124 |
| Opening tables | 0.000058 |
| optimizing | 0.000009 |
| statistics | 0.000030 |
| preparing | 0.000036 |
| executing | 0.002876 |
| end | 0.000007 |
| closing tables | 0.000016 |
| freeing items | 0.000044 |
+----------------------+----------+各阶段怎么看:
- Opening tables 耗时长:表太多或文件系统慢
- statistics 耗时长:统计信息收集慢,可能需要 ANALYZE TABLE
- executing 耗时长:SQL 本身执行慢,重点看索引
- Sending data 耗时长(有些版本会显示):数据量太大,或网络传输慢
还可以指定看 CPU、IO 等详细信息:
-- 看 CPU 耗时
SHOW PROFILE CPU FOR QUERY 1;
-- 看 IO 操作次数
SHOW PROFILE BLOCK IO FOR QUERY 1;
-- 看全部信息
SHOW PROFILE ALL FOR QUERY 1;SHOW PROFILE 在 MySQL 5.x 可以放心使用。在 MySQL 8.0 中,它正逐渐被 Performance Schema 和 EXPLAIN ANALYZE 取代。
3.2 EXPLAIN ANALYZE(MySQL 8.0.18+)
这是 MySQL 8.0 引入的利器------它会真正执行查询,并返回每个步骤的实际耗时和实际行数:
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 1001;-> Index lookup on orders using idx_user (user_id=1001)
(cost=3.50 rows=10)
(actual time=0.042..0.078 rows=8 loops=1)这里有两组数据:
cost=3.50 rows=10------优化器的预估(成本 3.5,预估 10 行)actual time=0.042..0.078 rows=8 loops=1------实际结果(耗时 0.042~0.078 毫秒,实际 8 行,执行 1 次)
这个工具最大的价值在于:当预估和实际差距很大时(比如预估 100 行实际 10 万行),说明统计信息过时了,可以用 ANALYZE TABLE orders; 更新统计信息,让优化器做出更准确的判断。
3.3 SHOW PROCESSLIST:看当前在跑什么
当数据库突然变慢,用 SHOW PROCESSLIST 可以快速了解当前所有连接的状态:
SHOW PROCESSLIST;
+----+---------+------------------+--------+---------+------+----------+------------------------+
| Id | User | Host | db | Command | Time | State | Info |
+----+---------+------------------+--------+---------+------+----------+------------------------+
| 1 | app_usr | 192.168.0.10:33 | mydb | Query | 45 | Sorting | SELECT * FROM orders.. |
| 2 | app_usr | 192.168.0.10:34 | mydb | Query | 32 | Sending | SELECT * FROM users... |
| 3 | app_usr | 192.168.0.11:12 | mydb | Sleep | 120 | | NULL |
+----+---------+------------------+--------+---------+------+----------+------------------------+重点关注:
- State 列:如果大量连接状态是
Sorting result或Sending data,说明正在执行数据量大的操作 - Time 列:已经运行了多长时间(秒)。如果某些连接运行了几十秒甚至更久,很可能就是慢 SQL
- Info 列:正在执行的 SQL 语句。如果看到
Sleep且 Time 很大,可能是连接泄漏
四、SQL 调优套路
掌握了工具之后,我们来总结一套完整的调优方法论。四个字:定、分、优、验。
第一步:定位------找到问题 SQL
来源可以是:
- 慢查询日志(最基础)
- APM 监控工具(如 SkyWalking、CAT)
- 数据库中间件告警(如 TDDL 的超时日志)
- 用户反馈(某个页面突然变慢)
第二步:分析------搞清楚为什么慢
用 EXPLAIN 分析执行计划。常见的慢查询原因:
| 序号 | 原因 | 在 EXPLAIN 中的表现 |
|---|---|---|
| 1 | 没有索引 | type=ALL, key=NULL |
| 2 | 索引失效(函数/类型转换/不符合最左前缀) | type=ALL 或 type=index |
| 3 | 多表 JOIN,被驱动表无索引 | Extra: Using join buffer |
| 4 | 排序没走索引 | Extra: Using filesort |
| 5 | GROUP BY/DISTINCT 产生临时表 | Extra: Using temporary |
| 6 | 深度分页(LIMIT offset 太大) | rows 非常大 |
| 7 | SELECT *,无法覆盖索引 | Extra 中没有 Using index |
| 8 | 数据量太大(单表超千万行) | rows 很大即使走了索引 |
| 9 | 锁等待/长事务 | SHOW PROCESSLIST 中 State 显示锁相关 |
第三步:优化------对症下药
不同原因有不同的解法:
| 原因 | 优化手段 |
|---|---|
| 缺索引 | 创建合适的索引,优先考虑联合索引 |
| 索引失效 | 修改 SQL 写法,避免在索引列上使用函数或隐式类型转换 |
| 多表 JOIN | 给被驱动表的 JOIN 字段加索引,确保小表驱动大表 |
| 排序慢 | 建联合索引同时覆盖 WHERE + ORDER BY |
| 临时表 | 给 GROUP BY 字段加索引 |
| 深度分页 | 延迟关联(先查 ID 再回表)或游标分页(WHERE id > last_id) |
| SELECT * | 只查需要的字段,让查询走覆盖索引 |
| 数据量大 | 归档历史数据、分库分表、或同步到 ES |
| 参数不合理 | 调整 innodb_buffer_pool_size、sort_buffer_size 等 |
第四步:验证------确认效果
优化后的验证清单:
- 重新执行 EXPLAIN,确认 type 提升(ALL -> ref)、rows 减少、filesort/temporary 消失
- 在测试环境实际运行,确认执行时间下降到可接受范围
- 上线后持续观察:监控 RT(响应时间)、慢 SQL 数量、CPU 使用率
- 如果优化后反而变慢了(有时候会发生),用 EXPLAIN 对比优化前后的执行计划差异
五、常见面试题精选
Q1:EXPLAIN 执行计划中,最该关注哪些字段?
三个最核心的字段:
- type:访问类型,反映了 MySQL 访问数据的效率。从好到差是 system > const > eq_ref > ref > range > index > ALL。至少要 range,最好 ref 或 const。
- key:实际使用的索引。如果为 NULL 说明没走索引。但 key 有值也不代表一定高效------还得看 type 是不是 index(全索引扫描)。
- Extra:额外信息。
Using filesort和Using temporary是红灯;Using index是绿灯(覆盖索引)。
另外 rows 也很重要------它直接反映了扫描量。同样走了索引,扫描 100 行和扫描 100 万行性能差距巨大。
Q2:count(1)、count(*) 和 count(字段) 有什么区别?
COUNT(*)和COUNT(1)在 MySQL 中完全等价,没有性能差异。MySQL 官方文档明确说明 InnoDB 对二者的处理方式相同。COUNT(*)是 SQL92 标准语法,推荐使用。COUNT(字段)语义不同------它只统计该字段值不为 NULL 的行数。所以结果可能比 COUNT(*) 小。- 性能上,COUNT(*) 会优先选择最小的非聚簇索引来扫描(因为非聚簇索引比聚簇索引小得多),而 COUNT(字段) 还要额外判断是否为 NULL。
Q3:Using filesort 一定要优化吗?怎么优化?
不一定,取决于数据量。如果排序数据只有几十行,filesort 在内存中完成,影响微乎其微。
但如果数据量大到 sort_buffer 装不下,MySQL 就要用磁盘临时文件做归并排序,性能急剧下降。这时就必须优化。
优化三板斧:
- 建联合索引:让 WHERE 和 ORDER BY 在同一个索引中。如
WHERE status='PAID' ORDER BY create_time,建(status, create_time)联合索引。 - 调大 sort_buffer_size:增加排序用的内存。但注意每个连接都会分配这么多内存,不要设太大。
- 减少排序数据量:不要 SELECT *,只查需要的字段。字段越少,sort_buffer 能装下的行越多。
Q4:MySQL 怎么查看一条 SQL 的执行耗时?
两种方式:
- MySQL 8.0 之前:用 SHOW PROFILE。先
SET profiling=1开启,执行 SQL 后用SHOW PROFILES看整体耗时,SHOW PROFILE FOR QUERY N看各阶段详情。 - MySQL 8.0.18+:用
EXPLAIN ANALYZE。它会真正执行查询,返回每个步骤的实际耗时(actual time)、实际行数(rows)和执行次数(loops)。比 SHOW PROFILE 更精准。
Q5:慢 SQL 排查的完整步骤是什么?
- 发现:通过慢查询日志、监控报警、或用户反馈发现慢 SQL
- 定位:从日志中找到具体的 SQL 语句,关注 Query_time(执行时间)和 Rows_examined(扫描行数)
- 分析:用 EXPLAIN 分析执行计划,重点看 type、key、Extra 三个字段
- 诊断:确定是什么原因------缺索引?索引失效?多表 JOIN?排序?深度分页?
- 优化:对症下药------加索引、改 SQL、调参数
- 验证:重新 EXPLAIN 确认优化效果,上线后观察监控
小结
本文围绕慢查询分析的完整流程,覆盖了三大核心环节:
- 慢查询日志------帮你找到问题 SQL(在哪里)
- EXPLAIN 执行计划------帮你分析为什么慢(因为什么)
- 调优方法论------定位、分析、优化、验证(怎么修)
记住一个核心原则:不要凭感觉优化,要用数据说话。
先用 EXPLAIN 看执行计划,确认问题所在------是没走索引?还是索引选错了?还是排序拖慢了?然后有针对性地调整。盲目加索引不仅可能没效果,还会拖慢写入性能------因为每次 INSERT/UPDATE/DELETE 都要同步维护索引。
最后送一句口诀:慢日志定位,EXPLAIN 分析,type 要看好,Extra 别忽略,优化完要验。
附录:EXPLAIN 速查对照表
在日常开发中,你可以把下面这张表打印出来贴在工位上,每次看执行计划时对照一下:
type 速查
| type | 含义 | 是否需要优化 |
|---|---|---|
| system | 表只有一行 | 无需优化 |
| const | 主键/唯一索引等值查询 | 无需优化 |
| eq_ref | JOIN 时被驱动表用主键/唯一索引匹配 | 无需优化 |
| ref | 非唯一索引等值查询 | 通常不需要 |
| range | 索引范围查询 | 一般可接受 |
| index | 全索引扫描 | 数据量大时需优化 |
| ALL | 全表扫描 | 必须优化 |
Extra 速查
| Extra | 含义 | 行动建议 |
|---|---|---|
| Using index | 覆盖索引 | 非常好,保持 |
| Using index condition | 索引下推 | 较好,保持 |
| Using where | Server 层过滤 | 检查是否可以通过索引过滤 |
| Using filesort | 额外排序 | 考虑建联合索引覆盖 ORDER BY |
| Using temporary | 临时表 | 考虑给 GROUP BY 字段加索引 |
| Using join buffer | JOIN 无索引 | 给被驱动表的 JOIN 列加索引 |
| Using where; Using index | 全索引扫描后过滤 | 检查是否违反最左前缀 |
常见优化场景速查
场景 1:WHERE + ORDER BY 组合
-- 原始:WHERE status = 'PAID' ORDER BY create_time
-- 索引:(status, create_time) 联合索引
-- 效果:避免 filesort场景 2:覆盖索引消除回表
-- 原始:SELECT user_id, status FROM orders WHERE user_id = 1001
-- 索引:(user_id, status) 联合索引
-- 效果:Extra 出现 Using index,无需回表场景 3:JOIN 优化
-- 原始:SELECT * FROM orders o JOIN users u ON o.user_id = u.id
-- 关键:确保被驱动表的 JOIN 列有索引
-- 效果:type 从 ALL 变为 eq_ref场景 4:深度分页优化
-- 原始:SELECT * FROM orders ORDER BY id LIMIT 1000000, 10
-- 优化:SELECT * FROM orders WHERE id > 上一页最大ID ORDER BY id LIMIT 10
-- 效果:避免扫描前 100 万行场景 5:GROUP BY 优化
-- 原始:SELECT city, COUNT(*) FROM users GROUP BY city
-- 索引:给 city 加索引
-- 效果:消除 Using temporary 和 Using filesort