EXPLAIN 里 key 是 NULL,多半不是索引没建好,而是写法逼优化器放弃了它:函数运算、隐式转换、最左前缀、前置百分号、OR 不对等、回表成本过高------逐个排查,优先改写 SQL。
一条 SQL 的现场
WHERE 条件的列上明明有索引,EXPLAIN 出来 type=ALL、key=NULL、rows 七位数。第一反应通常是"索引失效了,重建一个"。
重建完还是不走。
索引失效从来不是单一原因,它是一串因果链:要么 SQL 让索引根本用不上,要么优化器算了一下觉得用索引更亏。 这两类问题的修法完全不同------前者改 SQL,后者改索引,或者干脆接受全表扫描。
possible_keys 才是分水岭
下面这张图是我实际用的排查路径,从上往下走,每一步都能排除掉一批可能。
possible_keys 为空,说明索引根本没资格参与;它有值但 key 为空,才轮到写法或成本估算背锅。先把这两拨问题分开,方向就不会跑偏。
索引压根没建对
最常见也最冤枉的一种:条件里用的是 B、C 两列,手上却是一个单列索引,或者字段顺序不匹配的联合索引。
修法只有一句------重新建一条单独的索引,或者按查询形态重建联合索引。 没有别的技巧。
函数一包,索引就废
sql
-- 索引失效:create_time 被函数包住了
SELECT * FROM t_order WHERE DATE(create_time) = '2024-05-01';
-- 改成范围查询,索引可用
SELECT * FROM t_order
WHERE create_time >= '2024-05-01' AND create_time < '2024-05-02';
B+ 树里存的是 create_time 的原始值,不是 DATE(create_time) 的结果。函数一包,索引列就退化成表达式,MySQL 只能逐行算。 通用解法是把运算挪到常量那一侧。
隐式类型转换,Java 工程师的重灾区
sql
-- phone 是 varchar,传数字会触发隐式转换
SELECT * FROM t_user WHERE phone = 13800000000; -- 全表扫描
SELECT * FROM t_user WHERE phone = '13800000000'; -- 走索引
数据库会自动把字段侧转成数字来比较,等价于对索引列做了一次函数运算。两边数据类型保持一致,问题就消失了。
这类坑在 Java 里特别容易踩,因为 Long 参数和 varchar 列看起来"都是数字"。等号两边类型不一致,优化器不跟你讲情面。
联合索引被跳过了最左列
sql
CREATE INDEX idx_a_b_c ON t_order (a, b, c);
SELECT * FROM t_order WHERE a = 1 AND b = 2; -- 命中
SELECT * FROM t_order WHERE b = 2 AND c = 3; -- 跳过 a,不命中
联合索引按 A、B、C 的顺序层层排序,跳过 A 直接查 B,等于在一本没按 B 排序的目录里找 B。建索引时按业务查询频率排字段顺序,高频等值列放最左边。
前置百分号,MySQL 的硬边界
sql
SELECT * FROM t_user WHERE name LIKE '%小明'; -- 前置模糊,索引失效
SELECT * FROM t_user WHERE name LIKE '小明%'; -- 后置模糊,正常走索引
前缀不确定,B+ 树就定位不到起点,只能全扫。后置百分号可以用上索引,前置的不行。 真需要前置模糊,那是全文检索的活儿,该上 ES 就上 ES,别在 MySQL 上硬扛。
OR 两侧不对等,一起完蛋
sql
-- age 有索引,city 没有
SELECT * FROM t_user WHERE age = 18 OR city = '杭州';
-- 拆成 UNION,让有索引的一侧走索引
SELECT * FROM t_user WHERE age = 18
UNION
SELECT * FROM t_user WHERE city = '杭州';
OR 的语义是"两边都要查",只要有一侧无索引,整体就只能全表扫描才有正确结果。要么两侧都建索引,要么拆成 UNION 改写。
优化器主动放弃:不报错,也不违规
这一条最容易被误判成"索引失效",其实索引好好的,是优化器不想用。
筛选之后返回的数据量占整张表超过 20% 时,优化器会判断:走二级索引要不停回表,随机 IO 的开销已经高于直接顺序扫全表。于是它主动放弃索引。
注意这里是估算 ,不是精确值。所以统计信息一偏,执行计划就可能翻车------这也是排查步骤里必须有一句 ANALYZE TABLE 的原因。
应对方式是构建覆盖索引,让查询需要的列全在索引里,把回表这个动作干掉:
sql
CREATE INDEX idx_status_time_user ON t_order (status, create_time, user_id);
EXPLAIN SELECT status, create_time, user_id FROM t_order WHERE status = 1;
四步收口
EXPLAIN看执行计划,重点看type、possible_keys、key、rows- 对比
possible_keys和实际的key,判断是"没资格用"还是"能用但不用" ANALYZE TABLE t_order;更新统计信息,排除统计偏差导致的误判- 评估实际返回的数据量占全表比例,判断优化器的选择是否合理
sql
EXPLAIN SELECT * FROM t_order WHERE status = 1;
+----+-------+---------+------+---------------+------+---------+------+---------+-------------+
| id | type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------+---------+------+---------------+------+---------+------+---------+-------------+
| 1 | SIMPLE| t_order | ALL | idx_status | NULL | NULL | NULL | 1200000 | Using where |
+----+-------+---------+------+---------------+------+---------+------+---------+-------------+
速查表
| 现象 | 根因 | 修法 |
|---|---|---|
| 索引列被函数包住 | 索引存的是原值 | 运算挪到常量侧 |
varchar 列传数字 |
隐式类型转换 | 参数加引号,类型对齐 |
| 联合索引只用后几列 | 违反最左前缀 | 调整索引字段顺序 |
LIKE '%x' |
无法定位前缀 | 改后置,或上 ES |
OR 一侧无索引 |
整体放弃索引 | 两侧建索引或拆 UNION |
| 走全表比走索引便宜 | 回表成本高于全扫 | 建覆盖索引 |
只会重建索引的人,和会逐层排查的人
菜鸟遇到索引失效,只会两招:重建索引、FORCE INDEX 强制干预执行计划。
高手会从函数运算、隐式类型转换、最左前缀、模糊查询、优化器决策这几个层面逐层排查,优先改写 SQL,而不是粗暴地干预执行计划。
区别在于:FORCE INDEX 只是把问题按下去,数据量一涨、统计信息一变,坑还在那里;改写 SQL 是让优化器自己愿意选索引,这才稳。
写在最后
这套排查顺序里,我个人觉得最容易翻车的是第 7 条------因为它不报错、不违背任何语法规则,只是优化器算了一笔账。你们线上有没有遇到过那种"昨天还走索引,今天就 ALL 了"的查询?最后是靠覆盖索引解决的,还是干脆改了分页策略?评论区聊聊。
有用的话点个赞。