EXPLAIN 是每个站长都装过、但八成没真正读懂的东西。我在自己的站上就吃过这个亏:一条查询在测试库上 type=ref、rows=1,看起来完美,上线后慢查询日志里却天天出现,扫描行数几十万。后来才发现,测试库只有两千行数据,优化器认为走索引不如全表扫——同一句 SQL,在不同的数据量下执行计划完全不同。
这篇文章不讲索引原理,只讲一件事:怎么把 EXPLAIN 输出的每一列读成一句能行动的话。看完你应该能在十分钟内判断出一条慢查询该加索引、该改写、还是该换思路。
一、先把三种 EXPLAIN 分清楚
很多人不知道 EXPLAIN 其实有三个版本,输出完全不同:
EXPLAIN SELECT ...; -- 传统表格,估算值
EXPLAIN FORMAT=JSON SELECT ...; -- JSON,信息量最大
EXPLAIN ANALYZE SELECT ...; -- 8.0.18+,真正执行并给出实际耗时日常排查我用传统表格做初筛,因为它一眼能看出扫描顺序;要判断「估算和实际差多少」时必须用 EXPLAIN ANALYZE。后者的输出是树形结构,每个节点都带 actual time=... rows=...,能直接看到某一步实际返回了多少行、花了多少毫秒。
有一点必须提醒:EXPLAIN ANALYZE 会真实执行查询。对一条 UPDATE 或 DELETE 用它,数据会真的被改掉。要分析写操作,请先包在事务里做完再回滚,或者改写成等价的 SELECT。
二、type 列:只记住六档就够了
type 表示访问类型,从好到坏大致是:
system > const > eq_ref > ref > range > index > ALL实际排障时,我把它们分成三组来读:
放心的:const(主键或唯一索引等值命中一行)、eq_ref(联表时被驱动表用主键/唯一索引命中一行)、ref(普通索引等值命中若干行)。这三档在小表上基本不会出问题。
要看情况的:range 表示走了索引范围扫描,常见于 BETWEEN、>、IN、LIKE 'abc%'。它本身不坏,坏的是 range 覆盖的行数太多——如果 rows 显示几十万,说明这个索引的选择性不足,等于绕了一圈还是扫了大半张表。
要处理的:index(扫完整个索引树)和 ALL(扫完整张表)。注意 index 并不比 ALL 快多少,它只是「在索引上做全扫」,如果索引列不是覆盖索引需要的全部列,还要回表,往往比 ALL 更慢。
一个真实例子:文章列表页按 created 倒序取 10 条,SQL 里是 ORDER BY created DESC LIMIT 10。如果 created 上有索引,type 会是 index,看着「用了索引」,实际上是在索引上从头扫,遇到不符合 WHERE 条件的行就跳过,一直扫到凑够 10 条为止。当过滤条件很稀疏时,这一步能扫掉整个索引。此时正确的做法不是纠结 type,而是建一个把 WHERE 列放前面、created 放后面的联合索引。
三、key、key_len 与 possible_keys:索引到底用没用上
这三列要一起看:
possible_keys 是优化器「考虑过」的索引,key 是「最终选了」的索引,key_len 是「用了索引的前多少个字节」。三个结合起来,能定位出最常见的一类问题:联合索引只用了最左边一列。
举个具体数字。假设有联合索引 (user_id, status, created),其中 user_id 是 int(4 字节),status 是 tinyint(1 字节),created 是 datetime(5 字节,允许 NULL 是 6 字节)。如果 key_len 显示 4,说明只用了 user_id;显示 5 说明用到了 status;显示 10 说明三列都用上了。
为什么只用了第一列?无非几种:WHERE 里跳过了中间列、中间列用了范围条件(范围之后的列无法再用于定位)、或者中间列做了函数运算/隐式类型转换。最隐蔽的是隐式转换:字段是 varchar,查询里传了数字,MySQL 会把字段转成数字比较,索引直接失效,key_len 会显示成字段的最大长度(utf8mb4 下 varchar(32) 是 128+2=130),这一眼就能认出来。
四、rows 与 filtered:估算值的正确用法
rows 是优化器估算的「需要读多少行」,filtered 是这些行里「经过 WHERE 过滤后还剩百分之几」。两者相乘,才是这一步实际参与后续运算的行数。
很多人只看 rows 小就放心,这是错的。假设 rows=1000、filtered=0.1,意味着要读 1000 行才得到 1 行有效数据。反过来 rows=500、filtered=100 反而是理想状态。我习惯把 rows × filtered 在心里算一遍,超过一万就要开始怀疑索引设计。
另一个必须知道的点:rows 是估算,来源是 InnoDB 的采样统计,不是精确值。统计信息过期会让估算严重失真——明明只匹配 3 行,却估算成 30 万行,优化器于是「明智地」放弃索引去做全表扫。遇到「明明加了索引却不走」,第一件事是更新统计:
ANALYZE TABLE typecho_contents;
SHOW INDEX FROM typecho_contents;SHOW INDEX 里的 Cardinality 就是统计出的不同值个数,它如果和实际差异很大(比如一个只有 3 种取值的 status 字段却显示 Cardinality=30000),说明采样不准。这时 ANALYZE TABLE 往往能让执行计划立刻回正。
五、Extra 列:精华都在这里
Extra 是信息量最大的一列,我按「好消息 / 坏消息」分组记:
好消息
Using index——覆盖索引,不需要回表。这是最理想的状态,说明查询要的列全在索引里。如果一条高频查询拿到 Using index,基本可以不用再优化了。
Using index condition——索引下推(ICP)。MySQL 5.6 之后,被 WHERE 用到但不适合用来定位的索引列,可以在存储引擎层先过滤一遍再回表,减少回表次数。key_len 看不出它用了几列,但 Using index condition 说明多了一层过滤,是好现象。
坏消息
Using filesort——需要额外排序。注意它不一定是磁盘排序,小结果集在内存里排完也叫 filesort。真正要担心的是「排序的行数很大」:如果 ORDER BY 涉及的列不在索引顺序里,MySQL 要把结果全部取出来再排。解决办法是让索引顺序与 ORDER BY 一致,即「先按等值条件列、再按排序列」建联合索引。
Using temporary——用了临时表,常见于 GROUP BY、DISTINCT、UNION。临时表可能落盘,代价比 filesort 更高。看到它,优先检查 GROUP BY 的列是否和索引顺序一致。
Using join buffer (Block Nested Loop)——联表的被驱动表没有可用索引,只能用 join buffer 做块嵌套循环。这说明被驱动表的关联列缺索引,加一个索引通常能把整条查询从秒级拉到毫秒级。
六、一个完整案例:日志表的翻页查询
假设有一张访问日志表,页面要做「按日期范围 + 按状态码筛选,倒序分页」。最初的表结构只有一个自增主键,查询是这样的:
SELECT id, ip, uri, status, created
FROM access_log
WHERE created >= '2026-11-01' AND status = 404
ORDER BY id DESC
LIMIT 20 OFFSET 4000;第一次 EXPLAIN:type=ALL、rows=820000、Using where; Using filesort。全表扫加排序,深翻页时每次都要扫掉几万行。
第一反应是给 status 加索引。加完后 type 变成 ref,rows 降到 12000,但 Using filesort 还在——因为要按 id 排序,索引是 (status),排序列不在索引里。而且当 status 取值分布不均(404 只占 3%)时,rows 可能只有几万,但如果查的是 200 状态码,rows 直接回到八十万。
正确的索引是 (status, created, id) 还是 (created, status, id)?取决于哪个条件的过滤性更强、哪个会被写成范围。这里的 created >= 是范围条件,范围列之后的列无法用于定位,所以 (created, status, id) 中的 status 只能靠 ICP 过滤。而在 (status, created, id) 里,status 等值命中、created 范围扫描、id 用于排序,三段都能用上,是最优顺序。
建完之后 EXPLAIN:type=range、rows=1500、Extra=Using index condition,filesort 消失。id 作为索引的最后一列,天然按顺序排列,ORDER BY id DESC 可以直接反向读索引,不需要额外排序。
但深翻页的问题还在。LIMIT 20 OFFSET 4000 意味着要读出 4020 行再丢掉 4000 行。索引帮不上这个忙,只能改写法——用「记住上一页的最后一条 id」的方式做键集分页:
SELECT id, ip, uri, status, created
FROM access_log
WHERE status = 404 AND created >= '2026-11-01' AND id < 94020
ORDER BY id DESC
LIMIT 20;这条查询的 rows 会显示 20,一步到位。代价是不能跳页,只能上一页/下一页——但对日志列表这种场景,跳页本来就没意义。
七、EXPLAIN 看不出来的三类问题
讲到这里必须泼一盆冷水:EXPLAIN 只描述执行计划,不描述真实代价。以下三类问题它看不出来。
第一类是锁等待。执行计划再完美,如果行锁被别的事务占着,查询照样卡住。要看锁,得用 SHOW ENGINE INNODB STATUS 或 performance_schema.data_lock_waits,EXPLAIN 帮不上。
第二类是回表次数。Using index condition 看起来很美,但如果过滤后仍有几万行需要回表,随机 IO 的代价可能超过全表顺序扫。这时候该考虑把查询改成两步:先用覆盖索引取主键,再按主键批量取数据。
第三类是网络与结果集。一条 SELECT * 从大表取 5000 行,即使索引完美、耗时 20 毫秒,把 5000 行通过网络送到 PHP 再序列化成 JSON,可能花掉 200 毫秒。EXPLAIN 里的 rows 已经暗示了这一点,但很多人只看耗时不看行数。
八、把 EXPLAIN 变成日常动作
我现在的习惯是:任何新增的、带 WHERE 或 ORDER BY 的 SQL,在提交代码前都跑一次 EXPLAIN,只看三件事——type 是否在 ref 及以上、rows × filtered 是否小于一万、Extra 里有没有 filesort/temporary。三项都过关才上线。
对于已经上线的慢查询,从慢日志里捞出来之后,先跑 EXPLAIN ANALYZE,把「估算 rows」和「实际 rows」对一遍。差距超过十倍,八成是统计信息或者索引选择性出了问题,这时先去 ANALYZE TABLE,再谈改索引。这个顺序能避免很多无用功——我见过太多人一看到慢查询就加索引,结果加了五六个索引,写入反而更慢了,而真正的问题只是统计过期。