大家好,我是小富。
《十万个why》系列持续更新中
线上慢查询告警,捞出来一条 SQL 一看:有索引,explain 的 type 是 ref,key 里也确实命中了索引。按道理走了索引不应该慢啊?
但这条查询跑了 8 秒。
很多人遇到慢查询的第一反应是"索引没走",然后一看 explain 发现走了索引,就懵了——走了索引还慢,那是啥问题?
走了索引 ≠ 查得快
explain 的 type=ref 只是说明查询用了非唯一索引进行等值匹配。但这并不意味着查询扫描的行数少。
先看这个场景:
-- 订单表,1000 万条数据
-- status 字段有索引:idx_status
SELECT * FROM orders WHERE status = 2;
EXPLAIN 结果:
type: ref
key: idx_status
rows: 4500000
Extra: NULL
type=ref,key=idx_status,确实走了索引。但 rows=4500000——这条索引命中了 450 万行。
status 字段的值分布:
| status | 含义 | 占比 |
|---|---|---|
| 1 | 待支付 | 5% |
| 2 | 已支付 | 45% |
| 3 | 已发货 | 30% |
| 4 | 已完成 | 20% |
status=2 覆盖了表里 45% 的数据。MySQL 走了索引,然后用这个索引匹配到了 450 万条记录,再逐条回表去聚簇索引里取完整行数据。
450 万次回表,每次都是随机 IO。这比全表扫描(顺序 IO)还慢。
索引的"选择性"决定了它有多大用
索引的选择性(Selectivity)= 不同值的数量 / 总行数。
SELECT COUNT(DISTINCT status) / COUNT(*) FROM orders;
-- 结果:4 / 10000000 = 0.0000004
status 字段只有 4 个不同的值,选择性极低。对这种字段建索引,每个值对应几百万行数据,索引几乎起不到缩小范围的作用。
对比一下:
SELECT COUNT(DISTINCT order_no) / COUNT(*) FROM orders;
-- 结果:10000000 / 10000000 = 1.0
order_no 的选择性是 1.0(每个值唯一),对它建索引能精确定位到一行。
索引的价值 = 能帮你过滤掉多少数据。如果一个索引命中了 45% 的行,它等于没怎么过滤。
MySQL 优化器有时会"聪明地"选错索引
有些情况更坑:MySQL 明明有更好的索引可以用,却偏偏选了一个更差的。
-- 联合索引 idx_user_status (user_id, status)
-- 单列索引 idx_created (created_at)
SELECT * FROM orders
WHERE user_id = 12345
AND status = 2
AND created_at > '2025-01-01'
ORDER BY created_at DESC
LIMIT 20;
理论上应该走 idx_user_status,用 user_id + status 过滤后只剩几十条,再排序非常快。
但优化器可能选了 idx_created,因为它觉得按 created_at 排序可以避免文件排序(filesort),走 idx_created 扫描出来的数据天然有序。
然而走 idx_created 意味着要扫描大量不满足 user_id=12345 AND status=2 条件的行,边扫描边过滤,直到凑够 20 条。如果满足条件的数据很稀疏,可能要扫描几十万行才能凑够 20 条。
EXPLAIN 结果:
type: range
key: idx_created
rows: 500000
Extra: Using where
走了索引,type=range,看起来没问题。但 rows=500000,实际扫描了 50 万行,只取出 20 条。
回表才是真正的杀手
SELECT * 是慢查询的常见帮凶。当你 SELECT * 的时候,二级索引上没有全部列的数据,必须拿着主键 ID 回聚簇索引取完整行。
如果查询命中了 10 万行,就要回表 10 万次。二级索引是连续的(索引页内有序),但聚簇索引上这 10 万行的物理位置是分散的,所以回表是随机 IO。
当回表的行数超过一定比例(通常是 10% ~ 30%),MySQL 优化器会认为全表扫描(顺序 IO)比大量随机回表更快,直接放弃索引。但如果刚好在临界值附近,优化器可能做出错误的判断。
排查和解决
第一步:看 explain 的 rows 和 filtered
EXPLAIN SELECT * FROM orders WHERE status = 2 AND user_id = 12345;
type: ref
key: idx_status
rows: 4500000
filtered: 0.01
rows=4500000 说明走了索引但匹配行数太多,filtered=0.01 说明只有 0.01% 的行满足全部条件。这明显选错了索引——应该走能更精确过滤的索引。
第二步:用 FORCE INDEX 验证
SELECT * FROM orders FORCE INDEX (idx_user_status)
WHERE user_id = 12345 AND status = 2 AND created_at > '2025-01-01'
ORDER BY created_at DESC LIMIT 20;
如果强制走另一个索引后快了几十倍,说明优化器确实选错了。
第三步:建更合适的索引
-- 覆盖查询条件和排序的联合索引
ALTER TABLE orders ADD INDEX idx_user_status_created (user_id, status, created_at);
这个索引同时覆盖了 WHERE 条件和 ORDER BY,MySQL 不需要回表就能完成过滤和排序。
**第四步:干掉 SELECT ***
-- 只查需要的列,能走覆盖索引更好
SELECT id, order_no, amount, created_at FROM orders
WHERE user_id = 12345 AND status = 2
ORDER BY created_at DESC LIMIT 20;
总结
| 情况 | explain 表现 | 实际问题 |
|---|---|---|
| 索引选择性太低 | type=ref, rows=几百万 | 索引命中太多行,等于没过滤 |
| 优化器选错索引 | type=range, rows=几十万 | 有更好的索引但没选 |
| 大量回表 | Extra 没有 Using index | SELECT * 导致每行都要回表 |
| 索引条件和排序冲突 | Extra: Using filesort | 优化器在过滤和排序之间二选一 |
走了索引不等于查得快。索引的价值在于"过滤能力"——能过滤掉多少不需要的行。 如果一个索引命中了表里大部分数据,它只是在以随机 IO 的方式做了一次全表扫描,比不走索引还慢。
我是小富,下期见。
