上篇聊到线上一个查询突然变慢的案例,最后定位到索引失效上。这个坑做后端的基本都踩过,面试也常被问,但说实话,大部分人只背得下那几条场景,比如“like前面不能加%”“联合索引要遵守最左前缀”,换个壳子就不认识了。这篇把索引失效这件事彻底展开讲一讲,从常见场景到底层原理,再到线上排查手段和业务侧解法,一次说透。
如果你是刚接触MySQL优化不久,这篇文章能帮你把“索引失效”从面试题变成真正的排查能力。如果你已经写过不少SQL,相信里边有几条排查思路和优化方案的取舍,也能给你一些新的角度。全文没有太多理论绕弯,都是实操场景和踩坑总结。
1. 先别急着背场景:索引失效这件事要分两层看
1.1 第一层:真正失效,索引结构上就注定用不上
先建立几个基础概念,后面所有场景都离不开它们。MySQL的InnoDB引擎里,主键索引是聚簇索引,叶子节点存的是整行数据。二级索引(普通索引)的叶子节点存的是索引列的值加上主键值。一条查询如果用上了二级索引,通常需要先扫二级索引拿到主键,再回聚簇索引查整行数据,这个过程叫回表。
了解了这个结构,就能理解很多“失效场景”的本质。比如联合索引 (a, b, c),在B+树里是按 a 排序的,a 相同的情况下才按 b 排序,b 相同才按 c 排序。如果你查询条件只写了 b = ?,B+树的有序性对你来说毫无意义,因为 b 在全局上是无序的,你没法利用有序性做快速定位,只能全表扫。这就是“最左前缀原则”的底层逻辑——不是MySQL故意不让你用,是数据结构上就不支持。
同样道理,对索引列做函数操作,比如 WHERE DATE(create_time) = '2024-01-01',优化器在索引树里存的 create_time 原始值和你查询条件的值永远对不上号,除非把索引树整个扫一遍一边扫一边算函数值,否则没法定位。这些都是“物理层面”的失效,索引在结构上就帮不上忙。
1.2 第二层:优化器说不走,成本和代价说了算
还有一类“失效”是很多人忽略的,就是索引明明能用,但MySQL的优化器盘算了一下,觉得走全表扫描更划算,直接选择不走索引。很多人在线上看到 type = ALL 就大喊“索引失效”,其实这个说法不准确,更准确的说法是“优化器放弃了索引”。
MySQL的优化器本质是个成本计算器,它会根据表的行数、索引的区分度、要返回的数据量等因素,估算走索引和走全表扫描的代价,然后选一个它认为更便宜的方案。最典型的就是你查一张只有几千行的表,就算条件列上有索引,优化器也可能选择全表扫描——因为全表扫描就是几万个数据页顺序读一遍,走索引反而要多一次回表随机读,算下来更贵。
所以不要看到 EXPLAIN 结果里 possible_keys 有索引,key 是空的,就急三火四地去加索引或改SQL。先想想是不是优化器觉得全表更快,或者统计信息不准导致判断失误,再去动手。后面第4节会专门讲这种情况。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工作中最常见的几个失效场景,逐个拆原理
2.1 联合索引不满足最左前缀,一切白搭
这个场景我默认为每一位同学都听过了,但还是要提,因为实际出问题最多的就是它。很多人不是不知道最左前缀,而是写SQL的时候根本没意识到自己违反了。
举个例子,假设表结构长这样:
sql复制CREATE TABLE `order_info` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`order_no` varchar(64) NOT NULL,
`create_time` datetime NOT NULL,
`status` tinyint NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_create` (`user_id`, `create_time`)
) ENGINE=InnoDB;
索引 idx_user_create 是 (user_id, create_time) 的联合索引。下面这条SQL是完全可以走索引的:
sql复制SELECT * FROM order_info WHERE user_id = 1001 ORDER BY create_time DESC LIMIT 20;
因为查询条件里带了联合索引最左边的 user_id,MySQL可以利用索引树先定位到所有 user_id = 1001 的节点,而且在这个范围内 create_time 是有序的,排序都可以省了。
但如果有一天你把SQL写成这样:
sql复制SELECT * FROM order_info WHERE create_time > '2024-01-01' AND create_time < '2024-02-01';
那就完全走不了 idx_user_create,因为查询条件没有包含 user_id。索引树里 create_time 全局无序,优化器只能扫全表。
还有一种常见情况,就是条件里带了最左列,但中间跳过了某一列。比如索引 (a, b, c),你写 WHERE a = 1 AND c = 2,这时候只有 a 能用索引,c 用不上。很多人以为索引里包含了 c 就能用,其实在 a 值确定的情况下,c 在索引里依然是无序的,因为它要先按 b 排序。
注意:在MySQL 8.0里有一个“跳跃扫描”(Skip Scan)优化,可以在某些场景下自动处理跳过最左列的情况,但它要求前导列区分度低,而且不是万能解,不能指望它兜底。
2.2 like前面带通配符,B+树的有序性彻底用不上
这是面试最爱问的一个场景。其实理解了B+树的有序性,这个就特别容易记。
索引列的字符串在B+树里是按照字典序排好的。如果你要查 name LIKE '张%',MySQL可以非常高效地在索引树里定位到“张”开头的第一个节点,然后顺序往下扫,直到下一个字开头为止。这是利用有序性做范围扫描,很酷。
但如果你写 name LIKE '%三',情况就完全不同了。因为通配符在最前面,你根本不知道要从哪个位置开始扫,索引树的有序性对你来说完全没用。你只能选择把整个索引树扫一遍(Index Scan),也就是遍历所有的叶子节点,判断每个值是否匹配。如果数据量很大,优化器甚至直接选择全表扫。
还有个细节,LIKE '%三%' 这种写法,有些版本里如果索引是覆盖索引(要查的字段都在索引里),MySQL可能会走 index 级别的全索引扫描,就是为了避免回表。这个属于优化器的权衡,但本质上依然不是“高效利用索引”,数据量大了一样扛不住。
2.3 对索引列做函数或运算,索引列的“原值”找不到了
这个场景在线上特别常见,而且隐蔽性很强。我遇到过一个真实案例,一条SQL原本跑得挺快,后来突然在慢查询日志里频繁出现:
sql复制SELECT * FROM payment_record
WHERE DATE(create_time) = CURDATE()
AND merchant_id = 888;
开发同学的本意是查“今天的所有支付记录”,但他不知道 DATE(create_time) 这个写法直接让索引失效。因为索引树里存的是 create_time 的原始值,比如 2024-06-01 10:23:45,而你查询条件是 DATE(create_time) = '2024-06-01',MySQL需要对索引列上的每个值都调用一次 DATE() 函数,然后才能比较。这等于在索引列上做了一次隐式计算,B+树的快速定位能力完全用不上。
MySQL 8.0.13开始支持函数索引,你可以直接给 DATE(create_time) 建一个索引。但更通用的做法是建一个生成列,或者干脆在业务层把查询区间算好,写成 WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00'。后者在绝大多数场景下才是最优解,因为它在不改表结构的前提下就能让索引生效。
类似的情况还有对索引列做算术运算,比如 WHERE price * 1.1 > 100。正确的写法是把运算挪到等号右边,写成 WHERE price > 100 / 1.1。这样索引列保持原样,B+树才派得上用场。
2.4 隐式类型转换,看起来在查索引列,实际上索引列在偷偷转换
这个坑我估计新手踩过之后会记很久。直觉上感觉不出问题,比如有个 phone 列是 varchar 类型,建了索引。某天你为了省事,直接这么写:
sql复制SELECT * FROM user WHERE phone = 13800138000;
表面上看,phone 列有索引,条件也匹配,应该没问题。但MySQL的隐式类型转换规则是:当字符串和数字比较时,会把字符串转成数字。也就是说,上面这条SQL实际相当于:
sql复制SELECT * FROM user WHERE CAST(phone AS SIGNED) = 13800138000;
这不就是第2.3节说的“对索引列做函数操作”吗?索引自然就失效了。
反过来也一样。如果列是数字类型,你写成 WHERE user_id = '1001',MySQL会把字符串 '1001' 转成数字再比较,这种情况下索引列没被加工,走索引没问题。所以判断规则记住一句话:比较时被转型的是索引列,索引就会失效;被转型的是普通值,索引安然无恙。
那问题来了,怎么知道MySQL到底把谁转型了?最简单的方法就是把SQL拿到 EXPLAIN 里看一眼,如果 key_len 异常或者 type 变成 ALL,基本就是类型转换在作祟。更准确的方法是对表执行 SHOW WARNINGS,MySQL会把改写后的SQL打印出来,一目了然。
2.5 OR连接左右两边只要有一侧没索引,整体就会退化
OR的坑比较让人无语。有时候你明明已经把条件列都建了索引,但SQL还是慢,可能问题出在OR的另一侧。
sql复制SELECT * FROM order_info
WHERE order_no = '20240601001'
OR status = 1;
假设 order_no 有索引,status 没索引,那么这条SQL很难走索引。为什么?因为OR的含义是“满足任意一个条件即可”,MySQL如果走 order_no 的索引,只能拿到 order_no 匹配的那部分结果,还得再想办法把 status = 1 的数据也查出来合并。要做这件事,要么扫全表,要么做索引合并(Index Merge),但索引合并本身也是需要条件的,而且MySQL不一定认为它更划算。
所以大多数情况下,OR两侧只要有一侧没有索引,整条查询就会退化成全表扫描。解决思路有几个:
- 把
status列也加上索引,让两侧都有索引可用。 - 改写成
UNION ALL,把两个条件拆开分别走各自的索引再合并。 - 如果OR两侧查出来的数据量都不大,用
UNION ALL通常比一个复杂OR要稳得多。
我个人的建议是:先看业务语义能不能改成两个查询合并,不能的话再考虑补索引。因为OR的索引合并(Index Merge)在某些版本下有时会选错执行计划,稳定性不如拆分查询。
2.6 范围查询之后的字段排序失效
这个场景适合在讲联合索引的时候一起说。假设我们有联合索引 (a, b),也就是 (user_id, create_time)。下面的SQL可以走索引,而且索引能帮上排序:
sql复制SELECT * FROM order_info
WHERE user_id = 1001
ORDER BY create_time DESC
LIMIT 10;
因为 user_id = 1001 是等值条件,在这个等值范围内,create_time 是有序的,直接顺着索引取前10条就行。
但如果改成范围条件:
sql复制SELECT * FROM order_info
WHERE user_id > 1000
ORDER BY create_time DESC
LIMIT 10;
情况就不一样了。此时 user_id > 1000 是一个范围条件,在这个范围内,create_time 并不是全局有序的。为什么?因为联合索引是先按 user_id 排序,user_id 相同时才按 create_time 排序。user_id 从1001到1002跨段的时候,create_time 的顺序是会重新开始的。
所以MySQL就算用了 (user_id, create_time) 索引,也没法借助索引直接完成排序,必须把命中的所有行拿出来,做一次 filesort(文件排序)。这就是为什么有些SQL看起来索引KEY被用上了,但执行计划里还挂着 Using filesort。
遇到这种场景,要么缩小范围条件,要么考虑换一个以 create_time 为第一列的索引来支撑排序场景,要么接受 filesort,同时把 sort_buffer_size 调大一点。关键是要看懂执行计划,别被“用了索引”蒙蔽双眼。
3. 线上真的遇到索引失效,怎么一步步查
3.1 第一步:从慢查询日志里捞可疑SQL
线上排查的第一步不是打开IDE改SQL,而是先找到“哪条SQL慢”。MySQL的慢查询日志就是干这个的。确认一下你的实例是不是开了慢查询日志:
sql复制SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
long_query_time 默认是10秒,线上建议调到1秒甚至0.5秒。如果你确定某个业务接口近期变慢,可以用 mysqldumpslow 或者直接查 performance_schema 里的统计信息,把热门的慢SQL捞出来。
bash复制mysqldumpslow -s at -t 10 /var/log/mysql/slow.log
这个命令按平均查询时间排序,取前10条。拿到慢SQL之后,先把完整的执行计划看一看。
3.2 第二步:EXPLAIN别只看type,关键看rows和key_len
很多同学用 EXPLAIN 就是看一眼 type 是不是 ref 或 range,不是就喊索引失效,这个习惯要改。执行计划里信息量最大的两个字段是 rows 和 key_len。
rows 表示优化器预估要扫描多少行。如果一条SQL预估扫描的行数接近全表行数,就算 key 里有索引,那也是“名义上走了索引,实际上没占到便宜”。常见的优化器这种判断和真实数据的区分度有关系,后面细讲。
key_len 表示MySQL在索引里实际使用的字节数。这个值特别能说明问题。比如联合索引 (user_id, create_time),user_id 是 bigint,占8字节,create_time 是 datetime,占5字节(MySQL 5.6+)。如果执行计划里 key_len = 8,说明MySQL只用到了联合索引的第一列;如果 key_len = 13,说明两列都用了。通过 key_len 的变化,你能精确判断“索引到底用到了哪一层”,这是排查最左前缀问题的重要抓手。
补充一个细节:varchar 的 key_len 计算方法是 varchar长度 * 字符集最大字节数 + 2(变长长度标识)+ 1(如果允许NULL)。比如 varchar(50) 在 utf8mb4 下就是 50*4+2+1 = 203 字节。记住这个公式,看到 key_len 异常就能反推是哪一列没被用上。
3.3 第三步:优化器为什么没选索引,用Optimizer Trace看
如果 EXPLAIN 显示 possible_keys 里有索引但 key 是空的,说明优化器权衡之后决定不走索引。这时候你想知道它到底怎么想的,可以用MySQL的Optimizer Trace。
sql复制SET optimizer_trace='enabled=on';
SELECT * FROM order_info WHERE create_time > '2024-01-01';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace='enabled=off';
OPTIMIZER_TRACE 里会包含优化器的完整决策过程,包括每个访问路径的估算成本、回表成本、排序成本等。你可以在 rows_estimation 部分看到优化器估算的扫描行数,在 considered_execution_plans 部分看到它最终为什么选了全表扫描。
线上排查比较紧张的时候,可以不用看全量跟踪结果,重点看 cost_info 和 rows。只要发现优化器估算的扫描行数异常偏大(比如实际只有1万行,它估算成1000万行),大概率就是统计信息不准——这时候别改SQL,先执行 ANALYZE TABLE 刷新统计信息。
4. 别被“数据量小”骗了:优化器选择背后的成本逻辑
4.1 数据量小时全表扫描反而更优
索引不是万能的,它的成本在于:查二级索引(B+树定位)然后回表查数据。如果一张表数据量很小,比如几千行,数据页可能就几十个,InnoDB顺序扫一遍非常快,因为顺序IO比随机IO快一个数量级。走索引的话,反而要多一次索引树的查找和回表随机读,两次IO,显得更“贵”。
所以有时候你新建一张表,测试数据就几百行,写任何SQL都是全表扫描,这不是索引失效,是优化器“理性选择”。你用 FORCE INDEX 去强制走索引,实测往往反而更慢。遇到这种情况别慌,往表里灌几百万行真实分布的数据再测,执行计划可能就变了。
4.2 索引基数、统计信息不准会导致优化器判断失误
这是最坑的一种“索引失效”:优化器估算的扫描行数跟实际严重不符。比如一个订单表有1000万行,status 列只有3个值(0待支付、1已支付、2已取消),它的区分度非常低。你写 WHERE status = 1,优化器会认为要扫出好几百万行“已支付”的数据,回表成本太高,于是放弃索引。
但实际情况可能是大部分历史订单都变成了2,status = 1 只剩几千行,走索引明明很快。优化器之所以判断失误,是因为InnoDB的统计信息是抽样的,不是精确的。抽样时刻的数据分布可能跟你查询时的真实分布不一致。
解决办法是老老实实执行 ANALYZE TABLE order_info; 让统计信息重新采集。如果某个列的数据分布很不均匀,且查询频繁,MySQL 8.0可以考虑为这列建直方图(Histogram),给优化器提供更精确的数据分布参考。
sql复制ANALYZE TABLE order_info UPDATE HISTOGRAM ON status WITH 4 BUCKETS;
直方图适合那种索引没有被创建,只能扫全表的低区分度列,建了它能帮优化器更准确地估计扫描行数。但注意,直方图只在索引不可用的情况下才发挥作用,如果列上有索引,优化器更倾向于用索引统计数据,而不是直方图。
4.3 刚插入大量数据后先ANALYZE TABLE再谈优化
这个经验我在实际运维中踩过。有次批量导入了几百万条数据,导完直接上线,结果线上接口立刻变慢。排查了一圈,慢SQL的执行计划显示原本该走索引的查询变成了全表扫描。原因就是InnoDB的统计信息没有更新,优化器依然拿着旧的数据分布做判断。
批量导入、大量删除、OPTIMIZE TABLE 之后,都要留意一下统计信息的状态。最稳妥的做法是做一次 ANALYZE TABLE,让统计信息跟上数据变化。另外,MySQL的 innodb_stats_auto_recalc 默认是开启的,但不是每次变更都立即触发重新采样,有些情况下需要手动触发。
5. 不只是数据库的锅:业务侧的正确解法
5.1 冗余字段解决函数索引问题
我之前遇到一个日志表,查询条件经常是 WHERE MONTH(create_time) = 5 AND YEAR(create_time) = 2024,每次都是全表扫。这种“函数套在索引列上”的SQL,最稳妥的解法是加一个生成列,把月份和年份单独存下来。
比如:
sql复制ALTER TABLE access_log
ADD COLUMN log_month TINYINT GENERATED ALWAYS AS (MONTH(create_time)) STORED,
ADD KEY idx_log_month (log_month);
然后查询改成:
sql复制SELECT * FROM access_log WHERE log_month = 5;
MySQL 8.0可以直接用函数索引,写法上更简洁:
sql复制ALTER TABLE access_log ADD KEY idx_month ( (MONTH(create_time)) );
但8.0之前的老版本,生成列方案是更通用的做法。冗余列听起来“占空间”,但如果你是为了让一条高频SQL稳定走索引,这点空间完全值得。
5.2 覆盖索引让查询压根不碰回表
覆盖索引是“索引失效”问题最好的朋友,因为它连回表都能省掉。如果你的SQL只查少数几个字段,而这些字段恰好都包含在某个二级索引里,MySQL就不需要回表查聚簇索引,直接从索引树的叶子节点把数据取出来。这种情况下,虽然走了二级索引,但IO开销比普通回表小得多,查询速度会快很多。
看执行计划时,看到 Using index 就说明走了覆盖索引。比如:
sql复制SELECT user_id, create_time FROM order_info
WHERE user_id = 1001;
如果有一个 (user_id, create_time) 的联合索引,这条SQL查的字段都在索引里,连主键都不需要回表。
在排查慢SQL的时候,我会刻意检查SELECT的字段列表,如果能通过调整查询字段或者增加冗余字段来让SQL变成覆盖索引,一般都比单纯加索引效果更好。尤其是那种高频查询、查询字段固定的接口,值得专门设计覆盖索引。
5.3 改写SQL的几种实用姿势
改SQL是解决索引失效的最直接手段,但改写时要注意保持语义一致。我常用几个姿势:
- 函数操作移到右边:
WHERE DATE(create_time) = '2024-06-01'改成WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00'。 - OR改UNION ALL:当OR两侧条件各自能走索引时,拆成
UNION ALL往往能让优化器分别走索引,再合并结果。 - 子查询改JOIN:有些情况下子查询会导致外层条件无法下推,改写JOIN之后索引利用反而更好。但注意JOIN产生的临时表和去重问题,不是所有子查询都要改。
IN改EXISTS或JOIN:当IN子查询的表很大时,优化器可能选择全表扫外层表,改写成EXISTS或JOIN能利用外层表的索引。- 分页深翻页优化:
LIMIT 100000, 20这种深分页会导致大量无用扫描,改成先查子查询拿主键ID再关联回表,效率提升明显。
5.4 把索引规则写进Code Review清单
最后这条是治本的。SQL的性能问题,靠DBA线上救火是救不完的。最好的时机是在代码评审阶段就拦住。我强烈建议在团队的Code Review checklist里加几条:
- 新增SQL执行计划里是否有
type = ALL,且表数据量预计超过10万行。 - WHERE条件里的索引列有没有被函数、运算、隐式类型转换包裹。
- 联合索引的使用是否遵守最左前缀。
- 是否有
SELECT *这种可以改成覆盖索引的高频查询。 - 排序字段是否跟联合索引的字段顺序匹配,有没有不必要的
filesort。
这几条看起来基础,但真的能拦下大量线上隐患。毕竟很多索引失效问题,是刚写成SQL的那一刻就已经注定了,后面靠数据库调优只能擦屁股,不能替代设计。
6. 索引失效常见问题速查表
| 场景 | 现象 | 底层原因 | 排查建议 |
|---|---|---|---|
| 联合索引缺最左列 | 执行计划 key 为空或 key_len 很短 |
B+树按最左列排序,跳列后无法定位 | 调整查询条件或索引列顺序 |
| LIKE前缀通配符 | 全表扫描或全索引扫描 | 无法确定B+树起始扫描位置 | 避免前缀通配,改用全文检索或前缀范围 |
| 索引列套函数/运算 | type 变ALL或index |
索引列的值被加工,与原值无法比较 | 函数移到右侧,或使用生成列/函数索引 |
| 隐式类型转换 | type 变ALL |
MySQL把索引列隐式转换,索引失效 | 检查字段类型与参数类型是否一致 |
| OR一侧无索引 | 全表扫描 | OR语义导致必须有全部数据 | 补索引或拆分UNION ALL |
| 范围查询后的索引列 | Using filesort 或 key_len 缩短 |
范围条件之后联合索引无序 | 调整索引列顺序或优化排序字段 |
| 统计信息不准 | possible_keys 有索引,key 为空 |
优化器按过时统计估算成本 | 执行 ANALYZE TABLE,必要时建直方图 |
| 数据量太小 | 全表扫描比索引快 | 顺序IO优于随机IO | 灌大数据量测试 |
排查顺序建议:先看 EXPLAIN 的 type、key、rows、key_len,再结合 SHOW WARNINGS 看MySQL实际执行的改写SQL,最后用 OPTIMIZER TRACE 深挖成本决策。这三板斧下来,90%的索引失效问题都能定位到具体原因。
最后顺手分享一个我自己的小习惯。每次给表加完索引,我不会直接上线,而是先把目标SQL跑一遍 EXPLAIN ANALYZE(MySQL 8.0.18+),看实际执行时间和预估是否一致,再多造几种数据分布测一下执行计划是否稳定。索引不是加完就结束的,它是跟你业务数据分布强相关的。数据变了,统计信息变了,执行计划也可能变,索引优化是一个持续跟踪的过程,不是一锤子买卖。
