☰
MySQL索引失效的十种常见场景与EXPLAIN排查指南
2026/10/10 3:44:32 网站建设 项目流程

1. 第一次看到 type=ALL 的时候,我以为 MySQL 在偷懒

两年前我接手了一个订单查询接口,线上偶尔会有几个请求要等三秒多才能返回。当时的 SQL 长得很"无辜",WHERE 条件里带上了订单号、用户ID、创建时间,每一个字段我都建了索引。当时我的第一反应是:这数据库是不是出了什么问题?后来用 EXPLAIN 一看,type 一栏明晃晃写着ALL,也就是全表扫描,一条订单表几十万行,每次查询都从头扫到尾。

那是我第一次意识到,索引不是建了就一定会被用上。MySQL 的优化器在决定"走不走到索引"这件事上,有它自己的一套逻辑,而这套逻辑经常跟人脑直觉相反。之后两年我做过的慢查询排查,很大一部分都栽在"字段明明有索引却不走"这个问题上。这篇文章不是教科书式的索引原理解读,而是我想把自己踩过的、见过别人踩的坑一次性说清楚:哪些条件会让 MySQL 放弃索引,为什么优化器会"犯傻",以及我们到底该怎么查、怎么写。

先说一个基本认知:MySQL 里所说的"走索引",指的是从存储引擎层读取数据时,通过 B+ Tree 快速定位到目标行的过程。但优化器决定是否采用索引,靠的是成本估算,并不是"能走就一定要走"。说白了,优化器是个精明的账房先生,如果它觉得全表扫反而更划算,哪怕索引摆在那里,它也宁可扫全表。所以研究"不走索引的条件",本质上是在研究两件事:一是哪些 SQL 写法让索引从根本上失去可用性,二是哪些情况下优化器觉得索引不划算。

2. 让 MySQL 放弃索引的十种常见姿势

这一节的内容是重头戏。我把实际工作中遇到的"索引失效"场景按根因归成了十类,每类都会给一个可以复现的示例,并尽量把背后的原理讲清楚。

2.1 最经典的元凶:LIKE 左模糊

SELECT * FROM orders WHERE order_no LIKE '%20240901%';

这种写法非常经典。%开头的模糊匹配在 B+ Tree 里是没法定位起点的,因为索引按从左到右的顺序存储值,你给不出一个确定的左边界,优化器只能从头到尾把所有索引页读一遍,还不如直接扫表。

我见过很多人问"为什么我明明给了前缀,比如LIKE '20240901%'仍然不走索引"。这种情况其实要分两层看:如果只是走索引进行范围扫描,前缀 LIKE 是可以走的;但如果 SELECT 出来的列在索引里找不到,MySQL 需要回表去聚簇索引里取整行数据,当回表成本太高时,优化器同样可能放弃。所以前缀 LIKE 不是必然走索引,还得看覆盖索引的配合。

经验做法:业务上能不用%开头就不用,实在要模糊搜,走专门的搜索组件;只能走 MySQL 的话,考虑把需要模糊匹配的字段拆成长度可控的子串,或者接受全表扫描但控制数据量。

2.2 对索引列做函数或运算

SELECT * FROM orders WHERE DATE(created_at) = '2024-09-01'; SELECT * FROM active_users WHERE age + 1 = 30;

这是另一大类高频问题。只要索引列被函数或表达式包裹,索引就废了。原因是 B+ Tree 存储的是原始值,而你在 WHERE 里用的是一个在原始值基础上加工后的值,索引里根本没有这个加工结果,MySQL 只能对每一行的原始值先做函数运算,再跟条件比较。

DATE(created_at) = '2024-09-01'应该改成created_at >= '2024-09-01 00:00:00' AND created_at < '2024-09-02 00:00:00',这样既走索引,又是个闭区间范围扫描,一石二鸟。至于age + 1 = 30这种,改写为age = 29就行。

一个容易忽略的细节:即使你只对索引列做了一次加减乘除,哪怕结果还是同一列的值,优化器也没有聪明到能反解出等价原始范围。它看到的是一棵无法直接从 B+ Tree 起点匹配的比较树,所以直接放弃。

2.3 隐式类型转换是个安静的杀手

# 假设 phone 列是 CHAR(11),但你传了数字 SELECT * FROM users WHERE phone = 13800138000; # 假设 user_id 列是 BIGINT,但你传了字符串 SELECT * FROM active_users WHERE user_id = '10001';

第一种情况特别容易踩坑。字符串列跟数字比较时,MySQL 会自动把字符串转成数字再比较,实际上变成了CAST(phone AS SIGNED) = 13800138000。一旦发生函数转换,索引就直接失效。第二种情况反过来,字符串转数字,BIGINT 列不会失效,因为 MySQL 会尝试把右侧的'10001'转成数字与左侧列比较,列本身没被函数包住。

判断方法很粗暴:能保证"列的原始值直接跟某个值比较",索引大概率可以用;只要中间有任何类型转换发生在列这边,就等着走全表吧。

类型转换本身是隐式的,所以在代码评审阶段很难看出来。我有一次白天排查了很久才定位到,是一个查询条件里的用户ID从接口进来变成了字符串,恰好表字段是 BIGINT,运行时 MySQL 做了转换但没体现在代码里。从那以后我要求团队所有 DAO 层的查询参数类型必须跟表结构字段类型对齐,这个问题可以彻底从源头掐掉。

2.4 索引列参与了计算或比较操作的两边

这类跟前一类有点像,但场景更隐蔽:不是函数包裹,而是索引列和另一个列做了运算。

SELECT * FROM delivery_info WHERE end_time - start_time > 3600; SELECT * FROM account_flow WHERE create_date + INTERVAL 7 DAY < NOW();

end_time - start_time这种写法里end_time和start_time都可能建了索引,但由于二者之间做的是减法,优化器只能取出两列的值逐行计算。这种写法无论建多少索引都救不回来,正确的做法是把差值这个字段单独落库,或者在查询前由应用层算出时间窗边界,再走范围比较。

这类场景我强调一个排查原则:看 WHERE 条件时,如果你发现任何一列"不是在跟常量比较,而是在跟另一列比较,或者跟表达式比较",先怀疑索引失效。因为索引有效的前提是可以通过列值做有序查找,列跟列比较本质上没有"起点",只有"逐一校验"。

2.5 OR 连接的条件只要有一边不走索引,整个条件就废了

SELECT * FROM orders WHERE order_no = 'A10001' OR status = 5;

这里如果order_no和status都有索引,MySQL 在新版本里可以走 index_merge(索引合并),把两个索引分别查出来再合并。但一旦其中一个条件没有可用的索引,优化器就会选择直接放弃所有索引,全表扫描。原因很简单:OR 的语义是"满足任意一边即可",如果有一边必须全表扫才知道结果,那合并后还是全表扫,不如干脆全表扫一遍。

这算是个"木桶效应"。解决思路通常是用 UNION 拆分:

SELECT * FROM orders WHERE order_no = 'A10001' UNION SELECT * FROM orders WHERE status = 5;

或者改成 IN 列表,前提是列表里的枚举值有索引覆盖。但改写成 UNION 也不是万能的,它会增加一次查询的复杂度,必须确认两条分支都能走索引才有意义。如果业务允许,用冗余条件让 OR 两侧都能走索引也算勉强能用,但工程上我还是更喜欢 UNION 拆分,定位清晰。

2.6 复合索引没按最左前缀来

复合索引 (a, b, c) 在 B+ Tree 里的存储规则是先按 a 排序,a 相同再按 b 排,再按 c 排。这种结构决定了一个铁律:如果 WHERE 里没有 a,只用 b 或只用 c 作为过滤条件,那索引的有序性就发挥不出来。

# 假设复合索引 (user_id, order_no) SELECT * FROM orders WHERE order_no = 'A10001'; -- 不走该索引 SELECT * FROM orders WHERE user_id = 1001; -- 可以走该索引 SELECT * FROM orders WHERE user_id = 1001 AND order_no = 'A10001'; -- 可以走

很多人误以为"我建了复合索引,里面每个字段都自己带索引"。不是的。复合索引只有一个 B+ Tree,最左前缀本质上是查询条件要与索引列的前缀子集匹配。WHERE order_no = 'A10001'想用 (user_id, order_no) 这个索引时,相当于要在 B+ Tree 里查找一个"第一关键字未指定"的区间,索引无从开始。

这里有个实际工程里容易踩的细节:查询条件里明明带了 user_id,但顺序写反了或者被函数包住,也会让最左前缀失效。比如WHERE order_no = 'A10001' AND user_id + 0 = 1001,user_id 被函数包住失效,整个条件里能用的就只剩 order_no,但 order_no 又不是最左字段,结果整个复合索引被放弃。

2.7 排序和分组字段与索引顺序不匹配

SELECT * FROM order_log GROUP BY order_type ORDER BY create_time;

ORDER BY、GROUP BY、DISTINCT 其实都可以利用索引有序性来避免 filesort 和临时表。但前提是,排序字段必须与索引的排序列方向一致、顺序一致。

最常见的失效场景是:索引是 (user_id, create_time),你的 SQL 是WHERE user_id = 1001 ORDER BY create_time DESC,这是能走索引的;但如果你写WHERE user_id = 1001 ORDER BY product_id DESC,索引排序列是 create_time,你要求按 product_id 排序,索引的无序性暴露了,MySQL 只能额外 filesort。

还有一个容易被忽略的点:ORDER BY 和 GROUP BY 里混合了索引列和普通列,也会导致排序无法完全借助索引。解决办法就是让你的索引结构尽量贴合高频排序需求,按"过滤条件 + 排序字段"的顺序去设计复合索引。你没法让一个索引同时满足所有排序,所以要挑出最核心的那一两个查询模式。

2.8 不等于、NOT IN、NOT LIKE 通常不走索引(但也不绝对)

SELECT * FROM users WHERE status != 1; SELECT * FROM orders WHERE supplier_id NOT IN (5, 10);

不等操作的问题在于:它天然对应了一个"排除某个值"的语义,而 B+ Tree 擅长的是"限定范围去取"。status != 1等价于 "status < 1 OR status > 1" 两端开区间,而且如果 status 只有少量几个离散值,这个条件往往扫出大部分数据,优化器会觉得全表扫更快。

但对数据高度倾斜的情况,!=未必完全不能走索引。比如状态列绝大部分都是 1,只有极少数的 2,status != 1在优化器的成本模型里如果判定扫描的数据量很少,仍可能走索引。所以不要把"不等于一定不走索引"当成铁律,要结合执行计划判断。

2.9 索引列可空与 IS NULL / IS NOT NULL

关于 NULL 有一个经典争议:WHERE name IS NULL到底走不走索引?在 MySQL 5.7 以后,对于带 NULL 值的索引列,IS NULL 是可以走索引的,因为 B+ Tree 里 NULL 也会被当成一个值排进去。但有一个前提:如果表的 NULL 值占比极高,优化器可能觉得索引扫描要跳过大量NULL,不如全表扫。

更常见的问题是:列被定义为NULL,而且业务里大量存在 NULL,然后你在 WHERE 里用了 IS NOT NULL,优化器看到要处理的行里有一大半的 NULL 值不满足条件,计算的扫描区间很大,就干脆走全表。这种场景下,建议把列改成 NOT NULL DEFAULT 一个默认值,既减少判断开销,也让索引更"纯粹"。

2.10 强制走索引也白搭:SELECT * 和回表成本

最后这一条其实很多文章会忽略:即使条件本身完全满足索引要求,SELECT * 带来的回表成本也可能让优化器放弃索引。比如一张表的数据量不大,但行宽度很宽,SELECT * 需要回表把每一行的所有列都拿到,而全表扫描直接顺序读聚簇索引,页面的IO效率反而更好。

我遇到过一个典型案例:一张商品表几万行,WHERE 只用到 category_id 索引,但 SELECT 要取 50 多个字段,优化器最后选择了 ALL。有人会问,索引明明可以定位到行,为什么不用?因为二级索引找到的是主键,还得再去聚簇索引里读一遍完整记录,每一行都要两次IO。全表只扫聚簇索引一次IO,数据量不大的时候,一次IO反而更省。解决办法有两条:一是把查询改成覆盖索引,只 SELECT 索引里包含的字段;二是真需要全字段,同时数据量又大,那就得评估分页或缓存方案。

3. 不是所有不走索引都是 bug:聊聊优化器的小算盘

上面十种场景里,有几种属于"写法废了索引",也有几种其实是"优化器权衡之后放弃了索引"。我自己早期踩坑时容易陷入一个误区:看到 type 不是 ref 或 range 就觉得出事了。后来看得多了才明白,ALL 和 index 这两种访问类型不总是坏事,关键要看扫描行数和回表成本的估算。

优化器判断用不用索引,主要看三个因素:行数估算、选择性(区分度)、回表成本。一张 5000 万行的订单表,如果WHERE status = 1命中 4000 万行(状态极少有非 1),优化器就算知道 status 有索引,大概率也会走全表。因为在 B+ Tree 里扫出 4000 万个主键,再回表 4000 万次,成本远高于顺序读整个聚簇索引一遍。

还有一种情况是统计信息失真。MySQL 的优化器依赖表的统计信息来估算行数,如果ANALYZE TABLE长期没跑,或者 InnoDB 的采样统计精度不高,优化器可能低估或高估了某个索引的选择性。我排查另一个慢查询时,明明小字段和业务完全匹配索引,执行计划却显示全表扫描,后来跑了一次ANALYZE TABLE,执行计划立刻变了。从那以后我养成了一个习惯:分析任何"索引该走却没走"的问题前,先刷新统计信息再看。

成本评估这部分我能给的实操建议是:不要靠猜,直接看执行计划里的rows和filtered两列。如果rows显示扫描了几百万行,但filtered只有 1%,说明优化器算到最后觉得通过索引筛选后剩余行太少而没走;如果rows本身就很小,但 type 还是 ALL,那大概率是统计信息过旧或者索引写法的确有问题。

4. 用 EXPLAIN 把"不走索引"揪出来:一套可复用的排查流程

纸上谈兵够了,说说实际怎么查。我把排查"mysql 不走索引"这个问题沉淀成了一套固定流程,现在团队里新同学来我也会先让他们按这个思路走。

4.1 第一步:看 type 列,快速定位访问类型

EXPLAIN 输出里的 type 列,从上到下效率大致是:

type含义健康度
system表里只有一行极优
const主键或唯一索引等值查询极优
eq_ref连接查询时被驱动表通过 PK/UNIQUE 访问优
ref非唯一索引等值查询良好
range索引范围扫描良好
index全索引扫描(遍历索引树)一般
ALL全表扫描差

如果 type 是 ALL 或 index,大概率就是"不走索引"的现场。但注意,type 是 range 也不代表一切正常,还要看 Extra 里有没有 Using filesort 或 Using temporary,这些说明排序或分组没能借助索引。

4.2 第二步:看 key 和 rows,判断用了哪个索引、扫了多少行

key列显示实际用到的索引,如果为 NULL 说明没走。rows是预估扫描行数,这个值跟实际行数差距如果特别大,就要考虑统计信息过旧。

一个替代技巧是EXPLAIN ANALYZE(MySQL 8.0+ 支持),它会真实执行 SQL 并输出实际耗时和扫描行数,比 EXPLAIN 的估算靠谱很多。遇到优化器"拍脑袋"走错的情况,EXPLAIN ANALYZE能看到每个节点的实际数据流,比看一堆估算列直观得多。

4.3 第三步:对照排查清单定位根因

拿到执行计划后,我从上到下核对一遍:

  1. WHERE 条件里的列有没有被函数包裹
  2. 参数类型跟字段类型是否一致
  3. LIKE 是不是左模糊
  4. 是不是 OR 连接了多个条件
  5. 复合索引用没用最左前缀
  6. 排序字段跟索引列顺序是否一致
  7. SELECT 列是不是导致大量回表
  8. 统计信息是否过旧

这八项检查完,90% 的"不走索引"问题都能找到归属。剩下 10%,再去看 MySQL 版本差异和优化器的特殊行为。

5. 让 SQL 主动走索引:几个实战改写方案

这一节给的是能直接抄走的改写模板。我从多个真实项目里总结出来的,优先级也是从高到低。每个方案都会配一个改写前和改写后的对比。

5.1 函数包裹场景:重写范围

改造前:

SELECT * FROM payment_record WHERE DATE(paid_at) = '2024-09-01';

改造后:

SELECT * FROM payment_record WHERE paid_at >= '2024-09-01 00:00:00' AND paid_at < '2024-09-02 00:00:00';

这类改写最简单,收益也最直接。因为日期时间字段的边界转成一个半开区间[起始, 结束)以后,优化器可以直接走 B+ Tree 范围扫描,实际扫到的行数也跟原始条件一致。

5.2 隐式类型转换:让参数类型跟字段对齐

改造前:

SELECT * FROM users WHERE phone = 13800138000;

改造后:

SELECT * FROM users WHERE phone = '13800138000';

说实话这类问题在代码里很难通过 SQL 层彻底规避,因为最终传给 MySQL 的参数类型取决于语言和框架。我现在的做法是:在 DAL 层加一个小工具函数,专门负责根据字段类型做参数格式化。比如 phone 字段永远先转字符串再拼 SQL,user_id 字段永远先转长整型,这样至少从入口处掐掉了大部分隐式转换。

5.3 LEFT JOIN 的驱动表与被驱动表

联表查询里也经常出现"不走索引",但很多人会把锅甩给 JOIN 本身。真实原因是被驱动表的关联字段没建索引。

-- 改造前:drivers 表的 id 有索引,但 orders 表的 driver_id 没索引 SELECT * FROM orders o LEFT JOIN drivers d ON o.driver_id = d.id; -- 改造后:确认 orders.driver_id 和 drivers.id 都有索引 ALTER TABLE orders ADD INDEX idx_driver_id (driver_id);

优化器做 JOIN 时,默认会选小表做驱动表,大表做被驱动表,然后对被驱动表的关联字段做索引查找。如果被驱动表的关联字段没有索引,每次匹配都要全表扫,性能直接崩盘。所以排查 JOIN 慢查询时,第一件事就是检查被驱动表关联字段的索引状态。

5.4 覆盖索引解决 SELECT 回表问题

改造前:

SELECT order_no, status, create_time FROM orders WHERE status = 1;

改造后,建一个覆盖索引:

ALTER TABLE orders ADD INDEX idx_status_create (status, create_time, order_no);

只要 SELECT 的字段都包含在索引里,MySQL 就无需回表,Extra 会显示 Using index。这是让"索引看起来很划算"的最有效手段之一,尤其适合高频查询只关心少量字段的场景。

但覆盖索引不是越多越好。索引本身占空间,写入时要同步维护多个 B+ Tree。所以我只在查询频率极高、查询字段相对固定的情况下才建。一个表通常控制在 3~5 个索引以内,多余的索引不仅浪费磁盘,还拖慢 INSERT/UPDATE 速度。

5.5 用 UNION 拆分 OR 条件

改造前:

SELECT * FROM user_info WHERE name = '张三' OR mobile = '13800138000';

改造后:

SELECT * FROM user_info WHERE name = '张三' UNION SELECT * FROM user_info WHERE mobile = '13800138000';

前提是 name 和 mobile 都各自有索引。UNION 会把两条分支的结果合并去重,虽然会多一次查询开销,但每条分支都能走索引,整体性能通常优于全表扫描。如果业务只接受一条 SQL,可以考虑改写为IN列表,但需要一个能在索引里直接匹配的列;如果两个条件分属不同列,还是 UNION 最可靠。

6. 从索引失效里挖出的几条建索引原则

排查了这么多案例之后,我对"怎么建索引才能最大程度避免不走索引的尴尬"有了更实际的理解,这里分享一下。

第一,复合索引要按"等值条件优先、排序字段随后、范围条件下放"的顺序排列。例如WHERE a = 1 AND b > 10 ORDER BY c,理想的复合索引顺序是 (a, c, b)。因为 a 是等值过滤可以把范围缩小到一个很小的区间,然后按 c 已经有序可以直接利用索引排序,最后 b 再在这个小范围内做范围过滤。这个顺序很多人会搞反,把 b 放中间,结果排序没法走索引,白白多了一次 filesort。

第二,基数过低的列不要单独建索引。比如性别、状态、是否删除这些只有两三个取值的列,单独建索引基本没有意义,优化器算一下选择性太低,很可能走全表。这种列如果非要过滤,通常放在复合索引的靠后位置,作为"二次削减"而不是"主力过滤"。

第三,避免在索引列上做"冗余加工"。我在评审代码时有一条不成文规定:所有 WHERE 里的索引列必须是"裸列"。任何人写SUBSTR(name, 1, 3)或者DATE(create_time)都得重新改写。这样在习惯层面直接消灭一大类索引失效问题。

第四,数据量大到一定程度,索引就不是万能的。走索引至少要随机读索引页 + 回表读数据页,即使每次IO只有 0.1ms,几百万次回表也要几十秒。这时候就不能只靠"建索引"解决问题,要开始考虑分页、查询下推、汇总表或者数据归档。我之前优化过一个报表查询,最后不是靠加索引解决的,而是把三年历史数据按月归档到历史表,活数据只保留最近三个月,查询量瞬间降下来,索引也就"活"了。

7. 几种"看似没走索引"其实已经走了的特殊情况

有时候排查的人会被 EXPLAIN 的结果误导,以为没走索引,其实是走了,只是访问类型不同。这里专门讲一下容易误判的情况。

type = index其实是全索引扫描,它确实遍历了整个索引树,但索引树通常比聚簇索引小很多,所以比全表扫描快。如果你看到 type = index 并且 Extra 里有 Using index,说明 SELECT 的列都包含在索引中,虽然没有用索引精确定位,但至少避免了全表扫描,这算"半走索引"。比如SELECT count(*) FROM big_table用的是统计信息或一个最小索引扫描,type 是 index,设计上是合理的。

还有一种情况,possible_keys有值但key是 NULL。这代表优化器评估了那些索引,最终放弃了。遇到这种别急着加FORCE INDEX(强迫走索引),因为优化器放弃通常有它的理由。FORCE INDEX是对优化器说"你别算成本了,按我说的来",但一旦数据分布变化,原来合理的强制索引可能变成灾难。我在生产环境只会在 EXPLAIN 和真实压测都确认"强制走索引确实更快"之后,才会把 FORCE INDEX 加进代码,并且旁边注释清楚理由和数据量验证结果。

8. 写在排查经验末尾的一个小提示

最后分享一个我自己的习惯:每次定位完一个"明明有索引却不走"的问题,我会把 SQL 原文、表结构、执行计划和最终解决方式整理成一个短小的案例,按场景归类存到团队知识库里。这样做的价值不在于"以后遇到能直接搜到答案",而在于积累多了以后会对"索引什么时候失效"产生一种直觉。比如现在我看到一条 SQL,脑子里能大致预估出优化器会怎么选,然后才决定需要不需要去看执行计划。

如果你正在排查类似的慢查询,我的建议是先别急着改 SQL,也别急着加索引。先打开 EXPLAIN,搞清楚优化器到底在算什么账,再对照这篇文章的分类去查。多数时候你会发现问题落在某一种具体写法上——要么是函数包裹,要么是类型不一致,要么是 OR 条件拖了后腿。把根因找到,改写可能只需要一行,但理解为什么会变成这样,才是以后不再踩同一个坑的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询