☰
MySQL索引失效全解析:从EXPLAIN到优化器,彻底排查SQL不走索引
2026/10/9 6:22:13 网站建设 项目流程

你有没有遇到过这种情况:一条mysql查询慢得离谱,慢查询日志里躺着好几秒,你登录数据库一看,表上的索引明明都建好了,甚至还是复合索引,可EXPLAIN一跑,key列是NULL,type显示ALL,全表扫描。我处理这种“不走索引条件”的问题少说也有上百次了,这类问题在性能优化里最磨人——不是没索引,而是索引在,优化器不用。

这篇文章我打算把“MySQL不走索引”这件事彻底讲透。从怎么判断SQL真的没走索引,到哪些写法会把索引搞“失明”,再到优化器为什么放着索引不用,最后给你一套可以直接上手的排查流程。不管你是刚接触数据库的新手,还是已经在生产环境里排查过几次慢SQL的开发者,这篇文章都能给你一点新东西。尤其是那些平时文档里不写、只有踩过坑才明白的细节,我会重点讲。

1. 先分清“没走索引”长什么样

很多人一看查询慢就急着加索引,这是典型的病急乱投医。第一步应该是确认它到底有没有走索引、走到了哪一步、卡在了哪个环节。EXPLAIN是唯一靠谱的手段,但前提是你得看得懂它输出的每一列。

1.1 慢查询日志才是问题的起点

生产环境里,你不可能每秒都盯着数据库看,慢查询日志就是最好的“监控摄像头”。MySQL默认的long_query_time是10秒,也就是超过10秒的SQL才会被记录下来,这个阈值对绝大多数业务来说太宽松了,建议调到1秒甚至更低。

-- 临时调整,重启失效 SET GLOBAL long_query_time = 1; -- 确认是否生效 SHOW VARIABLES LIKE 'long_query_time';

这里有个小坑:这个参数是全局的,但已有连接不会立即生效,需要重新建立连接才能读到新值。如果你用的是连接池,最好在调整后观察一段时间,或者直接在配置文件里改好再重启MySQL。另外,log_queries_not_using_indexes这个参数很有用,开启后凡是没走索引的SQL都会进日志,哪怕它只跑了0.1秒。

SET GLOBAL log_queries_not_using_indexes = ON;

从日志里捞出SQL之后,别急着改,下一步是把这条SQL单独拎出来跑一遍EXPLAIN。

1.2 一行EXPLAIN看懂执行计划

EXPLAIN这条命令是排查索引问题的第一工具,没有之一。用法很简单:在查询前面加EXPLAIN,MySQL会返回一个执行计划。

EXPLAIN SELECT * FROM user WHERE mobile = '13800138000';

输出的结果里有几个关键列,我按重要性排个序:

列名代表含义判断标准
type访问类型从好到坏依次是system > const > eq_ref > ref > range > index > ALL
key实际选用的索引名为NULL就是没走索引
rows预估扫描行数数字越大越危险
Extra附加信息出现Using filesort或Using temporary要特别注意

type为ALL是最典型的全表扫描,也是我们在排查慢SQL时最不想看到的结果。type为index虽然表示“用了索引”,但扫的是整个索引树,相当于把索引当成全表来遍历,性能好不到哪里去,这个稍后细说。key列是最直接的判断:如果显示NULL,说明优化器根本没选任何索引。

1.3 覆盖索引与全表扫描的边界

type=index这个状态容易被误解。它确实走了索引,但扫描的是整棵索引树,本质上也是全量数据,成本可能比全表扫描还高。真正的性能优化目标,是type至少到range级别,理想状态是ref或const。

这里必须提一个概念:覆盖索引。如果查询要的字段全部包含在某个索引里,MySQL就不需要回表去主键索引里取整行数据,这种情况EXPLAIN的Extra列会显示Using index。比如你有一个复合索引(a, b, c),然后执行SELECT a, b, c FROM t WHERE a = 1,这个查询的所有字段都在索引里,不需要回表。

加索引时不能光看where条件,SELECT的字段也得考虑进去。能设计出覆盖索引,性能提升是质的飞跃,能从“扫全表”变成“只读索引”。

1.4 索引存在但失效的隐蔽情况:invisible index

MySQL 8.0开始支持不可见索引。这个特性本意是让你在不删除索引的情况下,验证去掉某个索引对性能的影响。但它也有坑:如果你把某个索引设为INVISIBLE之后忘了恢复,SHOW INDEX还是能看到它,可优化器根本不会用它,于是表面上索引在,实际执行却又没走。

排查方法很简单,查一下索引的可见性:

SELECT TABLE_NAME, INDEX_NAME, IS_VISIBLE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = '你的库名';

如果看到IS_VISIBLE是NO,而你的SQL确实需要这个索引,执行下面的语句恢复即可:

ALTER TABLE user ALTER INDEX idx_mobile VISIBLE;

很多同事在8.0版本升级后碰到“索引失效”的诡异问题,查了半天索引都在,最后发现是invisible flag被人动过,或者在创建时不小心加了INVISIBLE关键字。这类问题我不会责怪任何人,因为SHOW INDEX里压根不显示这个标记,容易被忽略。

2. 让索引“失明”的几种写法

我见过很多开发者在索引设计上花了很多功夫,最后却因为SQL写法的问题,让优化器对索引视而不见。这一节是最实战的部分,每一条都是我见过多次的典型场景。你会发现,大部分失效都不是索引本身的问题,而是SQL语句踩了MySQL的规则红线。

2.1 索引列上做了运算或函数调用

这是最经典的一种失效场景,也很容易被新手踩中。只要索引列参与函数运算,MySQL就无法利用B+树的有序性去定位数据,只能老老实实全表扫。

-- 失效写法:对索引列使用了函数 SELECT * FROM order_test WHERE DATE(create_time) = '2025-06-01'; -- 生效写法:范围查询利用索引有序性 SELECT * FROM order_test WHERE create_time >= '2025-06-01' AND create_time < '2025-06-02';

你可能会想,结果不是一样的吗?但优化器没法把DATE(create_time)自动改写成范围条件。B+树的叶子节点按create_time原始值排序,DATE()函数处理后的结果完全打乱了原有的顺序。

同样的道理,在索引列上做算术运算也会失效:

-- 失效:WHERE id + 1 = 10001 -- 生效:WHERE id = 10000

这里有个比较容易忽略的点:优化器能自动让你把10001改写成10000吗?答案是不能,它只会傻傻地认为id+1是一个表达式,没法直接定位。你在写SQL时,应当尽量把运算放在常量那一侧,或者在应用层先算好结果。

2.2 隐式类型转换:一个静默的杀手

这个场景极其隐蔽,因为SQL能正常跑,结果也对,就是慢。原因是比较的两边类型不一致,MySQL被迫做隐式转换。

-- 表字段 mobile 是 varchar(11),条件传的是数字 SELECT * FROM user WHERE mobile = 13800138000; -- 正确写法:注意类型匹配 SELECT * FROM user WHERE mobile = '13800138000';

当varchar列和数字比较时,MySQL会把varchar转成数字来做比较。这一转换落在索引列上,索引就废了。你在EXPLAIN里看到的典型现象是:key为NULL,type是ALL,但SQL本身没有任何报错。

我对这个问题的建议是,排查慢SQL时多留意下条件值的类型。如果你用的是MyBatis等ORM框架,尤其要检查参数是否被包装成了字符串,或者反过来,Java代码里传的是Long但SQL参数没配好。类型推断错误会导致你写对了索引,优化器却不买账。

顺带提一个反例:如果索引列是int类型,条件写字符串'123',MySQL会尝试把字符串转成数字再比较,这种情况下索引是能用的,因为转换发生在列外。但为了可读性和一致性,还是建议两边类型严格对齐。

2.3 最左前缀:复合索引的黄金法则

复合索引是我在性能调优中用的最多的索引类型,但它也是失效高发的重灾区。最左前缀法则说:只有当查询条件用到了复合索引最左侧的列时,索引才会生效。

-- 假设有复合索引 idx(user_id, status) -- 生效:user_id 在最左侧 SELECT * FROM order_test WHERE user_id = 10086; -- 生效:user_id + status,完全匹配 SELECT * FROM order_test WHERE user_id = 10086 AND status = 1; -- 失效:跳过了 user_id,只使用 status SELECT * FROM order_test WHERE status = 1;

我经常用“电话簿”这个类比来解释最左前缀:电话簿先按国家、再按城市、再按姓氏和名字排序。你知道城市和姓氏,但不知道国家,就没法快速定位到人。复合索引和这个道理完全一样。

还有一个容易混淆的点:范围查询之后的条件列会失效。比如索引是(a, b, c),查询a > 10 AND b = 2,b这个条件是没法用到索引的,因为a的范围破坏了b的有序性。所以设计复合索引时,要把等值条件的列放在前面,范围条件的列放在后面。

2.4 OR、LIKE、IN等条件的经典陷阱

OR条件是个老生常谈的坑。如果OR两边的列都能单独走索引还好,但MySQL的优化器有时会为了兼容多个条件而选择全表扫描。

-- 假设 user_id 和 status 都有独立索引 -- 这种写法很容易全表扫 SELECT * FROM order_test WHERE user_id = 1 OR status = 2; -- 拆成两条再 UNION ALL,索引就能用上 SELECT * FROM order_test WHERE user_id = 1 UNION ALL SELECT * FROM order_test WHERE status = 2;

不过这不是绝对的。MySQL有index merge优化,可以分别用两个索引取交集合并,但触发条件比较苛刻,取决于表的大小、数据分布和优化器版本。我一般建议:能用UNION ALL拆分就优先拆,不要赌优化器的merge策略。

LIKE查询要看通配符的位置:

-- 生效:最左匹配 SELECT * FROM user WHERE nickname LIKE '张%'; -- 失效:前导通配符导致索引无法定位 SELECT * FROM user WHERE nickname LIKE '%张';

IN的情况要分类讨论。IN的值数量较少时走索引没问题,但如果你IN了几千个值,优化器算算回表成本,可能选择全表扫。这时候可以尝试拆分成多个小批次IN,或者改用临时表JOIN。

NOT IN和!=这两个条件也容易被优化器抛弃,因为它们意味着“排除少量数据,而不是定位少量数据”,在数据量大的表上,优化器通常会选择全表扫描。如果某个字段的值为0只占1%,但你查NOT IN(0),优化器也未必走索引——它的统计精度不够细,只能按全局比例估算。

2.5 ORDER BY 与 filesort:排序引发的拦路劫

有些查询过滤条件很快,但整体慢,问题出在排序上。Extra列里出现Using filesort就说明MySQL在内存里或磁盘上额外做了一次排序,这个操作开销不小。

-- 假设没有对 create_time 建索引 EXPLAIN SELECT * FROM order_test WHERE user_id = 1 ORDER BY create_time DESC;

user_id条件走了索引,但create_time的排序需要额外处理。原因很简单:B+树是按索引列排序的,你现在要按create_time排,但数据实际是按user_id+create_time的某种顺序存储的,除非索引的键设计恰好和排序需求一致。

解决思路有两个方向:

  1. 如果排序字段是单列,建单列索引;
  2. 如果是复合条件+排序的组合,比如等值条件+排序字段,建一个(user_id, create_time)的复合索引,这样WHERE条件的等值定位和ORDER BY的排序都能落在同一个索引上。

MySQL 8.0开始支持降序索引,对于ORDER BY xxx DESC这种频繁出现的SQL,可以直接在索引里指定DESC,省去排序的额外开销。5.7及以下版本没有这个能力,只能靠filesort硬扛。

3. 优化器为什么放着索引不用

前面讲的都是“因”层面的东西,这一节我要聊“果”背后的逻辑。你以为索引失效是SQL写错了,但很多时候SQL写得没问题,是优化器权衡之后主动抛弃了索引。理解了它的决策逻辑,你才能在设计索引时不走弯路。

3.1 回表成本让索引“看起来不划算”

二级索引(也叫辅助索引)只存了索引列+主键,查询需要返回更多字段时,MySQL要先在二级索引里找到主键值,再回主键索引里取整行数据。这个过程叫“回表”,通常对应随机IO。

我举个例子你就明白了。一张表有1000万行,某个查询条件命中30万行。假如这个条件可以走二级索引,MySQL的算账逻辑是这样的:

  • 通过二级索引定位并读取30万条记录的主键信息;
  • 再回表30万次,每次都涉及随机IO;
  • 而全表扫描只需要顺序读取表数据文件。

30万次随机IO的成本远高于顺序扫描1000万行,优化器当然选全表扫描。所以你会遇到一种情况:单看结果集只有很小比例,但优化器仍然不走索引。原因就是回表成本压过了全表扫描成本。

这也是覆盖索引的杀手锏意义。如果查询的字段都在索引里,回表次数直接降为0,优化器自然愿意走索引。你在设计索引时,一定要问自己:这张表的查询模式里,哪些高频率SQL能做成覆盖索引。

3.2 区分度低,索引等于废的

索引好不好使,取决于一个指标:区分度,也就是数据列的多样性。执行SHOW INDEX FROM,可以看到一个Cardinality字段,它表示索引中不同值的估算数量。Cardinality相比表行数越小,区分度越差。

典型例子是性别字段,只有男、女、未知三种值,如果你建索引WHERE gender = 'male',命中了一半行。这种情况下走索引不仅没帮助,回表成本还翻倍,优化器直接放弃。

我在设计联合索引时,会刻意把区分度高的列放到前面。比如一个订单表,状态字段只有几个值,创建时间几乎每行都不同。那么查询经常是WHERE status = 0 ORDER BY create_time DESC,设计索引时可以考虑(status, create_time)而不是(create_time, status),具体得看查询模式。但核心原则不变:让高区分度的列在索引的左边能更快地把搜索范围缩到最小。

3.3 统计信息过旧与参数改动导致的误判

优化器做成本估算依赖统计信息,而统计信息不会实时更新。如果表经历了大量增删改,统计数据严重偏离实际,优化器就可能做出错误决策。比如它以为某个索引匹配500万行,实际上改了以后只剩500行,于是弃用了索引。

解决办法很直接:

ANALYZE TABLE order_test;

这条命令会重新计算索引的Cardinality,更新统计信息。执行完再跑EXPLAIN,很可能执行计划就变了。我在例行巡检里,都会对频繁变更的大表定期跑ANALYZE TABLE。

还有一个隐藏因素:optimizer_switch系统变量里有各种优化器开关,有些开关会影响索引策略。比如index_merge、condition_pushdown这些选项,如果被人改过,可能导致某些本该生效的优化策略失效。排查时用SHOW VARIABLES LIKE 'optimizer_switch'看一眼,正常情况下保持默认值即可。

4. 一次完整的排查实测

理论聊了一堆,下面我把一次真实排查过程完整重现一遍。你跟着做一遍,遇到类似问题就心里有底了。

4.1 构造测试表和测试数据

为了演示,我建一张订单表,模拟真实业务场景:

CREATE TABLE order_test ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_no VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, KEY idx_user_status (user_id, status), KEY idx_create_time (create_time) ) ENGINE=InnoDB;

插入测试数据时注意,user_id的数据分布要有区分度,比如1000个用户各有若干订单:

INSERT INTO order_test (user_id, order_no, status, create_time) SELECT FLOOR(RAND() * 1000) + 1, CONCAT('ORDER', LPAD(id, 8, '0')), FLOOR(RAND() * 4), NOW() - INTERVAL FLOOR(RAND() * 365) DAY FROM information_schema.COLUMNS LIMIT 50000;

这里借用了系统表的行数生成随机测试数据,快速又方便,不必手写循环。

4.2 跑EXPLAIN看失效与生效的完整对比

先看一个正常走索引的例子:

EXPLAIN SELECT * FROM order_test WHERE user_id = 123;

正常情况下的执行计划里,key列显示idx_user_status,type是ref,rows预估在几百行左右,说明优化器顺利使用了复合索引。

再看失效的写法:

EXPLAIN SELECT * FROM order_test WHERE user_id + 1 = 124;

执行计划里type变成ALL,key变成NULL,rows显示5万行,这就是索引列上做运算的典型后果。

再看只查status的情况:

EXPLAIN SELECT * FROM order_test WHERE status = 1;

因为复合索引最左列是user_id,这里是status条件,不满足最左前缀法则,所以同样无法走idx_user_status。除非status的区分度低,优化器更不可能选它,通常就是全表扫。

再看一个排序场景:

EXPLAIN SELECT * FROM order_test WHERE user_id = 123 ORDER BY create_time DESC;

user_id条件走了索引,但create_time排序没有可用索引,Extra列出现Using filesort,这就是查询慢的元凶。改成先查一下索引设计能不能覆盖排序需求。

4.3 用optimizer_trace看优化器“内心戏”

EXPLAIN只能告诉我们结果,想知道优化器具体怎么算账、为什么放弃索引,需要用到optimizer_trace。这个方法我日常排查时经常用,能直接看到优化器在候选索引之间的成本比较。

-- 开启追踪 SET optimizer_trace = "enabled=on", end_markers_in_json=on; -- 执行你要排查的SQL SELECT * FROM order_test WHERE user_id + 1 = 124; -- 查看追踪结果 SELECT * FROM information_schema.OPTIMIZER_TRACE;

输出是一大段JSON,重点看access_path部分,里面有rows_estimation和cost字段。你会看到优化器对全表扫描算过一笔账,如果回表成本太高,它会明确选择全表扫描,甚至根本不会把某个二级索引列入候选。

不要忘了在排查完成后关闭追踪:

SET optimizer_trace = "enabled=off";

这个工具在生产环境尽量少开,因为它会记录所有SQL的优化过程,额外开销不小。

4.4 解决这类问题的合理检查顺序

根据我的经验,解决“索引在但不用”的问题,顺序比技巧重要。我给自己定了一套检查流程,你也可以直接抄走:

  1. 先跑EXPLAIN,确认key是否为NULL、type是否ALL、Extra有没有filesort;
  2. 再用information_schema检查索引可见性,排除invisible index的坑;
  3. 检查SQL写法:索引列有没有函数运算、条件类型是否与列类型匹配;
  4. 检查复合索引顺序是否满足最左前缀法则;
  5. 看统计信息,必要时执行ANALYZE TABLE;
  6. 如果是迫不得已的验证,可以用FORCE INDEX强制走索引对比一下性能。

FORCE INDEX是治标手段,我不建议长期使用,它纯粹是为了验证“如果走这个索引,是否更快”。如果FORCE INDEX确实快但优化器死活不用,说明统计信息确实有偏差,或者成本模型本身的问题,这时候该做的是更新统计信息、调整索引结构,而不是用hint硬扛。

5. 个人经验与最后的建议

排查慢SQL这件事,我踩过的坑多了,最后形成了一套自己的习惯。想分享给你的是:SQL上线前必须先看执行计划,不要只在出问题才想起来。我在团队里定的规矩是,所有涉及查询的SQL变更,测试环境跑一遍EXPLAIN,重点看key列有没有掉索引,一旦掉了就要解释清楚为什么。

索引设计也不要迷信“越多越好”。每个索引都会占用磁盘空间,并拖慢INSERT、UPDATE、DELETE的写入性能,因为每次写入都要同步维护所有索引。我看到不少项目里一张表建了十来个索引,查询是快了,写入慢到报警。索引少而精,服务的是真实查询模式,而不是“可能用得上”的猜测。

压测数据量也很关键,用100条数据跑EXPLAIN看不出问题,索引失效在数据量小的时候根本不会暴露。尽量在测试环境造出和生产一个数量级的数据再验证,否则你上线前看到的执行计划和生产环境的完全不是一回事,这个教训我是真金白银买来的。

最后再分享一个小技巧:每次排查完“不走索引”的问题,都值得把SQL、对应索引、执行计划的变化记录下来。下次遇到类似场景,直接翻之前的笔记,往往几分钟就能定位到问题。你会发现,90%的索引失效问题逃不开这一篇讲到的几个套路:函数运算、隐式转换、复合索引乱序、统计信息不准。把这几关逐一排查,效率比盲目试错高太多了。

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

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

立即咨询