一条慢 SQL 的执行计划里,最容易被跳过的三行
一条慢 SQL 的执行计划
线上一条查询慢到 3 秒,拿 EXPLAIN 一看:
+----+-------+------+---------------+---------+---------+----------+----------+-------------+
| id | type | key | possible_keys | key_len | ref | rows | filtered | Extra |
+----+-------+------+---------------+---------+---------+----------+----------+-------------+
| 1 | ref | idx_uid | idx_uid | 8 | const | 482193 | 100.00 | Using where |
+----+-------+------+---------------+---------+---------+----------+----------+-------------+
走了索引、type=ref、filtered=100,看起来"没毛病"。但实际上:
rows=482193——它要扫 48 万行;Extra里没有Using index——意味着每一行都要回表;- 没有
Using filesort也没有Using temporary——排序和去重走的是索引顺序,这一点倒是没问题。
问题在 rows。走索引不等于走了"好"索引,扫描行数才是代价的直接体现。
EXPLAIN 里有几行参数最容易被跳过,而它们恰好决定了性能:
第一行:type 的等级差
从好到坏(常见值):
system > const > eq_ref > ref > range > index > ALL
几个容易误判的:
ref不等于没问题。ref只说明用了非唯一索引做等值匹配,实际扫多少行看rows。上面那条ref扫了 48 万行,和全表扫描差不多,只是走了 B+ 树而不是顺序读。index比ALL好不了多少。index是"扫整个索引树",如果索引包含的列很多,它扫的数据量可能比全表扫描还大。看到type=index基本可以判定索引没被有效利用。range要分清范围大小。WHERE create_time > '2026-01-01'和WHERE id BETWEEN 1 AND 10都是range,但前者可能扫 90% 的行。还是要看rows。
一个经验判断:rows 超过表总行数的 20%,优化器就该考虑全表扫描了。如果它还固执地走索引,通常是因为回表代价估计得不准,或者 ORDER BY 恰好能用上这个索引的顺序。
第二行:filtered 是乘出来的
很多人以为 filtered 是"过滤掉多少",其实它是"经过这层条件后还剩多少百分比",而且它和 rows 是相乘关系:
实际参与下一步的行数 ≈ rows × filtered / 100
filtered=100 就意味着"这个索引条件筛完,一个都没筛掉"。上面那条 SQL 的 WHERE uid = ? AND status = 1,如果用 idx_uid,uid 筛完剩 48 万行,status = 1 这层才在 Using where 里做过滤——所以 filtered 是 100 是合理的,因为索引根本没管 status。
这就指向了真正的优化点:把 status 加进索引,变成 idx_uid_status(uid, status)。此时 key_len 会变长(多一个字段),rows 直接降到实际的 status=1 行数。
顺带说 key_len 的读法:它告诉你"索引用了几个字节",可以用来判断复合索引用到了第几列。比如 idx_a_b_c,a 是 int(4 字节,可空再加 1),如果 key_len 只有 4,说明只用到第一列,b 和 c 白建了。
第三行:Extra 里的四个关键词
Extra 字段是信息量最大的地方,重点看这四个:
| Extra | 含义 | 要警惕吗 |
|---|---|---|
Using where |
存储引擎返回行后,Server 层还要再过滤 | 看 rows 和 filtered,可能要改索引 |
Using index |
覆盖索引,不回表 | 好事 |
Using index condition |
索引下推(ICP),在引擎层过滤 | 好事 |
Using filesort |
需要额外排序 | 看数据量,可能要加排序字段到索引 |
Using temporary |
用了临时表 | 通常要改(GROUP BY / DISTINCT / UNION) |
两个容易被误解的:
Using index 才是"不回表",不是"用了索引"。 只要走了索引,key 列就有值;Using index 专指"查询的所有列都在索引里",不需要回表取数据。它的性能差异是数量级的——回表是随机 IO,覆盖索引是顺序读索引树。
Using filesort 不代表用了磁盘文件。 名字有误导性,它可能全在内存里完成(sort_buffer_size 内)。真正要关注的是排序的数据量:rows 很大 + Using filesort,才需要处理。
索引失效的六个真实场景
这一节的每一条都能用 EXPLAIN 验证,不必背,看到执行计划里 key=NULL 或 type=ALL 就对应上了。
① 列上做了函数或运算
-- 失效
SELECT * FROM orders WHERE DATE(create_time) = '2026-11-09';
-- 有效
SELECT * FROM orders WHERE create_time >= '2026-11-09 00:00:00'
AND create_time < '2026-11-10 00:00:00';
原因是 B+ 树按列的原始值排序,函数值没有顺序,没法二分查找。这也是为什么"按天统计"的查询一定要写成范围区间。
② 隐式类型转换
-- phone 是 varchar,下面这个会发生转换,索引失效
SELECT * FROM users WHERE phone = 13000000000;
-- 正确
SELECT * FROM users WHERE phone = '13000000000';
判断方向很重要:字符串列和数字比较时,MySQL 把字符串转成数字,转换发生在列上,索引失效。反过来,如果列是 int 而参数是字符串 '123',MySQL 把参数转成数字,索引正常。所以"数字列传字符串没问题,字符串列传数字就完蛋"。
③ 前导通配符
WHERE name LIKE '%abc' -- 失效
WHERE name LIKE 'abc%' -- 有效
% 在最左边就没法用索引有序性。真实场景里"搜关键词"的需求,正确解法是全文索引(FULLTEXT)或外部搜索引擎,不要指望 LIKE '%...%'。
④ 复合索引的最左前缀断裂
索引 idx_a_b_c(a, b, c):
WHERE a = 1 AND b = 2 AND c = 3 -- 全用上
WHERE a = 1 AND c = 3 -- 只用上 a(c 用不上,因为 b 断了)
WHERE b = 2 AND c = 3 -- 完全用不上
WHERE a = 1 AND b > 2 AND c = 3 -- a、b 用上,c 因为范围查询而失效
最后一条是坑:范围查询后面的列用不上索引(8.0 的索引跳跃扫描 skip scan 有改善,但依赖统计信息,别指望)。
⑤ OR 连接了不同列
WHERE a = 1 OR b = 2
如果 a 和 b 上分别有索引,优化器可能选择 index_merge(type=index_merge),但这种情况性能往往不如预期,很多团队直接禁用它。更常见的处理是拆成两条 SQL 用 UNION,或者建一个包含两列的复合索引(如果业务允许)。
⑥ 排序字段和索引顺序不一致
SELECT * FROM t WHERE a = 1 ORDER BY b DESC, c ASC;
要求索引是 (a, b DESC, c ASC) 或者 (a, b, c) 且 ORDER BY 方向一致(MySQL 8.0 支持降序索引,8.0 之前混合方向一定 filesort)。这种问题 EXPLAIN 里表现为 Using filesort,rows 不大时容易被忽略,等数据量涨上来才暴露。
两个经常被忽略的执行计划细节
细节一:EXPLAIN 不执行 SQL,EXPLAIN ANALYZE 会。
EXPLAIN 给的是优化器的估算,rows 和 filtered 都可能差几个数量级(尤其统计信息没更新时)。MySQL 8.0.18+ 支持:
EXPLAIN ANALYZE SELECT ...;
输出里会给出每个节点的 actual rows 和 actual time,和估算值对照,能立刻看出"哪个环节估错了"。这是排查慢 SQL 最快的手段——估错通常意味着统计信息陈旧,ANALYZE TABLE 一下可能就好了。
细节二:统计信息是会过期的。
information_schema.tables 里有 data_length、index_length,但更关键的是采样。InnoDB 默认 innodb_stats_persistent=ON,每个索引采样 20 个页(innodb_stats_persistent_sample_pages)。大表上 20 个页可能完全代表不了真实分布——这就是"同一条 SQL 昨天 50ms、今天 3 秒"的常见原因之一。
定期 ANALYZE TABLE,或者调大采样页数(代价是采样的时间变长)。
深分页:不是索引问题
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
这条即使走了主键索引也很慢,因为它要扫 100 万行然后丢掉。EXPLAIN 里看起来一切正常(type=index、rows=1000020),问题在语义上。
三种解法,按适用性排序:
- 游标式分页(最推荐):
WHERE id > 上次最大 id ORDER BY id LIMIT 20,翻页成本恒定; - 子查询延迟关联:
SELECT o.* FROM orders o JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000,20) t USING(id),子查询走覆盖索引,只在最后 20 行回表; - 限制最大页数(产品层面的妥协)。
一条能落地的检查顺序
拿到一条慢 SQL,我会按这个顺序看,不跳步:
EXPLAIN看type和rows—— 是不是压根没走索引,或者走了但扫得太多;- 看
Extra有没有Using filesort/Using temporary—— 有没有额外排序和临时表; - 看
key_len—— 复合索引到底用到了第几列; - 上用
EXPLAIN ANALYZE对照估算与实际 —— 判断是不是统计信息的问题; - 才动手改索引或改写 SQL。
跳过前三步直接加索引,是常见的浪费时间方式:索引加了一堆,写入变慢、磁盘变大,查询还是那样——因为瓶颈可能根本不在索引上,而在排序的数据量或者回表的随机 IO。
- 点赞
- 收藏
- 关注作者
评论(0)