EXPLAIN 里显示用了索引,不代表索引生效了
100 万行订单表,InnoDB,数据 248.8 MB。下面每个数字都是实测(取三次最好成绩)。
先看索引值多少钱
makefile
建 4 个二级索引耗时 7.9 s
索引体积: 0 MB -> 97.2 MB (数据本身 248.8 MB)
索引占了数据体积的 39%。 这是买来的东西的价格,下面看它值不值。
有无索引的差距
ini
无索引 有索引
按 uid 精确查 433.7 ms type=ALL 0.3 ms type=ref 扫描 2 行
按 created 排序取前10 401.1 ms filesort 0.2 ms type=index 扫描 10 行
1446 倍 和 2000 倍。这部分没什么悬念。
但同一组测试里有一行很值得说:
lua
按 status+city 查 LIMIT 20 无索引 0.8 ms ← ?
全表扫描,却只要 0.8 毫秒。
因为 status=2 AND city='Hangzhou' 匹配了 2.5 万行,密度极高,全表扫描跑了几千行就凑齐 LIMIT 20 然后提前退出了。
LIMIT+ 高频值会让全表扫描看起来无害。 等某天换成一个低频条件------或者用户翻到第 500 页------它立刻变回 400 毫秒。
压测时用的测试数据分布,往往就是这么骗过所有人的。
覆盖索引:少回一次表,快一倍
索引 idx_sc = (status, city, amount),查同样 25038 行:
sql
SELECT status,city,amount ... 120.2 ms Extra: Using index ← 覆盖
SELECT status,city,amount,payload ... 235.7 ms Extra: Using index condition ← 回表
只多取了一个 payload 字段,慢了一倍。
因为二级索引的叶子节点只存索引列 + 主键。要拿 payload,就得拿着主键回聚簇索引再查一次------25038 行就是 25038 次回表,而且这些主键是乱序的,对应的是随机 IO。
Extra 里的 Using index 就是「覆盖索引,不用回表」的标志。看到它,说明这个查询已经很便宜了。
实践含义很直接:别写 SELECT * 。不是为了省网络带宽,是为了让索引能覆盖。
最左前缀:key 不是 NULL,不等于索引生效
这是我认为最值得写出来的一组:
perl
条件 耗时 type key 优化器估算扫描行数
用 status (最左列) 41.7 ms ref idx_sc 414,460
用 status + city 6.5 ms ref idx_sc 52,634
只用 city (跳过最左) 197.0 ms index idx_sc 984,250 ← 注意
只用 amount (第三列) 179.1 ms index idx_sc 984,250 ← 注意
后两行的 key 栏明明写着 idx_sc。很多人看到这里就放心了:「索引用上了。」
但 type=index 的含义是 全索引扫描 ------把整棵索引树从头到尾撸一遍,98 万行一行不落,只是恰好不用回表而已。它比全表扫描快一点(因为索引比数据小),但和 type=ref 的 6.5 毫秒差了 30 倍。
看 EXPLAIN 要先看 type,不是 key。 记住这个由好到坏的顺序:
sql
system > const > eq_ref > ref > range > index > ALL
↑ 分界线
range 以上算正常,index 和 ALL 都意味着全量扫描。
索引失效的三种姿势,明码标价
ini
写法 耗时 type 相对正常写法
phone='13812345678' 0.2 ms ref 基准
phone=13812345678 ← 数字 213.5 ms index 慢 1068 倍
LIKE '138123%' 0.6 ms range 正常
LIKE '%2345678' 235.1 ms index 慢 392 倍
uid = 12345 0.2 ms ref 基准
uid+0 = 12345 147.2 ms index 慢 736 倍
第二行是最阴的一个。 phone 是 VARCHAR,你传了个数字。
SQL 不会报错,结果也完全正确。但 MySQL 的类型转换规则是:字符串和数字比较时,把字符串转成数字 。于是它必须对每一行执行 CAST(phone AS DOUBLE) 再比较------索引树的有序性建立在字符串排序上,转换之后完全用不上了。
慢 1068 倍,零报错,零告警。 ORM 里参数类型写错、JSON 里 "13812345678" 变成 13812345678,都能触发。
第三条同理:只要在索引列上套了函数或做了运算(uid+0、DATE(created)、UPPER(name)),索引就废了。改写方式是把运算挪到常量侧:
sql
sql
WHERE created >= 1700000000 AND created < 1700086400 -- ✅
WHERE DATE(created) = '2026-09-23' -- ❌
索引不是免费的
bash
写入吞吐 有 4 个二级索引 45,132 行/s
无二级索引 66,948 行/s
每插一行,4 个索引都要各自维护一次 B+ 树。吞吐掉了 33%。
再加上前面那 97.2 MB 的体积------它会一直占着 buffer pool,挤掉本该缓存热数据的空间。
所以「把可能用到的列都建上索引」是错的。没有查询在用的索引,是纯粹的负债。
MySQL 8 可以先用 ALTER TABLE ... ALTER INDEX xxx INVISIBLE 让索引对优化器隐身,观察几天没问题再删------比直接 DROP 安全得多。
一份自查清单
- 看
type,不看key。index和ALL都是全扫描。 Extra里出现Using index是好事(覆盖索引),出现Using filesort/Using temporary要警惕。- 别
SELECT *。 它让覆盖索引失效。 - 检查参数类型。 字符串列一定要传字符串。
- 联合索引按「等值在前、范围在后、区分度高的在前」排列,并确认查询用到了最左列。
LIKE只有前缀能用索引。 需要中缀搜索就上全文索引或专门的搜索引擎。- 定期清理没人用的索引。
sys.schema_unused_indexes能直接告诉你哪些是负债。
最后
索引的直觉很容易停留在「建了就快」。但这次实测里,有索引却慢 200 毫秒的情况出现了四次 ,而每一次 EXPLAIN 的 key 栏都写着索引的名字。
索引存在,和索引生效,是两件事。