MySQL 8.0.18 引入了一个被严重低估的命令:EXPLAIN ANALYZE。绝大多数站长对它的印象还停留在「EXPLAIN 看看走了哪个索引」,但普通的 EXPLAIN 只是优化器对你的查询的猜想,而 EXPLAIN ANALYZE 返回的是真实执行过的、带耗时和行数的执行树。这两者之间的差距,经常就是「我明明加了索引,怎么还是慢」的答案。
这篇文章不重复讲最左前缀那些基础,而是把 EXPLAIN ANALYZE 的输出逐行拆开,讲清楚怎么用它定位那些普通 EXPLAIN 完全看不出来的问题。
一、为什么普通 EXPLAIN 会骗你
先用一个真实的例子说明差别。假设内容站的表结构如下:
CREATE TABLE typecho_contents (
cid INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) DEFAULT NULL,
created INT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'publish',
type VARCHAR(16) NOT NULL DEFAULT 'post',
KEY idx_created (created),
KEY idx_type_status (type, status)
) ENGINE=InnoDB;执行普通 EXPLAIN:
EXPLAIN SELECT cid,title FROM typecho_contents
WHERE type='post' AND status='publish' ORDER BY created DESC LIMIT 20;你大概率看到 rows 列是几千、filtered 是 10%、Extra 写着 Using where; Using filesort。这个输出告诉你的是:优化器估计要扫几千行。但它不会告诉你:为什么是几千?filesort 到底排了多少行?索引为什么没被用上?
换成 EXPLAIN ANALYZE,输出变成这样(节选并加注释):
-> Limit: 20 row(s) (cost=1234 rows=20) (actual time=48.2..48.3 rows=20 loops=1)
-> Sort: created DESC, limit input to 20 row(s) per chunk
(cost=1234 rows=50000) (actual time=48.1..48.2 rows=20 loops=1)
-> Filter: ((status = 'publish') and (type = 'post'))
(cost=5000 rows=5000) (actual time=0.05..42.7 rows=200000 loops=1)
-> Table scan on typecho_contents
(cost=5000 rows=50000) (actual time=0.03..28.4 rows=500000 loops=1)这一下就清楚多了。actual ... rows=500000 告诉你实打实扫了 50 万行;cost ... rows=50000 是优化器原本估计的,差了整整 10 倍;Filter 那层的 actual time 从 0.05 涨到 42.7 毫秒,说明真正的耗时发生在过滤和排序,而不是在最后取 20 行。普通 EXPLAIN 永远给不出这些数字。
二、逐字段读法:记住三个数字和一个括号
输出的每一行都是一个「迭代器节点」,格式固定为:
-> 节点名 (cost=估计成本...) (actual time=实际耗时... rows=实际行数 loops=循环次数)cost 括号是优化器的预估,「估计要花多少代价、返回多少行」。这个数字可以骗你,尤其是统计信息过期时。
actual 括号是真实测量值,这是 EXPLAIN ANALYZE 的全部价值所在。其中:
actual time=首行耗时..末行耗时:第一个数字是返回第一行花的时间,第二个是返回全部行花的时间。首行耗时高(比如 40ms),说明这个节点要等很久才能出第一个结果——对 LIMIT 查询来说是致命的,因为用户等的是第一屏。rows:这个节点实际吐出了多少行。loops:这个节点被循环执行了多少次。这一列是最容易被忽略、也最常暴露问题的数字。
判定问题的方法很简单:比较 cost 里的 rows 和 actual 里的 rows。相差超过一个数量级,就说明优化器判断失误,通常源于统计信息不准。此时 ANALYZE TABLE typecho_contents; 往往立竿见影。
三、loops 隐藏的灾难:嵌套循环放大
看下面这段输出,这是一个典型的相关子查询或 JOIN 场景:
-> Filter: (c.cid > 100)
(actual time=0.2..3812.5 rows=180 loops=1)
-> Index lookup on u using idx_uid (uid = c.uid)
(actual time=0.05..0.09 rows=1 loops=49000)单看每一行都不慢:Index lookup 每次只要 0.09 毫秒。但 loops=49000 意味着这个「很快」的操作被执行了 4.9 万次,累计 4.4 秒。你在慢查询日志里看到的 3.8 秒,元凶就是这个内层循环。
这就是为什么不能只看单个节点的 actual time。真正的总成本要算 actual time × loops。诊断这类问题的顺序是:
- 从下往上找
loops大于 1 的节点。 - 算出每个节点的
末行耗时 × loops,找最大的那个。 - 如果内层是
Index lookup,说明索引没问题,问题在于被调用次数太多——通常要把相关子查询改写成 JOIN,或者用derived table先聚合再关联。 - 如果内层是
Table scan,那才是真的缺索引。
一个常见的改写:把 SELECT ..., (SELECT COUNT(*) FROM log WHERE log.uid=u.uid) FROM user u 这类写法,改成先 GROUP BY uid 聚合出计数再 LEFT JOIN。前者是 N 次子查询,后者是一次扫描加一次哈希关联,在几万用户规模下差别是秒级和毫秒级。
四、用 EXPLAIN ANALYZE 验证索引到底有没有生效
「我加了索引,为什么还是 filesort」是最多的疑问。用 EXPLAIN ANALYZE 一测就清楚。改一下表,加上复合索引:
ALTER TABLE typecho_contents ADD KEY idx_type_status_created (type, status, created);再跑同样的查询,如果输出变成了:
-> Limit: 20 row(s) (actual time=0.06..0.12 rows=20 loops=1)
-> Index lookup on typecho_contents using idx_type_status_created
(type='post', status='publish')
(actual time=0.05..0.10 rows=20 loops=1)注意三处变化:Index lookup 取代了 Table scan 加 Filter;Sort 节点整个消失了(因为索引第三列 created 已经有序,排序被索引本身满足);耗时从 48 毫秒降到 0.12 毫秒。
反过来,如果加了索引但输出里仍然有 Sort 节点,只有两种可能:索引列顺序不对(排序列不在索引末尾),或者查询里对排序列做了函数运算(如 ORDER BY FROM_UNIXTIME(created)),导致索引无法提供有序性。这两种都能从执行树上一眼看穿。
五、写查询别踩的三个陷阱
陷阱一:函数包住索引列,索引直接失效
-- 失效:索引 idx_created 用不上,只能全表扫描
SELECT cid FROM typecho_contents
WHERE FROM_UNIXTIME(created) > '2026-01-01';
-- 有效:把函数移到常量侧,走 created 索引范围扫描
SELECT cid FROM typecho_contents
WHERE created > UNIX_TIMESTAMP('2026-01-01');created 存的是 INT 时间戳,这一条在 Typecho 类站点上极其常见。凡是索引列被函数或表达式包住,优化器就只能退化成全表扫描,EXPLAIN ANALYZE 里会明明白白地出现 Table scan,且 actual rows 等于表总行数。
陷阱二:隐式类型转换让索引白建
如果 slug 是 VARCHAR,而你写 WHERE slug = 123(不带引号),MySQL 会把字符串列转成数字再比较,索引失效。反过来,如果列是 INT 而你传了字符串 '123',倒是能走索引,但如果是 usr_id = 'abc123def' 这种,转换后可能匹配出意外的数据。习惯是:给什么类型传什么类型,字符串一律带引号。
陷阱三:统计信息过期,优化器选错索引
InnoDB 的索引统计是采样估算的,innodb_stats_auto_recalc 默认只在表变化超过 10% 时触发。批量导入几万篇文章后,统计信息很可能还是旧的,优化器会按错误的行数估计选错索引。此时 EXPLAIN ANALYZE 的特征是:cost 里的 rows 估计和 actual 差出几十倍。解决方案是手动 ANALYZE TABLE typecho_contents;,或者调大采样页数:
SET GLOBAL innodb_stats_persistent_sample_pages = 64;
-- 查看当前统计信息
SELECT table_name, index_name, stat_value, last_update
FROM mysql.innodb_index_stats
WHERE table_name = 'typecho_contents';六、注意事项:EXPLAIN ANALYZE 会真的执行查询
这一点必须反复强调:普通 EXPLAIN 只是拿执行计划,不执行;EXPLAIN ANALYZE 会真的执行整条语句。
所以在生产环境上有三条纪律:
- 绝对不要对 UPDATE、DELETE、INSERT 直接跑 EXPLAIN ANALYZE,它会真的写入数据。MySQL 8.0.32 之后支持
EXPLAIN ANALYZE INTO搭配部分只读场景,但对写语句依然要格外小心。 - 先加 LIMIT 或收紧 WHERE 条件。如果知道某条查询会扫几百万行,先在测试环境或从库上跑,别在高峰期直接打生产主库。
- 用
FORMAT=TREE更易读,这是默认格式;如果需要贴到工单里,也可以用FORMAT=JSON获得带actual_rows、actual_loops的结构化数据,便于脚本化分析。
一个实用的日常做法:把慢查询日志里耗时最高的几条,用 EXPLAIN ANALYZE 逐条过一遍,重点关注 loops > 1 的节点和 Table scan 节点。这两类几乎覆盖了内容站 90% 的性能问题。掌握这个命令,你就不再需要靠猜来优化数据库了。
七、把 EXPLAIN ANALYZE 变成可比较的基线
单次测量只能告诉你「现在慢」,无法告诉你「改动有没有变快」。真正的做法是把执行树里的关键数字落成基线,每次改索引、改查询后重新测量并对比。一个简单可靠的采集方式是:
-- 把执行计划写进表,方便历史对比
CREATE TABLE explain_baseline (
id INT AUTO_INCREMENT PRIMARY KEY,
tag VARCHAR(64) NOT NULL,
plan MEDIUMTEXT NOT NULL,
created INT UNSIGNED NOT NULL,
KEY idx_tag_created (tag, created)
) ENGINE=InnoDB;
-- MySQL 8.0 支持把执行计划导出到变量再插入
EXPLAIN ANALYZE INTO @plan
SELECT cid,title FROM typecho_contents
WHERE type='post' AND status='publish' ORDER BY created DESC LIMIT 20;
INSERT INTO explain_baseline (tag, plan, created)
VALUES ('homepage-latest-20', @plan, UNIX_TIMESTAMP());
有了这张表,你就能在改索引前后各插一条,然后用脚本比对 actual time 的末位数字。这比「感觉快了」可靠得多。要注意 EXPLAIN ANALYZE INTO 是只读语句可用,写语句依然不建议在生产上执行。
另一个实用技巧是关注执行树里每一个节点的「行数漏斗」:最内层扫了多少行、经过 Filter 剩多少行、Join 之后又剩多少行。如果最内层扫 50 万行而最终只返回 20 行,这个漏斗就是断崖式的低效——理想的执行树应该是从下到上都在同一数量级,越往上越少,而不是出现几个数量级的骤降。这种「漏斗读法」能让你在几十秒内判断出问题到底出在扫描、过滤还是关联环节,比逐列研究 cost 更快。
八、一个完整的排查记录示例
把方法串成一条完整链路。假设首页偶发卡顿,慢查询日志里反复出现同一条语句,排查顺序如下:
- 确认是数据库的问题:先看 TTFB 拆解,如果 PHP 侧耗时占总响应的大部分,且 PHP 慢日志指向这条 SQL,才进入下一步。别把网络或 PHP-FPM 的问题误判成数据库问题。
- 跑 EXPLAIN ANALYZE:拿到执行树,先看根节点下面是不是
Table scan。如果是,直接跳到第 4 步。 - 检查 loops:找出所有
loops > 1的节点,计算末行耗时 × loops,排序后定位最大的那一层。这一层通常才是真凶,而不是看起来最耗时的根节点。 - 确认索引状态:
SHOW INDEX FROM typecho_contents;检查是否真有索引,以及列顺序是否匹配查询的等值列在前、范围/排序列在后。 - 核对统计信息:如果 cost 估计行数和 actual 实际行数差出一个数量级,先
ANALYZE TABLE再重测,不要急着加索引。 - 加索引或改写查询:优先改查询(去掉索引列上的函数、减少子查询、用 JOIN 替代嵌套),其次才是加索引。
- 回归验证:重新跑
EXPLAIN ANALYZE,确认出现Index lookup、Sort节点消失、耗时下降一个数量级,才算收工。
这套流程的核心价值是每一步都有客观数字支撑,排除了「凭经验猜测」带来的返工。个人站长往往只能一个人排查,越是这种时候,越需要工具给出确定性的答案,而不是靠感觉换来换去。数据库优化最怕的不是难题,而是在错误的方向上反复投入时间——EXPLAIN ANALYZE 正是帮你确认方向的那把尺子。