☰
MySQL索引明明在却不走?六大索引失效场景与排查指南
2026/10/9 11:00:15 网站建设 项目流程

先说一个我昨天刚处理完的线上问题。一条原本几十毫秒的订单查询,某天突然跑到三秒多,接口监控直接飘红。我第一反应不是去看代码,而是把SQL粘到生产库的从机上执行EXPLAIN,结果一眼看到type=ALL,走的是全表扫描。最气人的是,这张表明明建了索引,而且索引就在那儿放着,优化器就是不用。

这类“mysql索引条件都满足,查询却不用索引”的问题,我相信所有用过MySQL的人都遇到过。它不像语法错误那样会直接报错,而是悄无声息地让查询变慢,拖垮接口,甚至在更新数据的时候把锁的范围放大,连累整个业务。这篇就专门聊这个事:索引条件明明在,为什么MySQL不走索引,以及遇到这种情况你该怎么一步步排查、怎么修。

文章主要面向两类人:一类是被慢查询折腾过的后端开发,另一类是刚接触MySQL性能调优、想系统搞懂执行计划的新手。我会把判断依据、常见失效场景、优化器逻辑和实操案例串起来讲,尽量用大白话,让你看完之后能直接对着自己的SQL排查一遍。

1. 先搞清楚“走不走索引”的判断依据

1.1 EXPLAIN是最直接的照妖镜

不管你是开发还是DBA,排查“不走索引”第一步永远是同一件事:用EXPLAIN看执行计划。这步别省,也别靠猜。

EXPLAIN SELECT * FROM orders WHERE order_no = '202401010001';

执行之后MySQL会返回一行结果,里面有十来个字段,重点看这几个:type、key、rows、Extra。

  • type:访问类型,从好到差大致是const > eq_ref > ref > range > index > ALL。如果你看到ALL,那基本就是全表扫描,索引没起作用;看到index说明扫描了整棵索引树,虽然比全表好点,但通常也不是最优。
  • key:实际用到的索引名。如果这一列是NULL,说明这条SQL压根没用索引。
  • rows:MySQL预估要扫描的行数。这个数字越大,查询通常越慢。
  • Extra:额外信息,会出现Using where、Using index、Using filesort这些关键字,每一个都有特定含义。

举个典型例子。某条SQL明明有索引,EXPLAIN出来的结果却是这样的:

type: ALL key: NULL rows: 128000 Extra: Using where

这就说明MySQL选择扫描12.8万行,索引完全没参与。这样一条SQL,数据量小的时候还扛得住,一旦表涨到百万级,必然出事。

1.2 key_len和rows要配合着看

很多新手只看type和key,忽略了key_len,但实际上key_len信息量很大。

key_len表示索引中使用到的字节数。比如一个idx(a,b)联合索引,如果key_len只有4字节,说明只用到了第一个字段a去过滤;如果key_len等于8字节,说明a和b都用上了。这能帮你判断联合索引是否真的被完整利用。

举个例子,id是INT类型(4字节),name是VARCHAR(20)且utf8mb4字符集(最多20*4=80字节,加上变长字段的2字节记录),一个idx(id, name)联合索引,如果key_len=4,说明只用了id;如果key_len=86,说明id和name都用到了。

rows则是优化器估算的扫描行数。注意这是估算值,不是精确值。如果rows显示几万,但实际结果集只有几十条,说明优化器对数据分布的判断出现了偏差,这就是后面要讲的统计信息问题。

另外,看到Extra里出现Using filesort时要注意:它不代表真的在磁盘上排序,而是说MySQL额外做了一次排序操作,没用到索引的有序性。这种SQL就算type是range,排序环节照样拖慢速度。

1.3 先确认“这是不是真的不该走索引”

排查之前一定要破除一个执念:不是所有SQL都必须走索引。有时候优化器选全表扫描,其实是合理的选择。

最常见的场景就是小表。一张表只有几百行数据,全表扫描也就几个数据页的事,走索引反而要额外的IO去读索引页和回表,性能不一定更好。所以当你看到type=ALL时,先别急着骂优化器,看一眼表数据量,如果就几千行,这可能就是最优解。

另一个场景是“过滤后返回的数据占总量的比例太高”。比如一个性别字段,如果查询条件是WHERE gender = 1,而全表90%的数据gender都是1,那MySQL会认为走索引还不如直接扫全表划算。因为走索引查到记录之后还得回表去拿整行数据,回表的随机IO成本比顺序扫描高得多。

所以看到“不走索引”,先冷静判断:是真不该走,还是本该走却因为某些原因没有走。这个判断决定了你接下来的排查方向。

2. 最常见的六种索引失效现场

2.1 对索引列使用函数或表达式

这是最容易踩的坑,也是热词里出现“mysql datepart”这类关键词的原因。很多时候我们写SQL会把日期处理成“天”再做比较:

SELECT * FROM payment_log WHERE DATE(create_time) = '2024-06-01';

看起来人畜无害,但对MySQL来说,DATE(create_time)是对索引列做了函数处理,索引里存的是原始的datetime值,没法直接用来匹配函数结果。于是优化器只能放弃索引,对create_time这一列的每一行都执行DATE()函数,然后逐个比较。

正确的写法是范围查询:

SELECT * FROM payment_log WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00';

这样优化器可以直接在B+树上做范围定位,type能到range。

同理,索引列参与算术运算也是一样的道理:

WHERE age + 1 = 18 -- 错误示范 WHERE age = 17 -- 正确示范

规则很简单:索引列如果要和函数、表达式绑在一起,索引就废了。

2.2 隐式类型转换

这个场景在开发环境几乎测不出来,一到生产环境就暴雷。

典型的例子是订单号字段。建表时用的是VARCHAR类型,但应用层传过来的参数是数字类型,比如Java里的Long:

SELECT * FROM orders WHERE order_no = 202401010001;

MySQL看到等号左边是字符串类型,右边是数字,会做隐式类型转换。问题在于:转换的方向是把字符串转成数字,也就是说,每一行的order_no都要被CAST一下再比较。这一CAST,索引又废了。

怎么判断是不是这个问题?看EXPLAIN里key是不是NULL,再看SQL参数类型和字段定义是否一致。排查命令很简单:

SHOW CREATE TABLE orders\G

看看order_no字段到底是什么类型。如果是VARCHAR,应用侧就要保证传入的值是带引号的字符串。

还有一类比较容易忽略的字符集问题。两张表关联的时候,如果表A的字段是utf8mb4,表B的字段是utf8,MySQL在join时为了能比较,会对其中一侧做字符集转换,这个转换会让另一侧的索引失效。所以跨表关联时,关联字段的字符集和排序规则最好保持一致。

2.3 前导模糊查询

模糊查询用不用得上索引,取决于通配符的位置:

WHERE name LIKE '张%' -- 可以用索引,相当于范围查询 WHERE name LIKE '%张%' -- 不用索引,前导模糊

原因不复杂:B+树的索引是按照字段值顺序排列的,“张%”可以定位到“张”开头的区间,从前到后扫这个区间就行。但“%张%”意味着只要字符串里包含“张”的都算,那只能把整棵索引树或者整张表扫一遍才能确认哪些值符合条件。

如果业务确实需要包含匹配,有几个处理思路:数据量小就全表扫;数据量大可以考虑全文索引(不过中文分词是个麻烦事);更通用的方案是把这类搜索需求丢给专门的搜索引擎。如果只是“以某个固定后缀结尾”的匹配,还可以用反转字段加索引的方式,但这属于偏门技巧,能用上的场景不多。

2.4 OR连接了非索引条件

OR这个关键字非常容易让优化器“放弃治疗”。

SELECT * FROM user WHERE name = '张三' OR age > 30;

就算name上有索引,age上没有索引,MySQL也很难把“用索引查到张三”和“全表扫age>30”的结果合并起来。多数情况下优化器会直接选择全表扫描,因为它宁可简单粗暴也不愿意搞复杂的合并操作。

解决方案有几种。最常见的是拆成两条SQL,用UNION ALL合并:

SELECT * FROM user WHERE name = '张三' UNION ALL SELECT * FROM user WHERE age > 30;

注意如果你不想看到重复记录,用UNION去掉重复,但UNION会附带一次排序去重的开销,所以如果没有重复风险,优先用UNION ALL。

顺着热词里“mysql的or能去重吗”多说一句:UNION本身有去重效果,这是因为UNION会创建临时表并做唯一性检查。但在数据量大、且明确两个条件不会产生重复结果时,UNION的开销是纯浪费,UNION ALL才是正确选择。

2.5 联合索引没遵守最左前缀原则

联合索引idx(a, b, c)相当于建了(a)、(a,b)、(a,b,c)三套索引。如果查询条件里没有第一列a,那这个联合索引大概率用不上。

-- 假设联合索引 idx(user_id, status, create_time) SELECT * FROM orders WHERE status = 1 AND create_time > '2024-01-01';

这个查询完全没带上user_id,那idx这个索引对它是不可用的。因为B+树先按user_id排序,再按status排序,最后按create_time排序。你现在跳过user_id直接按status查,就相当于在电话簿里不查姓氏,直接翻中间找名字叫“建国”的人,没法二分开找。

还有一点很多人忽视:范围查询之后的列会失效。

WHERE user_id = 100 AND create_time > '2024-01-01' AND status = 1;

create_time用了>,那create_time后面的status就用不到索引的有序性了,key_len会告诉你status有没有吃进去索引。想要status也用上索引,得调整条件顺序或者拆分查询。这块MySQL有一个叫“索引下推”(ICP)的特性,能稍微缓解一下,但它不是万能的,不要把ICP当常规方案用。

2.6 ORDER BY和GROUP BY导致的filesort

这种问题一般EXPLAIN里type可能是range甚至ref,看起来索引走了,但Extra里冒出Using filesort,查询还是慢。

看一个标准错误案例:

SELECT * FROM orders WHERE user_id = 100 ORDER BY create_time DESC;

user_id和create_time上各自有单列索引。查询先用user_id索引定位到用户的所有订单,然后要对create_time排序。但由于create_time并不是同一个联合索引里的第二列,MySQL就得把结果集拿出来重新排一遍。

解决办法是把排序字段也塞进联合索引里:

ALTER TABLE orders ADD INDEX idx_user_create (user_id, create_time);

这样查询时,user_id过滤之后,拿到的数据已经天然按create_time排好序,Using filesort就消失了。

GROUP BY本质上也是排序,它需要在分组前先按分组字段排序,所以同理,能跟索引沾上边就尽量让分组字段成为联合索引的一部分。

3. 优化器“不听话”时,是统计信息和成本计算在作怪

3.1 统计信息不准确导致误判

你SQL写得没毛病,索引也建了,但优化器还是选全表扫描,这时候就要怀疑统计信息过期了。

MySQL优化器决定走哪条路,不是拍脑袋,而是根据表的统计信息估算成本。如果统计信息和实际数据差异太大,估算就会出偏差。特别是频繁UPDATE、DELETE的表,索引的基数统计会慢慢不准。

解决办法很简单:

ANALYZE TABLE orders;

执行完之后,优化器手里的统计信息就被刷新了。我处理过好几次类似问题:SQL没变、索引没变、数据量也没暴涨,就是ANALYZE之后查询速度突然恢复正常。这种“玄学”问题的真实原因,就是统计信息太久没更新。

另外可以查一下索引的实际区分度:

SELECT COUNT(DISTINCT order_no) / COUNT(*) AS selectivity FROM orders;

这个比值代表索引的选择性。如果比值太接近1,说明索引列区分度高,是优质索引;如果比值很低,比如0.001,说明该列大量重复,优化器认为用索引不如全表扫。

3.2 回表成本压过索引收益

有时候索引列本身没问题,但你要查的字段太多,导致回表次数太多,优化器算了一笔账,觉得全表扫描更便宜。

举个例子:表里有10个字段,索引只建在create_time一列上。查询条件是WHERE create_time > '2024-01-01',要返回所有字段。假如这个条件命中了50%的数据,那走索引意味着:先在索引树里找到5万条记录的位置,然后再回表读5万次完整行数据。每次回表是一次随机IO,代价不小。此时优化器真有可能选全表扫描,毕竟顺序读一遍物理文件也许更快。

应对策略就是覆盖索引。让SQL要查的字段都包含在索引里,查询就完全不需要回表:

ALTER TABLE orders ADD INDEX idx_cover (user_id, status, amount);
SELECT user_id, status, amount FROM orders WHERE user_id = 100;

这种情况下EXPLAIN里Extra会显示Using index,表示查询所需数据直接从索引树获取,零回表。这也是为什么业内一直强调“不要用SELECT *”的原因之一,查的字段越多,回表成本越高,索引被使用的概率越低。

3.3 判断是不是优化器误判,试试FORCE INDEX

如果你确认SQL没写错、索引没问题、统计信息也更新了,但优化器就是犟着不走索引,这时可以短时间用FORCE INDEX验证一下:

SELECT * FROM orders FORCE INDEX (idx_create_time) WHERE create_time > '2024-01-01';

如果FORCE之后查询变快了,说明确实是优化器成本评估出了问题,或者索引设计有不合理的地方。

但FORCE INDEX只能用来验证,不能当成长期方案。原因很简单:它把你的选择写死了,一旦数据分布变化,这个强制索引可能不再是好选择。并线上这种“打了补丁”的SQL,非常容易被后来接手的人骂。

更稳妥的做法是:通过增加覆盖索引、改写SQL、调整参数(比如optimizer_switch里的某些开关)来引导优化器。实在没办法了才留FORCE INDEX,而且要在代码注释里写清楚原因和有效期。

3.4 更新统计信息之后还是不走?

还有一种情况:统计信息是新的,索引区分度也很好,但优化器依旧不走索引。

这时候要看看是不是SQL本身的写法导致无法使用索引,比如对索引列用了函数、隐式转换,这类情况前面已经说过。还有一种可能是两个索引都能用,优化器选择了另一个索引,你看到key不为NULL,但性能还是差。这时把EXPLAIN里的possible_keys和key对比一下,再用key_len确认是不是选到了不应该用的短索引。

复杂的场景还可以用OPTIMIZER_TRACE看优化器的决策过程,能看到每一步的成本计算数值。这属于进阶手段,大多数情况下前面的排查步骤就够用了。

4. “不走索引”的连带反应:不只是查询慢

4.1 锁的粒度可能被放大

这个点很多开发容易忽略:不走索引的UPDATE和DELETE,危害比不走索引的SELECT大得多。

InnoDB引擎的行锁是加在索引记录上的。如果一条UPDATE通过全表扫描定位目标行,就会扫描过程中碰到大量行,并且在扫描过程中对触达的行加锁。也就是说,你要更新的明明只有一条记录,但整个表的写入操作可能都被堵住。

典型表现就是线上突然大量出现Lock wait timeout exceeded。一查processlist,发现一条UPDATE因为条件列没走索引,正在扫描全表并持有大量行锁,其他会话只能干等。

另外,不走索引的更新还会扩大间隙锁的范围。比如WHERE status = 1这个条件不走索引,扫到哪就锁到哪,间隙锁会把本来不该锁住的插入范围也锁上,严重时直接阻塞业务写入。

排查这类问题,用SHOW ENGINE INNODB STATUS看锁信息,或者查performance_schema.data_locks表,能定位到具体锁等待链。核心对策依然是:让UPDATE/DELETE的WHERE条件必须走索引,宁可先SELECT再用主键去更新,也不要把一个全表扫的更新扔到线上。

4.2 慢SQL叠加大事务,性能直接雪崩

不走索引的SQL一旦出现在事务里,问题会被放大。一个事务里可能有多条UPDATE,每条都全表扫描,事务又长时间不提交,MVCC的版本链会被拉得很长。其他读请求为了找历史版本,得沿着undo log往回找,读放大效应非常明显。

主从架构下还有另一个隐患:主库上这类慢事务执行多久,从库上SQL线程就要等多久。我见过一个案例,主库一条不走索引的UPDATE跑了十来分钟,结果从库延迟直接跳到几十分钟。这时候如果还有人去主库做备份或者大查询,延迟只会越滚越大。

所以监控MySQL的时候,除了看慢查询日志,还要关注Seconds_Behind_Master(主从延迟秒数)。一旦延迟异常,优先去主库看有没有不走索引的大事务在跑。这类问题的根治手段,依然是回到“让条件列走索引”这条根本原则上来。

4.3 用慢查询日志和PROFILE锁定时段

排查“不走索引”是否造成系统性影响,慢查询日志是最直接的证据来源。

SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1;

这样配置之后,超过1秒的SQL会被记录下来。注意线上环境long_query_time别设太短,否则日志量很大。日志里重点关注Rows_examined和Rows_sent:

  • Rows_examined:语句实际扫描的行数。
  • Rows_sent:返回给客户端的结果行数。

如果Rows_examined是十万,Rows_sent只有一百,那大概率就是没用上索引,做了大量无效扫描。

进一步分析,可以用EXPLAIN的rows来对比。慢日志里记录的Rows_examined和EXPLAIN预估的rows如果差距很大,基本可以断定统计信息不准或者执行计划有问题。

5. 一次完整的真实排查案例和速查清单

5.1 案例复盘:订单查询从50ms恶化到2.8s

某业务订单表,数据量180万行,表结构大致如下:

CREATE TABLE orders ( id BIGINT PRIMARY KEY, order_no VARCHAR(32), user_id BIGINT, status TINYINT, amount DECIMAL(10,2), create_time DATETIME, KEY idx_order_no (order_no), KEY idx_user_id (user_id) ) ENGINE=InnoDB;

应用反馈某个订单查询接口偶尔超时,监控显示该SQL执行时间从平均50ms涨到2.8s。

我用EXPLAIN排查:

EXPLAIN SELECT * FROM orders WHERE order_no = 202401010001\G

结果:

type: ALL key: NULL rows: 1800000 Extra: Using where

索引就在那里,但没走。原因是应用侧传参类型是Long,而order_no是VARCHAR,触发了前面讲的隐式类型转换。

修复方案是让应用侧保证传字符串类型,同时SQL里强制加引号:

SELECT * FROM orders WHERE order_no = '202401010001';

修复后EXPLAIN结果变成:

type: ref key: idx_order_no rows: 1

查询时间回到40ms以内。

这个案例里的教训其实不复杂:建索引的人没错,写SQL的人也没错,错的是表结构和传参类型不一致。所以每次建索引之前,先看一下对应字段的类型定义和ORM映射,别让类型不匹配成为隐藏的定时炸弹。

5.2 案例复盘:报表统计的DATE函数惹的祸

另一个案例是运营后台的日统计报表。SQL大概是:

SELECT user_id, COUNT(*) FROM payment_log WHERE DATE(create_time) = CURDATE() GROUP BY user_id;

表里create_time有索引,但这条SQL每次跑都在全表扫描。EXPLAIN结果type=ALL,key=NULL。

原因就是DATE(create_time)这个函数包裹。改写后:

SELECT user_id, COUNT(*) FROM payment_log WHERE create_time >= CURDATE() AND create_time < CURDATE() + INTERVAL 1 DAY GROUP BY user_id;

改完之后type变成range,扫描行数从几百万降到当天数据量,查询时间下降非常明显。

顺带提一句,这个场景里GROUP BY user_id要想完全避免临时表,建一个(user_id, create_time)或者(create_time, user_id)的联合索引会有帮助。具体选哪个方向,取决于业务里哪种查询更频繁。

5.3 速查清单:从问题到定位,十分钟搞定

整理一个我常用的排查顺序,遇到“索引存在但不走索引”直接照做:

检查项操作手段关键结论
确认SQL是否该走索引EXPLAIN看type、rowstype=ALL不一定错,小表全表扫是正常
索引列有无函数/运算看SQL的WHERE和JOIN条件索引列被函数包裹,索引大概率失效
字段类型和参数类型对比SHOW CREATE TABLE与传参类型类型不一致会导致隐式转换
LIKE和OR的写法检查谓词结构前导%和OR非索引条件都会导致失效
联合索引最左前缀用key_len判断跳过首列或范围后断列,索引用不完整
ORDER BY/GROUP BY查Extra里的filesort排序字段应在索引中
统计信息是否过期ANALYZE TABLE统计陈旧会导致优化器误判
回表成本评估看查询列,考虑覆盖索引回表过多时优化器可能弃用索引
FORCE INDEX验证临时强制走索引能验证优化器误判,但别当长期方案

这个清单我用了很长时间,基本能覆盖80%以上的索引失效问题。

5.4 我踩过的几个坑,顺便分享一下

关于FORCE INDEX,我多说一句。刚开始接触这个功能的时候,我觉得它简直是大杀器,SQL一慢就FORCE,效果立竿见影。后来有一次索引列的数据分布发生明显变化,FORCE指定的索引变成了坑,查询反而比全表扫描还慢。那次把我整怕了,之后我给自己定了一条规矩:FORCE INDEX只允许在排查时用,不允许直接留在线上。如果一定要用,必须同时提交一个问题单,记录“为何优化器选错、后续如何根治”。

还有就是建立索引不是越多越好。我见过一张表上建了十几个索引的,写入性能惨不忍睹。每个索引都会拖慢INSERT、UPDATE、DELETE的写入速度(要更新索引树),占用额外磁盘空间。所以排查“不走索引”的时候,也别急着加索引,按照清单找出真正的原因,很多时候加索引恰恰不是最优解。

最后再分享一个冷门但有用的技巧。遇到复杂查询一时半会儿看不出问题时,在WHERE条件里多写几个可能的裁剪条件,甚至调整一下JOIN顺序,都可能让优化器走回正轨。优化器这东西,说聪明也聪明,说笨也笨。它会老老实实按统计信息和成本模型来算,你给它多一个“压低成本”的线索,它就更容易选对路。

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

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

立即咨询