MySQL索引失效的8种典型场景与优化实战
作者:xixiaoyunya
摘要:线上慢查询里,十有八九是“索引建了却没生效”。本文不讲空洞理论,直接从 EXPLAIN 出发,还原 8 种最常见的索引失效现场:函数/表达式包装、隐式类型转换、前导模糊匹配、违反最左前缀、OR 连接、!= 与 IS NOT NULL、联合索引排序错位、统计与范围导致的回表爆炸。每种场景都给出可复现的 SQL、执行计划解读和改造方案,最后附一套可直接照做的排查清单。
关键词:MySQL;索引失效;EXPLAIN;慢查询优化;最左前缀;回表
一、先说清楚一件事:索引不是建了就一定用
很多人遇到慢查询的第一反应是“加索引”,结果索引加了,rows 没降,type 还是 ALL 或 index,QPS 照样被打爆。原因通常只有两个:
- 优化器算下来觉得全表扫描更便宜(数据量小、选择性差、回表成本太高);
- 你的写法让索引根本没法用(这是本文重点,也是最好修的)。
判断到底属于哪一种,唯一可靠的入口就是 EXPLAIN(或 EXPLAIN ANALYZE)。看这四个字段就够:
| 字段 | 含义 | 好信号 | 坏信号 |
|---|---|---|---|
type | 访问类型 | const / eq_ref / ref / range | ALL(全表)、index(全索引扫描) |
key | 实际用到的索引 | 出现你预期的索引名 | NULL |
rows | 预估扫描行数 | 远小于表总行数 | 接近总行数 |
Extra | 额外信息 | Using index(覆盖索引) | Using filesort、Using temporary、Using where(配合 key=NULL 时) |
提醒:EXPLAIN 的 rows 是估算值,统计信息过期时会严重失真。排查前先执行 ANALYZE TABLE 表名;,这一步能省掉一半的误判。
二、8 种索引失效现场,逐个击破
以下示例统一使用这张表(数据量按百万级理解):
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, amount DECIMAL(10,2), create_time DATETIME NOT NULL, remark VARCHAR(100), KEY idx_user_status_time (user_id, status, create_time), KEY idx_order_no (order_no), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
场景 1:对索引列做函数或表达式运算
-- ❌ 想查 2024 年所有订单 SELECT * FROM t_order WHERE DATE_FORMAT(create_time, '%Y-%m') = '2024-01';
create_time 上明明有 idx_create_time,但 key 为 NULL、type=ALL。
原因:B+ 树是按列的原始值有序的。一旦套上函数,优化器拿不到可以直接比较的键值,只能逐行计算后过滤。
-- ✅ 改成范围查询,走索引 range SELECT * FROM t_order WHERE create_time >= '2024-01-01' AND create_time < '2024-02-01';
同理要避开的写法:WHERE YEAR(create_time)=2024、WHERE user_id + 1 = 10001、WHERE LEFT(order_no,6)='ORD001'、WHERE amount*100 > 5000。
一句话原则:索引列必须单独出现在比较运算符的一侧,干干净净,不能参与运算。
场景 2:隐式类型转换(最容易踩、最难发现)
-- user_id 是 BIGINT,却传了字符串 SELECT * FROM t_order WHERE user_id = '10001'; -- order_no 是 VARCHAR,却漏了引号(数字型字符串) SELECT * FROM t_order WHERE order_no = 20240001;
原因:MySQL 会按类型优先级把列的值转成目标类型再比较,等价于 WHERE CAST(user_id AS CHAR) = '10001'——又回到了场景 1。字符串列被转成数字时,还会导致 '10001a' 这类脏数据被误匹配。
-- ✅ 参数类型与字段类型严格一致 SELECT * FROM t_order WHERE user_id = 10001; -- 数字对 BIGINT SELECT * FROM t_order WHERE order_no = '20240001'; -- 字符串对 VARCHAR
自查方法:SHOW WARNINGS; 能看到转换提示;或者用 EXPLAIN FORMAT=JSON,里面会出现 "attached_condition" 带 cast 的痕迹。ORM 框架里尤其要注意:Java 的 String ↔ varchar、Long ↔ bigint 别乱配,MyBatis 的 #{} 别写成 ${} 拼接。
场景 3:LIKE '%关键字'前导通配符
SELECT * FROM t_order WHERE order_no LIKE '%A123'; -- ❌ 全表扫描 SELECT * FROM t_order WHERE order_no LIKE 'ORD%A123'; -- ✅ 走索引 range
B+ 树依赖左前缀做定位,左边不确定就无法定位起点。解决办法按成本从低到高:
- 业务允许就改成后置通配
LIKE 'prefix%'(搜索框自动补全就是这个思路); - 必须全文检索 → 用 MySQL 全文索引(中文效果一般)或 Elasticsearch;
- 只是“后缀匹配”且字段固定长度 → 可以冗余一个反转字段并建索引,查
REVERSE(val) LIKE REVERSE('%xxx'); - 小表、低频后台查询 → 接受全表扫描,别过度优化。
场景 4:违反联合索引的最左前缀原则
idx_user_status_time (user_id, status, create_time) 实际上等价于三把索引:(user_id)、(user_id,status)、(user_id,status,create_time)。
WHERE user_id = 1 -- ✅ 用到 1 列 WHERE user_id = 1 AND status = 0 -- ✅ 用到 2 列 WHERE user_id = 1 AND status = 0 AND create_time > '2024-01-01' -- ✅ 全部用到 WHERE status = 0 -- ❌ 跳过前导列,索引直接废掉 WHERE user_id = 1 AND create_time > '...' -- ⚠️ 只用到 user_id,create_time 无法用于定位
两个高频追问:
Q1:条件顺序写错了会失效吗? 不会。优化器会重排,WHERE status=0 AND user_id=1 一样能用到两列。失效的是“缺了前导列”,不是“书写顺序”。
Q2:范围查询后面的列为什么不能用? 因为范围(>、<、BETWEEN、LIKE 'xx%')会把匹配到的行在该列上展开成一片区间,后面列的全局有序性被破坏,无法继续用于定位——但注意,它还能用于覆盖索引减少回表,以及 MySQL 8.0 里可能用于 Using filesort 的优化。
-- ⚠️ status 只能用于过滤,不能用于排序 EXPLAIN SELECT * FROM t_order WHERE user_id = 1 AND status = 0 AND create_time > '2024-01-01' ORDER BY create_time; -- Extra: Using index condition(ICP)
缺口怎么补:如果大量查询是“按时间查某用户”,单独补一个 (user_id, create_time);如果是“只按状态查”,就要评估是否值得单建索引(低选择性列单建索引通常没用,见场景 7)。
场景 5:OR连接的条件里有任一列没索引
SELECT * FROM t_order WHERE user_id = 10001 OR remark = '加急'; -- ❌ 全表
只要 OR 一侧无法用索引,MySQL 就会退化成全表扫描(union 语义要求统一处理)。
-- ✅ 拆成 UNION ALL 手动合并,两边各自走索引 SELECT * FROM t_order WHERE user_id = 10001 UNION ALL SELECT * FROM t_order WHERE remark = '加急' AND user_id <> 10001;
注意去重需求用 UNION(自带 DISTINCT,有排序开销),确认无交集时用 UNION ALL 更快。另外 IN 列表是安全的:WHERE user_id IN (1,2,3) 会转成等值多路匹配,不会失效。
场景 6:!=、NOT IN、IS NOT NULL导致放弃索引
SELECT * FROM t_order WHERE status != 0; -- ❌ 大概率全表 SELECT * FROM t_order WHERE status NOT IN (0,1);-- ❌ SELECT * FROM t_order WHERE remark IS NOT NULL; -- ❌
这不是语法限制,而是成本问题:否定条件命中的行数往往占大头,优化器认为“回表取整行”比全表顺序扫更贵。例外情况是覆盖索引——只需要索引列时,它会乐意用:
-- ✅ 只查索引列,走 index 扫描(Extra: Using index),不回表 SELECT user_id, status FROM t_order WHERE status != 0;
实用改写思路:把“排除”改成“枚举”。状态字段通常取值有限,status != 0 换成 status IN (1,2,3,4),立刻变 ref/range。IS NOT NULL 同理,如果业务确实需要频繁查“非空”,考虑冗余一个标记位 has_remark TINYINT 并建索引。
至于 IS NULL,它是可以用索引的(B+ 树里 NULL 排在最左),别把它和 IS NOT NULL 混为一谈。
场景 7:选择性太差 / 优化器主动放弃
SELECT * FROM t_order WHERE status = 0; -- status 只有 0/1/2 三个值
即使走了索引,假设命中 60% 的行,就要随机 IO 回表 60 万次——不如一次全表顺序读。此时 key=NULL 是正确决策,不是 bug。
判断标准:索引选择性 = COUNT(DISTINCT 列) / COUNT(*),越接近 1 越好。一般低于 5%~10% 的单列索引价值很低(主键、唯一键除外)。
两种解法:
- 改造成联合索引,把高选择性列放前面:
(user_id, status)就比单独的(status)有用得多; - 延迟关联(Deferred Join):先用覆盖索引捞出主键,再回表取完整行,把随机 IO 变成主键等值查询。
-- ❌ 原写法:回表海量行
SELECT * FROM t_order WHERE status = 0 AND create_time > '2024-01-01' LIMIT 10000, 20;
-- ✅ 延迟关联:子查询只走索引 + 主键回表
SELECT a.* FROM t_order a
JOIN (SELECT id FROM t_order
WHERE status = 0 AND create_time > '2024-01-01'
LIMIT 10000, 20) b ON a.id = b.id;
深分页问题本质上也是这个思路:LIMIT 100000, 20 改成“游标法”WHERE id > #{lastId} ORDER BY id LIMIT 20,性能是数量级差异。
场景 8:排序与索引错位,引发 Using filesort / temporary
-- 期望按创建时间倒序取最新订单 SELECT * FROM t_order WHERE user_id = 1 ORDER BY create_time DESC LIMIT 20; -- ⚠️ filesort
虽然 idx_user_status_time 包含 create_time,但中间隔着 status,且 status 没出现在 WHERE 中等值约束,有序性断掉了。
-- ✅ 方案 A:补齐等值条件,让排序列紧接在前导列之后 SELECT * FROM t_order WHERE user_id = 1 AND status = 0 ORDER BY create_time DESC LIMIT 20; -- ✅ 方案 B:单独建 (user_id, create_time),牺牲一点写入换读取 -- ✅ 方案 C:数据量小时接受 filesort,别盲目加索引(索引越多,更新越慢)
两条硬规则:ORDER BY 的列必须同向(一个 ASC 一个 DESC 在 8.0 之前无法利用索引,8.0 支持多方向但要求严格匹配);混合升降序 + 含范围条件基本都会退回 filesort。看到 Using temporary 更要警惕,它常出现在 GROUP BY 与 ORDER BY 列不一致时,意味着磁盘临时表,代价极高。
三、一套可复用的慢查询排查流程(照做就行)
1. 抓出慢 SQL - 开启慢查询日志:slow_query_log=ON, long_query_time=1 - 或用 pt-query-digest / Performance Schema / 云数据库的慢 SQL 面板 2. EXPLAIN 看 type / key / rows / Extra 3. ANALYZE TABLE 刷新统计信息,再 EXPLAIN 一次(排除统计失真) 4. 对照本文 8 条逐一过筛:有没有函数包装?有没有类型不匹配?最左前缀满足吗? 5. 看实际执行:EXPLAIN ANALYZE(8.0+)拿到真实耗时与循环次数 6. 改 SQL 优先于加索引;加索引时遵循:高选择性列在前、兼顾 ORDER BY/GROUP BY、控制单表索引数(一般 ≤ 5~6 个) 7. 上线后用相同参数跑一遍,确认 rows 下降、耗时下降,且写入 TPS 无明显劣化
顺手提一句:80% 的慢查询靠改 SQL 就能解决,剩下 20% 才需要动索引或架构。顺序千万别反。
四、高频疑问速答
Q:COUNT(*)、COUNT(1)、COUNT(主键) 有区别吗?
InnoDB 下三者基本等价,优化器都会选最小的可用二级索引扫描。COUNT(字段) 会忽略 NULL,反而可能走不到覆盖索引。真正的问题在于“大表全量 count”——这种需求应该走缓存或汇总表。
Q:主键为什么推荐自增/雪花 ID,不用 UUID?
UUID 无序插入会导致页分 裂、页密度低(填充因子差),同样数据量下树更高、IO 更多,写入性能明显劣化。雪花 ID 虽局部有序,但要注意时钟回拨和分布式单调性问题。
Q:加了索引后查询反而更慢了?
通常是两个原因:一是索引太多导致更新/插入时维护开销上升、Buffer Pool 被挤占;二是优化器选错了索引(可用 FORCE INDEX 临时验证,长期解法是删掉干扰索引或改写 SQL)。
Q:VARCHAR 字段太长,索引建不上怎么办?
用前缀索引:KEY idx_order_no_prefix (order_no(10))。代价是无法用于 ORDER BY 和覆盖索引,且选择性会下降——建之前先算一下不同长度的选择性:SELECT COUNT(DISTINCT LEFT(order_no,10))/COUNT(*) FROM t_order;
五、总结成一张检查清单
发版前照着勾一遍,能挡住绝大多数线上事故:
- 索引列没有被函数、运算、
CAST包裹 - 参数类型与字段类型完全一致(尤其注意 ORM 映射和拼接)
LIKE没有前导%- 联合索引满足最左前缀,范围查询列尽量放最后
OR已拆成UNION,或确认两侧都有索引- 否定条件已改为枚举,或确认是覆盖索引场景
- 索引选择性达标,低选择性单列索引已清理
ORDER BY/GROUP BY与索引列顺序、方向一致- 深分页已改为游标或延迟关联
- 上线前后都用
EXPLAIN对比过,且压测验证了写入性能
最后说两句:索引优化没有银弹,核心永远是两件事——让优化器能用上索引(写法规范),以及让优化器愿意用索引(选择性 + 回表成本)。把这两点吃透,比背十条“索引失效规则”管用得多。
以上就是MySQL索引失效的8种典型场景与优化实战的详细内容,更多关于MySQL索引失效场景与优化的资料请关注脚本之家其它相关文章!
