1. 为什么上篇发完之后,我反而越写越谨慎
上篇整理了一份“索引失效场景清单”,从最左前缀到隐式转换,从前导通配符到 or 条件,把常见的坑都过了一遍。发出去之后评论区很热闹,有人收藏,有人问“能不能直接给个全表禁止清单”,也有人问了一个我特别想展开的问题:既然失效场景这么多,那建索引到底还有没有用?
说实话,这个问题比“有哪些场景会失效”更难回答。因为索引失效不是背八个场景就能避开的,真正决定索引能不能用上的,是 MySQL 优化器基于成本、统计信息、B+树结构特性做出的一个综合决策。你背了“不要对列使用函数”,但如果不知道优化器为什么不能对函数列使用索引,换个 not in、or、range 查询,照样迷糊。
这篇可以作为上一篇的续篇来读。我会抛开“面试八股”式的场景清单,聊一聊索引失效背后的几个共性问题:为什么同一个 SQL,表数据量不同,执行计划完全不同?为什么明明建了联合索引,排序还是用了 filesort?为什么优化器偶尔会“放弃”索引,而选择全表扫描?这些才是索引失效真正值得思考的地方。
顺便说一句:索引失效不等于 SQL 一定慢。某些情况下,优化器放弃索引恰恰是最优解。如果你抱着“索引一旦失效就必须改”的思路去调优,反而可能把好的执行计划改成坏的。这里面的分寸,需要在真实案例里慢慢去摸。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 explain 重新认识“失效”:先从访问路径找源头
排查索引失效,我一般不看概念,直接看执行计划。因为“失效”是一个结果,结果背后的成因五花八门。explain 输出的一堆字段里,最值得先盯的其实是 type、key、rows、filtered 这四列。
2.1 type 的含义决定了对“失效”的基本判断
先看一个最简单的例子。假设我们有一张用户订单表,user_id 上建了普通索引:
sql复制CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
amount DECIMAL(10, 2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
KEY idx_user_id (user_id)
) ENGINE=InnoDB;
执行下面这条 SQL,然后看 explain:
sql复制EXPLAIN SELECT * FROM t_order WHERE user_id = 100;
多数情况下你会看到 type 为 ref,key 为 idx_user_id,rows 大概是该用户对应的记录数。这说明索引正常生效。
但如果我把查询改成这样:
sql复制EXPLAIN SELECT * FROM t_order WHERE user_id + 1 = 100;
type 会变成 ALL,key 变成 NULL。这就是“索引失效”的典型样例。但失效的本质不是 MySQL 不认 user_id 上的索引,而是 B+树索引的定位逻辑是在索引键原始值上进行的。user_id + 1 之后,索引里存储的键值没有经过 +1 的加工,优化器无法在树上执行等值比较,只能把整棵索引遍历完之后再对每条记录做表达式计算,成本自然就高于全表扫描。
这里的关键认知是:失效的是“索引快速定位”的能力,而不是索引文件本身。 索引还在,但无法用于 ref、range 这类快速定位访问,最多只能退化成 index 类型的全索引扫描。type=ALL 比 type=index 更糟,前者直接扫聚簇索引叶子节点,后者至少能扫一个较小的二级索引树。
2.2 区分“索引失效”和“索引效率不高”
看 explain 时很多人只看 key 是否为 NULL,这是不够的。比如这条查询:
sql复制EXPLAIN SELECT order_no FROM t_order WHERE status = 0;
status 上有索引,select 列也在索引里,type 是 ref,看起来没问题。但如果 status=0 的记录占了全表的 90%,优化器可能直接走全表扫描,此时 key 是 NULL。这是失效吗?不是。这是优化器经过成本计算后认为,使用索引需要大量回表,还不如直接扫全表。
所以在讨论“索引失效”之前,先要区分三种情况:
| 现象 | 本质 | 应对方式 |
|---|---|---|
| key=NULL,type=ALL | 无法使用索引定位 | 改写 SQL,让查询条件匹配索引 |
| key 有值,但 rows 极大 | 索引效率不高,区分度差 | 重新评估索引选择性和查询模型 |
| key=NULL,type=ALL | 优化器成本判断后选择全表 | 不一定要改 SQL,小心过度调优 |
上篇讲的各种失效场景,绝大多数属于第一类——语法上没有利用索引的机制,例如前导通配符、隐式转换、函数运算、不满足最左前缀等。遇到这类问题,应该先检查 SQL 写法,再检查索引设计。
2.3 用 explain 看一个隐式转换的例子
隐式转换是面试常考、线上也最高发的场景之一。假设订单号 order_no 是 varchar 类型,索引 idx_order_no 建好了:
sql复制EXPLAIN SELECT * FROM t_order WHERE order_no = 20240001;
因为 order_no 是字符串类型,MySQL 会把等号右边的数字常量转换成字符串来比较。这条其实能走索引吗?不一定。首先要看字符集和排序规则,以及常量与列类型的转换方向。更常见的反面案例是:
sql复制EXPLAIN SELECT * FROM t_order WHERE order_no = 20240001;
如果 order_no 列上有函数索引,或者字段定义成了数字,那没话说。但 varchar 列和数字比较时,MySQL 通常会把字符串列转成数字,意味着在列上发生了 CAST(order_no AS SIGNED),与“在索引列上做运算”同理,索引会失效。
我的习惯是:凡是类型对不上的 SQL,一律先统一类型再跑。应用层传参时,字符串就传字符串,不要图省事让 MySQL 去做隐式转换。哪怕某一条执行计划刚好走了索引,也只是因为常量恰好能转换且优化器选了一条成本低的路径,根上的风险并没有消除。
3. 左前缀、范围、排序:联合索引最容易踩的三个隐性雷区
如果说函数运算和隐式转换还算显性的坑,那联合索引上的“隐性失效”就隐蔽得多。很多同学建了联合索引,explain 一看 key 有值就欢呼,根本没发现 key_len 根本没走全,或者 Extra 里出现了 Using filesort。下面把这三个雷区拆开聊。
3.1 最左前缀不是“必须有左列”,而是“跳跃后停止匹配”
很多人把最左前缀理解为“查询条件里必须包含联合索引最左边的列”,这没错,但不够准确。更准确的表述是:联合索引在 B+树里先按第一列排序,第一列相同再按第二列排序,以此类推。查询条件只有从第一列开始连续匹配,优化的定位能力才能连续作用到后续列。
举个例子,联合索引 idx_user_created(user_id, created_at):
sql复制-- 能走索引,且 created_at 也能用于范围定位
SELECT * FROM t_order
WHERE user_id = 100 AND created_at >= '2024-01-01';
-- 完全不满足最左前缀,user_id 没用上
SELECT * FROM t_order
WHERE created_at >= '2024-01-01';
第二条不会用到 idx_user_created,因为 created_at 只是联合索引的第二列,B+树前缀里并不按第二列决定全局顺序。想要优化这类查询,要么建一个以 created_at 开头的新索引,要么让查询先以 user_id 为入口做等值条件,再在结果集里考虑时间范围。
还有一种情况更值得警惕:
sql复制SELECT * FROM t_order
WHERE status = 1 AND user_id = 100 AND created_at >= '2024-01-01';
如果索引是 idx_user_created(user_id, created_at),where 里 user_id 和 created_at 都能用于定位,status 就只能在回表之后再去过滤,因为联合索引中根本没有 status 这个键。这不是“索引失效”,而是“索引覆盖不住查询条件”,表现为主键回表次数多,rows 看起来不小。
3.2 范围查询之后的条件,不是“用不上”,而是“无法继续匹配”
这也是最容易引发争议的说法之一。很多人以为 WHERE user_id = 100 AND created_at >= '2024-01-01' AND status = 1 对联合索引 (user_id, created_at) 的匹配是:user_id 精确命中第一条,created_at 范围命中多条,status 在这些多条里继续等值过滤。
实际上,B+树在等值匹配时,可以精确定位到某个叶子节点;一旦进入范围条件,比如 created_at >=,那么从第一个满足条件的记录开始,叶子节点上的键就是按 created_at 递增排列的,status 在此时已经无法作为树上的有序键参与比较了。你只能把范围内所有记录读出来,回表后对 status 逐一过滤。
这不是 MySQL 的缺陷,而是索引结构决定的物理事实。你可以这样理解:一本字典按“姓氏-名字”排序,你要找姓“张”且名字大于“三”的条目,字典能帮你快速翻到“张”区,再帮你跳过“张二”之前的条目,但如果要找的是“性别男”,字典的排序里根本没有这个维度,你只能把“张”区所有人看一遍再逐一筛选。
所以设计联合索引时,要把等值条件尽量放前面,范围条件放后面,同时在想追加过滤列时避免把等值列塞到范围条件之后,因为那样对聚簇索引回表次数的削减没有任何帮助。
3.3 order by 失效:排序也走索引,但要求比 where 更苛刻
排序字段能否利用索引,很多人理解不够深。比如:
sql复制SELECT id, user_id, created_at
FROM t_order
WHERE user_id = 100
ORDER BY created_at DESC
LIMIT 20;
联合索引 idx_user_created(user_id, created_at) 对 user_id 等值匹配后,created_at 在索引上已经是升序的,遍历方向反过来就是降序,所以这个查询可以按索引顺序从后往前取 20 条,不需要文件排序,Extra 里不会出现 Using filesort。
但下面两句可能走向另一个极端:
sql复制-- 排序字段不在索引列中
SELECT * FROM t_order
WHERE user_id = 100
ORDER BY amount DESC
LIMIT 20;
-- 排序字段顺序与索引定义相反
SELECT * FROM t_order
WHERE user_id = 100
ORDER BY created_at ASC, id DESC
LIMIT 20;
第一句里 amount 没有参与联合索引,只能先把 user_id=100 的记录全部查到,再在内存或磁盘里做排序,extra 里出现 Using filesort。第二句的问题更隐蔽:虽然索引里有 id、created_at,但联合索引中 created_at 是第二列,排序要求先按 created_at 再按 id,索引定义是 user_id、created_at,方向上不匹配,也可能导致 filesort。
遇到 order by 性能瓶颈,不要只想着 where 条件能命中索引就够了,要把 order by 的字段纳入联合索引设计,尽量让排序也“走索引”。这里还要注意:如果排序字段很多、数据量很大,filesort 会在 sort_buffer_size 不够时使用磁盘临时文件,慢查询日志里执行时间会飙升。8.0 开始支持降序索引,可以用 KEY idx_user_id_time (user_id, created_at DESC) 显式声明排序方向,提前把方向问题在索引定义层面解决。
4. 优化器“选择失效”:被误解最深的成本决策
上面讨论的都是 SQL 写法或者索引定义导致“用不上”索引的情况。还有一种失效,严格说不叫失效,而是 mysql 优化器评估后认为不用索引更快。这种情况最容易让开发同学困惑。
4.1 回表成本是决定走不走索引的核心因素
InnoDB 的二级索引叶子节点不直接存储整行数据,而是存储主键值。通过二级索引查找时,第一步在二级索引树上定位到主键,第二步拿主键到聚簇索引里回表取整行。如果二级索引的选择性很差,比如 status=0 的记录占了全表一半,需要回表半数行,而回表是随机 I/O,优化器很可能直接选全表扫描,因为全表扫描是顺序 I/O,机械硬盘和 SSD 上成本模型差异很大,但总体上“一半行回表”比“全表顺序读”贵得多。
这就能解释一个反直觉现象:同样一个 SQL,在数据量小的表上走索引,在数据量大的表上反而全表扫描;或者在某个月份数据分布均匀时走索引,某个月份大量数据命中同一个值后改走全表。索引本身没变,变的是数据分布和优化器算出来的成本。
判断办法还是看 explain:
sql复制EXPLAIN SELECT * FROM t_order WHERE status = 0;
如果 type=ALL,key=NULL,但 data 分布里有 80% 都是 status=0,那么这其实是“正确”的全表扫描。这时候不要强行用 FORCE INDEX,否则可能更慢。正确做法是考虑数据倾斜问题本身:是不是应该增加一个 (status, created_at) 联合索引,让优化器看到“符合 status 且限定时间范围”的 rows 大幅度下降后,重新选择索引路径。
4.2 统计信息不准也会造成“假失效”
优化器做成本估算,依赖的是表的统计信息,包括表行数、索引基数、平均行长度等。InnoDB 的统计信息是采样估算的,不是实时精确值。如果数据频繁大量更新,或者刚导入了大批量数据,统计信息没来得及更新,优化器就会用一套过期的行数去计算成本,结果可能是一个本来应该走索引的查询走了全表。
遇到这种“执行计划突然变差,但 SQL 写法没问题”的情况,第一步不要改 SQL,先刷新统计信息:
sql复制ANALYZE TABLE t_order;
刷新之后再跑 explain,很多时候执行计划就回来了。这属于运维层的一个标准动作,尤其在批量导入、大事务删除之后。曾经有一个订单表每天定时同步几百万数据,同步后偶尔出现慢查询,DBA 查了半天才发现是优化器统计信息滞后,analyze 之后立刻恢复。这个经验我后来形成了固定流程:数据量变化超过 20% 的关键表,同步完成后必须 analyze。
4.3 optimizer_trace 是查看“优化器怎么想”的最后一招
如果 analyze 之后仍然选错,那就得拿 optimizer trace 来看具体的成本计算过程。
sql复制SET optimizer_trace = "enabled=on";
SELECT * FROM t_order WHERE status = 0 AND created_at >= '2024-01-01';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace = "enabled=off";
在 trace 结果里,可以找到 rows_estimation 和 considered_execution_plans 等字段,里面记录了优化器评估每个可用索引时估算的扫描行数和访问成本。看到这些数字之后,你才能明确回答“为什么这个索引没被用上”——到底是因为索引选择性差,还是统计信息过期,还是某个索引的 access path 成本算得比全表还高。
有一次我排查线上慢查询,explain 显示 type=ALL,索引明明建了,单列条件 user_type 区分度也很高。打开 optimizer_trace 才发现,优化器估算的 user_type 匹配行数严重偏大,原因是 user_type 字段取值分布严重不均,而统计信息采样刚好抽到了大头数据。然后我让业务侧用 user_type 和时间范围做联合等值,执行计划立刻切回索引。没有 trace,这种问题几乎不可能靠猜定位。
5. 一次真实排查:范围查询、排序、limit 凑在一起时,索引为什么会“断”
讲了这么多原理,不如看一个实际排查过程。这个案例不是什么惊天大坑,但完整展现了“方案设计 -> 执行计划分析 -> 根因定位 -> 优化验证”的思路,希望你能把第二、三、四节的知识点串起来。
5.1 背景和第一版 SQL
业务场景很简单:订单列表页要查某个用户最近 30 天已支付的订单,按支付时间倒序展示,只显示最新 20 条。
sql复制SELECT *
FROM t_order
WHERE user_id = 100
AND status = 1
AND pay_time >= '2024-01-01'
ORDER BY pay_time DESC
LIMIT 20;
表上有两个索引:
- idx_user_status(user_id, status)
- idx_pay_time(pay_time)
第一反应可能是:user_id、status 用联合索引等值匹配,pay_time 有单列索引,排序走文件排序也不至于太慢。但实际上线之后,这个 SQL 在高峰期平均执行了 700 多毫秒。why?
5.2 用 explain 和 trace 逐层拆解
先看 explain:
sql复制EXPLAIN
SELECT *
FROM t_order
WHERE user_id = 100
AND status = 1
AND pay_time >= '2024-01-01'
ORDER BY pay_time DESC
LIMIT 20;
结果里 type 为 ref,key 是 idx_user_status,rows 估算大概是 3 万行,Extra 里有 Using where; Using filesort。也就是说:MySQL 用 idx_user_status 定位到 user_id=100 且 status=1 的 3 万行,然后逐行回表过滤 pay_time 条件,最后用 filesort 排序取前 20 条。这个执行计划没有错,但瓶颈在于:回表 3 万行 + 排序 20 条,成本太高。
问题的根子是索引设计没有覆盖 where 里的 pay_time 和 order by 里的 pay_time。idx_user_status 的根本作用是把范围缩到该用户已支付订单,但 pay_time 的过滤和排序全在回表之后才做。优化器本来也可以选择 idx_pay_time,先用 pay_time 条件定位到 30 天内的所有记录,再回表过滤 user_id 和 status,但估算回表行数更大,所以它放弃了。
5.3 优化方案:把等值列和排序列放进同一个联合索引
正确的做法应该是建一个:
sql复制ALTER TABLE t_order
ADD KEY idx_user_status_paytime (user_id, status, pay_time DESC);
查询条件里 user_id 和 status 是等值,pay_time 既是范围过滤条件,也是排序字段。联合索引里等值列放前面,接着放排序字段,MySQL 可以直接在索引上按 pay_time 降序遍历,不需要 filesort。只要找到第一条满足条件的记录就开始取,取满 20 条就结束。
改完之后重新 explain:
- key 变成 idx_user_status_paytime;
- Extra 里不再有 Using filesort;
- rows 估算大幅下降;
- 线上执行时间从 700 多毫秒降到了 5 毫秒以内。
5.4 这个案例带来的三个“索引设计要一起看”的教训
第一条,等值条件、排序字段、范围字段必须放到同一个索引设计里考虑,不要各自为政。第二条,explain 里 key 有值不代表执行计划好,要同时关注 Extra 中的 Using filesort、Using temporary,以及 rows 与 filtered 的估算比例。第三条,慢 SQL 优化往往不是 SQL 写法错了,而是索引设计与查询模式错配。在写 SQL 之前先把索引模型画出来:哪些列是等值,哪些列是范围,哪些列要排序,哪些只需要回表取,在模型里过一遍,比等到线上慢再去 explain 高效得多。
6. 索引设计阶段就避开“失效”的四个实用原则
纸面排查聊完,再往前一步:很多索引失效问题在设计阶段其实是可以避免的。这节分享几个被验证有效的实战原则。
6.1 原则一:先建模查询,再决定索引
不要一上来就把可能出现 where 的列全部塞进索引。我见过有人给一张 20 列的表建了 15 个单列索引,看似覆盖了所有查询,实际上写入变慢,优化器也容易选错。
正确流程是:把核心业务查询和慢查询日志里的 TOP SQL 捞出来,逐个标注等值列、范围列、排序列、覆盖需求,再看哪些 SQL 可以共用一个联合索引。例如,如果线上高频查询是“用户在某时间段内的订单列表”,一个 (user_id, pay_time) 联合索引往往比单独的 user_id 索引和 pay_time 索引都更有效。
6.2 原则二:等值列放前,范围列放中,排序列最后显式考虑
这是设计联合索引时比较稳妥的排序思路。
- 等值条件列放最前面,用来快速收敛;
- 范围条件列随后,但要意识到范围之后的其他条件列“过滤能力”会大打折扣;
- order by 的列如果能归入同一索引,尽量让排序顺序和索引顺序一致;
- 如果 select 的字段不多,考虑覆盖索引,把查询列也放进索引的末尾,减少回表。
现实案例中,我也常碰到“等值列放前面”和“区分度高的列放前面”两种思路冲突。比如 city_id = 1 AND status = 1,city_id 区分度低于 status,但 city_id 是高频等值条件,status 是低区分度。这种情况下,把等值条件稳定、筛选能力较高的列放前面通常是首选;如果查询已经用 city_id 能框定很小范围,status 是否进索引可能不关键。先用 explain 和 optimizer trace 估算,再拍板,不要拍脑袋。
6.3 原则三:警惕对索引列的一切“包装”
我把列上做函数、隐式转换、数值与字符串比较、列与列比较这些统称为“包装”。设计规范里最好明确要求:where 条件里,索引列必须干净出现在比较运算符一侧。如果业务场景确实需要对时间列做函数,比如按 DATE(created_at) 分组,引入 8.0 的函数索引或者生成列索引,都比在 SQL 里写 DATE(created_at) = '2024-01-01' 更好。
sql复制-- 不推荐:对列使用函数,索引基本失效
SELECT * FROM t_order WHERE DATE(created_at) = '2024-01-01';
-- 推荐:范围写法,索引可以 range 扫描
SELECT * FROM t_order
WHERE created_at >= '2024-01-01'
AND created_at < '2024-01-02';
范围写法还能让优化器估算更准确,因为 date 列上的函数会把所有记录都扫一遍,这个成本差异在千万级表上非常夸张。
6.4 原则四:保留 analyze 和索引监控的运维动作
上节提到统计信息过期会导致优化器“选择失效”,这个问题的预防手段其实就是把 analyze 纳入常规运维。另外建议定期清理掉长期没有被使用的冗余索引。索引不是越多越好:每多一个索引,插入、更新、删除都要同步维护,buffer pool 的占用也会上升。可以用 performance_schema 或者 sys.schema_unused_indexes 来查哪些索引长期无人使用,该删就删。
删除索引前要注意一个常见陷阱:某些索引虽然没被这条 SQL直接使用,却被另一条 SQL 用来做覆盖扫描。删除前先完整核对慢查询日志和 explain 结果集,不能只看一张表的访问频率。
7. 说说我对“索引失效”这个说法的态度
写到这里,核心内容基本说清了。最后聊点个人体会。
“索引失效”这个词被用得太宽泛了,甚至带点误导。它让很多初学者觉得 MySQL 是个六亲不认的机器,一不小心就会把索引“弄坏”。但真实场景里,绝大多数索引失效不是玄学,而是SQL 条件、索引结构、B+树物理特性、优化器成本模型共同作用下的必然结果。你理解了 B+树靠有序键定位,就能理解为什么最左前缀断开不行、为什么范围列之后条件无法继续匹配、为什么列上套函数等于让定位条件变成无锚点的遍历。你理解了回表是随机 I/O,就能理解为什么有些情况下优化器宁可全表扫描也不走索引。
以后再遇到慢查询,建议按这个顺序思考:
- 执行计划里 key 是否为 NULL?如果是,看条件是脏了还是索引设计缺了;
- Extra 里有没有 filesort、temporary?如果有,多半是排序或分组逻辑没有吃进索引;
- 统计信息是否过期?批量更新之后先 ANALYZE TABLE;
- 如果一切正常但仍慢,打开 optimizer_trace 看成本估算;
- 最后再决定是改 SQL、调索引,还是接受全表扫描这个事实。
索引优化是长期工程,不是一次 explain 就完事。数据分布会变,业务查询模式会变,一个季度前的索引设计可能在这个季度就失效。保持对执行计划的敏感度,定期翻慢查询日志,把 explain 作为日常开发习惯,比收藏任何“索引失效清单”都更有用。这也是我写这一系列文章最大的初衷——清单只能帮你应付一次考试,理解背后的机制才能让你在真实数据面前做出对的选择。
