1. 从一条慢SQL说起:为什么索引明明建了,查询还是慢
先讲个我最近的经历。某天线上一个列表页突然变慢,接口从平时200ms涨到了2秒多。我看了一眼慢查询日志,SQL很简单,就是按订单号和状态两个字段做筛选,而这两列明明在建表时都加了索引。我当时的第一反应是:数据量大了,索引不够用。结果用EXPLAIN一分析,发现执行计划里走的是全表扫描,索引一个都没用上。再仔细看,问题出在查询条件里对索引列做了函数运算,优化器直接放弃索引了。
这类问题在开发中太常见了。很多人以为“加索引=查询快”,实际上索引能不能生效,取决于SQL的写法、字段的类型、优化器的判断逻辑,甚至数据分布。索引失效不是玄学,每一条都有明确的触发条件,也基本都能通过改写SQL或调整设计来规避。
这篇文章把最常见的15个索引失效场景全部梳理了一遍,每个场景都附上反例和正例,以及背后的原理说明。内容不算高深,但很实用,适合刚接触索引优化的新人,也适合写了几年SQL、想系统性自查的老手。要是你能把这篇看完,以后面试被问“索引失效有哪些情况”,至少能说出十条,而且每条都带例子。
2. 兜底认知:优化器、B+树与回表,先说清楚这3个词
在逐个看失效场景之前,有必要先把几个基础概念对齐一下,不然很多场景会看不透。
2.1 优化器是怎么决定走不走索引的
MySQL里有一个查询优化器,它的职责是拿到一条SQL后,根据统计信息、索引结构、表数据量等因素,评估出它认为成本最低的执行方式。这个成本主要包括:读取数据页的IO成本、比较记录行的CPU成本、回表查询的随机IO成本。走索引不一定比全表扫描快,尤其当过滤出来的行数占全表比例很高时,优化器会果断放弃索引。
这解释了一个关键现象:同样的WHERE条件,在小表上走索引,在大表上不走,因为数据量不同,成本评估结果就不同。也解释了为什么有时候你强制用FORCE INDEX反而更慢,因为优化器才是基于成本在做全局判断,手动干预容易弄巧成拙。
2.2 联合索引为什么有“最左前缀”一说
联合索引(也叫复合索引、多列索引)本质上是一棵B+树,排序规则是先按第一列排,第一列相同再按第二列排,以此类推。所以它天生就只能支持“从最左列开始连续匹配”的查询模式。你建了(a, b, c)的联合索引,能用上索引的情况是:只查a、查a和b、查a和b和c。直接查b或者查c,索引就用不上,因为b和c在B+树里不是全局有序的。
这个特性引出了一系列失效场景,后面会反复提到。理解最左前缀,不光是背规则,更要知道背后的排序机制,否则换个字段组合你就不会判断了。
2.3 回表:索引覆盖了才不白跑一趟
InnoDB的主键索引(聚簇索引)叶子节点存的是整行数据,而二级索引叶子节点存的是主键值。当你通过二级索引查询,但需要的字段不在索引里时,MySQL会拿着主键值再去主键索引里查一次完整行,这个过程就叫回表。回表多了,随机IO就多,性能自然差。
覆盖索引的意思是:查询的列恰好都在索引里,不用回表,直接遍历索引就得到结果。这是SQL优化里性价比很高的一种手段。后面讲分页优化时,会用到这个知识点。
有了这些基础,再往下看十五个场景,思路就会清晰很多。
3. 条件写错型失效:这4种写法会让索引直接躺平
这一部分属于写好SQL就能规避的场景,不需要改表结构,不需要动业务逻辑,改了SQL就行。
3.1 对索引列使用函数或表达式计算
反例:
SELECT * FROM orders WHERE DATE(create_time) = '2024-05-20';正例:
SELECT * FROM orders WHERE create_time >= '2024-05-20 00:00:00' AND create_time < '2024-05-21 00:00:00';原因:索引里存的是列本身的原始值,MySQL需要拿索引值和查询条件做比较。如果查询条件是一个函数表达式,那就得先把索引列的原始值都计算一遍,才能比较。这个过程无法使用B+树的有序特性,优化器只能放弃索引走全表扫描。
我见过最离谱的写法是把所有查询都包一层函数,比如WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-05-20',这种写法性能极差。实际上,MySQL从8.0版本开始支持在索引列上建函数索引,比如ALTER TABLE orders ADD INDEX idx_func_create_time ((DATE(create_time))),但绝大多数业务根本不需要走到这一步,把条件改写成范围查询就行了。
注意:不只是DATE函数,任何对索引列做运算的写法都会失效,包括加加减减、字符串拼接、数值计算。判断标准很简单:WHERE后面索引列是否被“包了一层”。
3.2 LIKE以通配符开头,索引直接罢工
反例:
SELECT * FROM articles WHERE title LIKE '%MySQL%';正例:
SELECT * FROM articles WHERE title LIKE 'MySQL%';原因:B+树的索引是按照字符串的字典序排列的,以固定前缀开头时,优化器可以快速定位到起始位置再顺序扫描。但以%开头时,前缀未知,不知道从哪里开始定位,只能全部扫一遍。
这里有个容易误判的点:LIKE '%关键字%' 这种“中间匹配”在搜索场景里确实需要,但代价是索引失效。如果业务必须支持全文搜索,正确做法是引入全文索引或者外部搜索组件,而不是在LIKE上硬扛。如果只是需要前缀搜索,把字段顺序调整一下,保证通配符不在开头就行。
3.3 联合索引不满足最左前缀要求
假设表里有联合索引 idx(a, b, c),下面这些写法都会失效:
-- 跳过第一列,直接查第二列 SELECT * FROM t WHERE b = 1; -- 跳过第二列,直接查第三列 SELECT * FROM t WHERE c = 1; -- 第一列用了范围查询,后面列也受影响 SELECT * FROM t WHERE a > 100 AND b = 1;这里要细讲一下。前面两种非常好理解:b和c在B+树中不是独立有序的,自然没法走索引。第三种是范围条件导致后续列失效,a > 100 之后匹配到的记录是a的多个值,b在这些记录里不保证有序,所以b的条件只能在回表或扫描时逐一过滤。
实操时最常见的错误是把建索引的顺序和查询条件的顺序搞混。WHERE条件里写的是c、b、a,只要查询里包含a且顺序不影响优化器自动调整,就能走索引。优化器会做条件重排,所以不要求SQL里的字段顺序和索引定义一致,但要求查询条件的组合包含最左列。
3.4 OR连接的非索引列,拖累整个查询
反例:
SELECT * FROM users WHERE name = '张三' OR phone = '13800000000';如果name有索引而phone没有,这条SQL会变成全表扫描。
原因:OR条件里只要有一个字段没有索引,MySQL就没法利用索引加速整个查询。它要做的是把两个条件的结果合并,而其中一个需要全表扫,那整个查询的成本就上去了,优化器会干脆放弃索引。
正例方案:
- 如果两个字段都建了索引,MySQL可能会用索引合并(Index Merge)优化,两个索引分别扫描再合并结果。
- 更稳妥的写法是把OR拆成两个查询用UNION合并:
SELECT * FROM users WHERE name = '张三' UNION SELECT * FROM users WHERE phone = '13800000000';这种场景我实际业务里遇到不少。很多时候是历史表结构遗留问题,某个老字段一直没加索引,新代码里加了个OR条件,性能直接从毫秒掉到秒级。排查时用EXPLAIN看执行计划,一眼就能发现key列是NULL。
4. 隐性规则型失效:MySQL的默认行为和类型转换陷阱
这一类和前面不同,不是你主观写错了,而是MySQL在解析时做了隐式处理,导致索引失效。这类问题更具隐蔽性,线上排查难度更大。
4.1 隐式类型转换:字符列和数字比,索引变废纸
反例:
SELECT * FROM users WHERE phone = 13800000000;如果phone字段是VARCHAR类型,而传入参数是数字,MySQL会把phone自动转成数字再比较。一旦对索引列做了类型转换,等同于对列使用了函数,索引就会失效。
正例:
SELECT * FROM users WHERE phone = '13800000000';这个场景我踩过不止一次。最常见的是手机号、身份证号这类用VARCHAR存储的字段,前端传递参数时没注意类型,后端框架自动做了数字处理,结果SQL就变成了数字和字符串比较,索引失效。
判断方法也简单:用EXPLAIN看type列是否是ALL或者index,再看filtered列的值是否异常。更直接的办法是对比两种写法的执行计划。
注意:反过来当字段是数字类型,传入字符串时,MySQL会把字符串转成数字,由于是值被转换而列没被转换,索引通常还能用。所以规则是:不要对列本身做转换,尽量保证传入的类型和列定义一致。
4.2 字符集不一致导致的隐式转换
这个场景比较隐蔽,很多人排查索引失效时根本不会往这个方向想。当两个表关联查询时,如果关联字段的字符集不一致,MySQL会对其中一列做转换,导致索引失效。
典型示例:
SELECT * FROM user_info u JOIN orders o ON u.user_id = o.user_id;如果user表的user_id是utf8mb4,orders表的user_id是utf8,MySQL会先把utf8的列转换成utf8mb4再比较,就相当于对orders表的索引列做了转换,索引失效。
解决办法是保持所有表的字符集一致,统一用utf8mb4。检查方法:
SELECT table_name, column_name, character_set_name FROM information_schema.columns WHERE table_schema = 'your_db' AND column_name = 'user_id';这个坑在老的业务系统里很常见,因为早期建表时默认字符集是utf8,后来新的表统一成utf8mb4,两表关联时问题就暴露了。
4.3 空值判断写法不当
先说结论:IS NULL和IS NOT NULL在一定条件下是可以走索引的,并不会必然失效。真正要避免的是列和NULL做等值或不等值比较。
反例:
SELECT * FROM users WHERE name != '张三'; SELECT * FROM users WHERE name = NULL;第一条会导致索引失效,因为不等于在B+树中没法直接定位,需要扫描所有非空记录再过滤。第二条本身就是错的,SQL里判断空值必须用IS NULL。
如果业务上确实需要过滤掉某个值,而且这个值占比很低,可以改成范围查询来利用索引。比如过滤status = 2,可以写成status > 2 OR status < 2,有些场景下优化器能走索引,但要注意写法别引入OR陷阱。实际上最稳妥的做法是看数据和索引的选择性,值分布均匀时,不等于的过滤效果很差,走全表扫描反而是优化器更合理的选择。
5. 数据分布型失效:优化器的“聪明”有时反而让你措手不及
这类失效场景和SQL写法无关,是数据和索引本身的状态导致的。遇到这类问题,改SQL往往不管用,得从业务逻辑和表设计层面解决。
5.1 优化器放弃索引:数据量太小或过滤比例太高
这是一个我很想重点强调的场景。有些人一看到查询慢就加索引,加完发现没用,就开始怀疑MySQL。但很多时候,优化器放弃索引是合理的。
举个例子,一张表只有500行,你要查询status = 1的记录,而这500行里300行都是status = 1。这种情况下走全表扫描可能只需要读1个数据页,走索引反而要读索引页+回表读数据页,多了一次IO。优化器算完账,直接全表扫。
同理,如果某列的取值区分度很低,比如性别列只有男女两个值,查询性别这个字段的索引基本不会生效,因为选择性太差。
判断方法:用EXPLAIN看rows列和表的总行数做对比。如果rows占比超过全表的20%到30%,优化器大概率不走索引,这属于正常行为。
解决办法也不复杂:如果业务确实需要过滤这种区分度低的列,考虑建联合索引,把区分度高的列放在前面。或者换个思路,把枚举值改成更细粒度的分类。
5.2 统计信息不准确:强制走索引救急但别长期依赖
有些情况下,数据分布其实适合走索引,但优化器拿到的统计信息太旧,算出来的成本不准,导致放弃了索引。这种情况多发生在大量增删改之后,表没有及时做ANALYZE TABLE更新统计信息。
遇到这种情况,可以先用ANALYZE TABLE刷新统计信息,再看执行计划是否变化。如果刷新后仍然不走索引,可以使用FORCE INDEX强制指定索引作为临时手段,但这个操作不建议长期使用,因为数据分布一变,强制索引可能反而更慢。
我在实际项目里就见过一次,业务表每天晚上批量更新大量数据,白天查询有些SQL偶尔走索引有些不走,波动很大。后来排查发现是统计信息在批量更新后没有及时更新,优化器拿到的数据严重失实。加了定期ANALYZE TABLE的任务后,情况就稳定了。
5.3 索引区分度太差,建了等于白建
前面提到过性别列的例子,这里展开说一下。索引的价值在于通过索引快速定位到少量记录。如果一个索引列的值基本一样,比如状态字段只有“有效”和“无效”两种值,而且90%都是有效,那通过索引查“有效”就会返回大量记录,优化器算完成本觉得还不如全表扫。
简单算一笔账:假设表有100万行,status = 1的有90万行。走二级索引查status = 1,先扫描索引找到90万个主键值,再回表查90万次随机IO。全表扫描是顺序读100万个数据页。显然全表扫描更划算。
处理方式:区分度太低的列不要单独建索引,可以作为联合索引的靠后列存在。比如(status, created_at)这种组合,查询时可以先按时间缩小范围再过滤状态,索引能派上用场。
6. 排序与分页型失效:请给ORDER BY和LIMIT多一点尊重
排序和分页是索引优化里很容易出问题的地方,尤其是数据量上来以后。
6.1 ORDER BY没走索引的三种典型原因
原因是排序字段和索引定义不匹配。具体有这三种:
第一种:ORDER BY的字段顺序和联合索引不一致。比如索引是(a, b),但你写了ORDER BY b, a,跟索引顺序正好相反,没法利用索引的有序性,MySQL只能把数据拿出来再做文件排序。
第二种:ORDER BY字段中混有非索引列。比如索引是(a, b),但写了ORDER BY a, c,c不在索引里,整体就没法无缝使用索引。
第三种:ORDER BY和WHERE组合后,联合索引的使用受阻。比如索引是(a, b, c),WHERE a = 1 ORDER BY c,按道理能走索引,但如果WHERE里a用了范围查询,C的有序性就打破了,排序就得单独处理。
解决方案:建索引时把排序字段和过滤字段一起考虑,通常的做法是把过滤等值条件的列放前面,排序字段放后面。比如WHERE a = 1 ORDER BY b,建(a, b)联合索引就能覆盖。
6.2 LIMIT深分页导致越翻越慢
反例:
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;这个SQL看起来简单,实际执行时MySQL要扫描前1000020行,然后把前1000000行全部丢弃。扫描加排序的成本非常之高,而且页码越深越慢。
正例方案:用延迟关联(先查主键,再回表取完整数据):
SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY id LIMIT 1000000, 20 ) tmp ON o.id = tmp.id;原理很简单:子查询只查主键id,走的是索引覆盖,速度快;拿到20个主键后再回表查完整行,回表次数被控制到了20次,而不是100万次。
另外还有一个更优雅的方案:记录上页最大id。如果你的分页是按id倒序排的,可以改成WHERE id < 上一页的min_id ORDER BY id DESC LIMIT 20,这种“key-set分页”方式完全利用索引有序性,翻多少页都很快。缺点是页面跳转(直接跳到第500页)的交互做不了,适合滚动加载的场景。
6.3 ORDER BY RAND():随机排序的隐藏致命伤
ORDER BY RAND()的杀伤力在于,它要对全表所有记录生成随机数再排序,索引完全帮不上忙。小数据量时感受不明显,一旦数据量超过几十万行,查询耗时直接爆炸。
我在某个后台系统的“随机推荐”功能里见过这种写法,数据量到50万行时接口超时到了30秒。后来改成先查主键id列表,再在业务代码里取随机值,最后WHERE id IN (...)查完整数据,接口耗时降到100ms以内。核心思路就一句话:别让数据库做随机排序,把随机逻辑挪到应用层。
7. 进阶优化:覆盖索引是性价比最高的“特效药”
聊完15个失效场景,再补充一个高频使用的优化手段:覆盖索引。它不能直接“修复”失效问题,但很多场景下能绕开回表,让SQL即使不完美也能保持不错的速度。
7.1 覆盖索引怎么用
所谓覆盖索引,就是查询的列全部落在某个索引中,查询过程中不需要回表。
反例(需要回表):
SELECT name, age FROM users WHERE status = 1;假设只有idx_status这个单列索引,就需要先查idx_status拿到主键id,再回表查name和age。
正例(覆盖索引):
CREATE INDEX idx_status_name_age ON users(status, name, age); SELECT name, age FROM users WHERE status = 1;这样查出来的列和WHERE条件都在idx_status_name_age中,直接用索引就能返回结果,省掉回表IO,速度会快很多。
我实际项目里经常用这个思路优化列表接口。比如商品列表,查询条件是类目+状态,展示字段是商品名+价格+缩略图URL,那就不需要SELECT *,而是把常展示的列放进联合索引里。代价是索引占用的空间变大,写入速度会有所下降,所以覆盖索引适合“读多写少”的查询场景。
7.2 回表和覆盖之间的权衡
覆盖索引也不是越宽越好。索引列越多,单条索引记录越长,每个数据页能装的索引条目就越少,B+树的层级可能变高,查询时的IO次数反而增加。所以设计覆盖索引时要围绕高频查询来定,不要为了“全”而把整表字段全塞进去。
我的做法是先统计最频繁的SQL是什么,找出查询频率Top 10的SQL,逐个分析它们的WHERE、ORDER BY、SELECT列,再决定要不要建覆盖索引。低频查询没必要专门建索引,用通用索引应付就行。
8. 实际操作记录:一次线上慢查询的完整排查过程
理论讲完,拿一个真实场景串联一下前面所有内容。可能你的场景不完全一样,但排查思路是通用的。
背景:某电商后台的商品列表接口,按店铺ID、上下架状态、更新时间排序分页。数据量120万行,线上接口从500ms涨到4秒。
第一轮排查,我抓了慢查询日志,发现SQL长这样:
SELECT * FROM products WHERE shop_id = 234 AND status = 1 ORDER BY updated_at DESC LIMIT 0, 20;表上已有索引idx_shop_status(shop_id, status),看起来完全能覆盖WHERE条件,但ORDER BY updated_at还是导致文件排序。EXPLAIN显示type为ref,但Extra列里出现了Using filesort。
这里的问题在于:索引(shop_id, status)帮WHERE过滤出了大量记录,但updated_at排序没法用索引,MySQL先按索引查出几千条记录,再丢给排序缓存处理,耗时自然上去了。
于是我把索引改成idx_shop_status_update(shop_id, status, updated_at),SQL没动,执行计划里的Using filesort消失,接口耗时降到200ms左右。这个案例是典型的“WHERE和ORDER BY混用联合索引”问题,索引列顺序直接决定排序能否走索引。
第二轮优化,发现分页深了以后还是慢。比如LIMIT 80000, 20,耗时又涨到1秒多。用的就是前面说的延迟关联方案,子查询只查主键id,再关联主表取完整数据。改造后的SQL:
SELECT p.* FROM products p INNER JOIN ( SELECT id FROM products WHERE shop_id = 234 AND status = 1 ORDER BY updated_at DESC LIMIT 80000, 20 ) tmp ON p.id = tmp.id;在这个子查询里,查询列只有id,联合索引(shop_id, status, updated_at, id)可以完全覆盖,连回表都省了。最终接口耗时稳定在300ms左右。
第三轮,上线前我加了定时任务,每晚对核心表执行ANALYZE TABLE,避免统计信息过期引发优化器误判。
整轮优化的核心就三句话:让WHERE条件命中联合索引最左列,让ORDER BY列接在过滤列之后,让子查询只查索引列避免回表。
9. 快速自查指南:索引到底走没走,一张图看懂EXPLAIN
排查索引失效最常用的工具是EXPLAIN。我提供一个对照标准:
- type:system > const > eq_ref > ref > range > index > ALL。const和eq_ref级别最高,range是范围扫描,index表示扫描了完整的索引树,ALL表示全表扫描。至少要达到range级别,如果出现ALL,大概率索引失效了。
- key:实际使用的索引名。为NULL说明没走索引。
- rows:预估扫描行数。这个值远大于实际返回行数,说明过滤能力差,可能存在索引失效或区分度不高的问题。
- Extra:重点关注几个值:
- Using index:表示覆盖索引,性能最好。
- Using where:表示在存储引擎层过滤后,又进行了条件过滤,行数过大的话要留意。
- Using filesort:表示走了文件排序,排序索引没生效。
- Using temporary:表示用了临时表,通常在GROUP BY或DISTINCT时出现,要留意效率。
最后给一个通用的排查脚本思路:拿到慢SQL,先EXPLAIN看执行计划;再检查WHERE、ORDER BY、GROUP BY涉及的字段是否和索引列匹配;然后看字符集、类型是否一致;最后看数据分布和统计信息。按这个顺序排查,95%的索引失效问题都能找到原因。
这15个场景不是背下来就完了,关键是理解每个场景背后的原理。索引本质上是一个有序的数据结构,所有失效场景的共性都是“破坏了索引的有序性”或“让优化器算出全表扫描成本更低”。你抓住这两条主线,遇到没见过的场景也能自己推出来,比死记硬背可靠得多。