Mysql

关注公众号 jb51net

关闭
首页 > 数据库 > Mysql > MySQL索引失效场景与优化

MySQL索引失效的8种典型场景与优化实战

作者:xixiaoyunya

线上慢查询里,十有八九是索引建了却没生效,本文不讲空洞理论,直接从 EXPLAIN 出发,还原 8 种最常见的索引失效现场,需要的朋友可以参考下

摘要:线上慢查询里,十有八九是“索引建了却没生效”。本文不讲空洞理论,直接从 EXPLAIN 出发,还原 8 种最常见的索引失效现场:函数/表达式包装、隐式类型转换、前导模糊匹配、违反最左前缀、OR 连接、!= 与 IS NOT NULL、联合索引排序错位、统计与范围导致的回表爆炸。每种场景都给出可复现的 SQL、执行计划解读和改造方案,最后附一套可直接照做的排查清单。
关键词:MySQL;索引失效;EXPLAIN;慢查询优化;最左前缀;回表

一、先说清楚一件事:索引不是建了就一定用

很多人遇到慢查询的第一反应是“加索引”,结果索引加了,rows 没降,type 还是 ALL 或 index,QPS 照样被打爆。原因通常只有两个:

  1. 优化器算下来觉得全表扫描更便宜(数据量小、选择性差、回表成本太高);
  2. 你的写法让索引根本没法用(这是本文重点,也是最好修的)。

判断到底属于哪一种,唯一可靠的入口就是 EXPLAIN(或 EXPLAIN ANALYZE)。看这四个字段就够:

字段含义好信号坏信号
type访问类型const / eq_ref / ref / rangeALL(全表)、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+ 树依赖左前缀做定位,左边不确定就无法定位起点。解决办法按成本从低到高:

场景 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% 的单列索引价值很低(主键、唯一键除外)。

两种解法:

-- ❌ 原写法:回表海量行
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;

五、总结成一张检查清单

发版前照着勾一遍,能挡住绝大多数线上事故:

最后说两句:索引优化没有银弹,核心永远是两件事——让优化器能用上索引(写法规范),以及让优化器愿意用索引(选择性 + 回表成本)。把这两点吃透,比背十条“索引失效规则”管用得多。

以上就是MySQL索引失效的8种典型场景与优化实战的详细内容,更多关于MySQL索引失效场景与优化的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文