Java

MySQL 索引为什么没按预期生效:SQL 明明走了索引,怎么还是慢

建了索引、执行计划里也能看到 key,但 SQL 还是慢,问题往往不在“索引有没有生效”,而在过滤、排序、扫描范围或回表这几步里,哪一步没有按预期接上。

  • MySQL
  • 索引
  • SQL优化
  • 数据库
12 分钟阅读

线上排查慢 SQL 时,经常会遇到一种很别扭的情况:

  • 这个字段明明建了索引
  • EXPLAIN 里也不是完全没走索引
  • 但 SQL 还是慢,RT 还是下不来

很多人这时候会直接下结论:索引失效了。

可真实现场里,问题往往没这么简单。

更多时候,不是索引完全没用上,而是你原本期待数据库这样执行:先靠索引把数据范围迅速缩小,再顺着索引把排序接上,最后低成本拿到结果;结果真正执行时,某一步没有按预期接上,整条 SQL 就开始变慢了。

所以这篇不再讲“8 个常见原因”。如果你现在面对的是一条“明明建了索引,怎么还是慢”的 SQL,更有用的排查方式是直接看执行计划:数据库到底在哪一步,没按你以为的路径走。

先别急着看“走没走索引”,先看你原本以为它会怎么跑

比如订单列表有这样一条查询:

select id, order_no, amount, create_time
from orders
where user_id = 1001
  and status = 'PAID'
order by create_time desc
limit 20;

表上有联合索引:

idx_user_status_ctime(user_id, status, create_time)

很多人看到这里,会默认觉得这条 SQL 应该很顺。

因为在脑子里,它大概会这样执行:

  1. 先用 user_id 找到这个用户的订单
  2. 再用 status 把已支付订单筛出来
  3. 顺着索引里的 create_time 倒序取前 20 条
  4. 中间不用额外排序,也不用多扫很多无关数据

真正排查时,关键不是看“字段都在索引里”,而是看执行计划有没有把这 4 步真的连起来。

只要其中有一步断了,这条 SQL 就会出现一种常见错觉:索引看起来在,但速度没有按预期好起来。

第一种情况:执行计划里虽然有 key,但索引根本不是你以为的那个入口

还是这个联合索引:

idx_user_status_ctime(user_id, status, create_time)

如果 SQL 变成这样:

select id, order_no
from orders
where status = 'PAID'
order by create_time desc
limit 20;

很多人会困惑:statuscreate_time 不是也都在索引里吗?

问题在于,你期待的那条索引路径,入口其实是 user_id。现在 SQL 一上来并没有先限定 user_id,数据库就很难顺着这条联合索引,把过滤和排序一路接下去。

这时执行计划里可能仍然会出现某个索引,但那不代表它走的是你脑子里那条“先过滤、再顺序取数”的理想路径。

这里真正要看的不是“有没有 key”,而是:

  • 选中的 key 是不是你以为能承接整条查询的那条索引
  • typerows 有没有说明它其实扫了很大一片范围
  • Extra 里是不是开始出现额外排序或额外过滤

这一类慢,不是因为“索引字段不够”,而是查询起点和索引起点不是一回事

第二种情况:过滤条件写上去了,但你把索引可用的比较方式改掉了

这类问题在代码里特别常见,因为 SQL 看起来完全合法。

比如:

select id, title
from article
where date(create_time) = '2026-03-20';

或者:

where cast(user_id as char) = '1001'

或者:

where amount * 100 > 5000

这几种写法有个共同点:你看到的是“条件里用了索引字段”,但数据库真正面对的已经不是原始列值,而是一个表达式结果。

一旦变成先算再比较,索引原本那种可直接定位、可顺序扫描的优势,往往就接不上了。

所以如果执行计划表现得很差,这时候不要停在“为什么字段有索引却没用上”,而要把问题说得更具体一点:

这条 SQL 不是没写索引字段,而是没有按索引原本能利用的方式去比较。

比如按天查时间,更稳的写法通常是:

select id, title
from article
where create_time >= '2026-03-20 00:00:00'
  and create_time < '2026-03-21 00:00:00';

这样改的重点,不是为了“语法规范”,而是为了让数据库还能沿着索引顺序去找,而不是把整列先算一遍再判断。

第三种情况:过滤那一步还行,但排序已经没接上

很多“明明有索引还是慢”的现场,真正拖垮性能的不是过滤,而是排序。

看这条 SQL:

select id, order_no, amount, create_time
from orders
where status = 'PAID'
order by create_time desc
limit 20;

假设表上只有这两个单列索引:

idx_status(status)
idx_create_time(create_time)

这时候很多人的直觉是:过滤列有索引,排序列也有索引,怎么还会慢?

因为数据库执行的不是拼图游戏,不是两个索引都存在就能自动把效果叠起来。你真正希望它做的是:先筛出 status = 'PAID',再顺着同一条有序路径直接拿到最新的 20 条。

如果过滤和排序不是同一条索引在承接,执行计划里就很容易出现这种情况:

  • 先用 idx_status 找出大量已支付订单
  • 再把结果单独排序
  • Extra 里出现 Using filesort

这时 SQL 慢,不是因为过滤完全没走索引,而是过滤之后,排序那一步已经掉到索引外面去了。

所以碰到 where + order by + limit 这类查询,别只盯着 key 看。还要继续问:

  • 过滤和排序是不是由同一条索引顺着完成的
  • 还是说过滤完以后,数据库还得单独再排一次

如果是后者,索引就只是帮了一半。

第四种情况:执行计划显示走了索引,但扫描量还是大得离谱

有时执行计划最容易误导人的地方,就是 key 不是空的。

大家一看到这里,第一反应往往是:那至少索引生效了。

但如果你同时还看到这些信号,就要小心:

  • rows 依然很大
  • 实际只需要几十条结果
  • Extra 里还有 Using whereUsing filesortUsing temporary

比如:

select id, order_no, amount, create_time
from orders
where status = 'PAID'
order by create_time desc
limit 20;

执行计划可能像这样:

key: idx_status
rows: 180000
Extra: Using where; Using filesort

这说明数据库确实用了 status 这个索引,但它仍然要扫出 18 万条候选记录,再从里面继续过滤、继续排序。

这种场景下,说“索引生效了”不算错,但对排查没什么帮助。因为真正的问题是:这条索引虽然参与了执行,但并没有把成本压到你想要的程度。

所以判断“索引为什么没按预期生效”时,我一般至少会连着看这几个点:

  • key 选了谁
  • rows 估计会扫多少
  • Extra 里有没有额外排序、临时表、额外过滤

只看 key,特别容易把“索引参与执行”误看成“索引已经解决问题”。

第五种情况:前面都对了,但回表把成本又抬起来了

还有一种情况,经常出现在列表页查询里。

比如:

select *
from orders
where user_id = 1001
order by create_time desc
limit 20;

假设 (user_id, create_time) 这条索引确实能承接过滤和排序,那这条 SQL 是不是就一定够快?也不一定。

因为你查的是 select *。数据库就算先沿着索引找到了目标记录,后面往往还要再回表,把整行数据取出来。

如果候选记录多、字段很多,或者行本身很宽,这一步的成本就会重新冒出来。

于是你看到的现象就会很容易让人困惑:

  • 过滤字段有索引
  • 排序字段也接上了
  • 执行计划看起来不算特别差
  • 但响应时间还是不够理想

这时要怀疑的,就不只是“有没有走索引”,而是:索引只负责把记录位置找出来,真正取数据那一步是不是还很重。

也就是说,索引路径前半段没有明显问题,不代表整条查询已经便宜了。

第六种情况:你以为还在看索引问题,其实已经是查询模型本身太重

再往后排查,有些 SQL 会发现问题已经不只是索引接没接上,而是查询本身就很贵。

比如深分页:

select id, order_no, create_time
from orders
where user_id = 1001
order by create_time desc
limit 100000, 20;

再比如前导模糊匹配:

where name like '%yuan%'

这些场景里,你当然还是可以继续看执行计划,但如果脑子里一直只想着“是不是索引失效”,通常会越看越别扭。

因为真正的问题已经变成:

  • 这类查询是不是本来就不适合靠普通 B+ 树索引低成本完成
  • 就算某个索引参与了,整体代价是不是还是很高
  • 该不该改分页方式、检索方式,或者干脆调整交互设计

也就是说,有些 SQL 的慢,不是因为索引突然没用了,而是因为你期待它解决的,已经不是索引最擅长解决的那类问题。

真正有用的问题,不是“索引失效了吗”,而是“哪一步和预期不一样”

如果你下次又遇到“这个字段明明建了索引,为什么还是慢”,我更建议直接拿 EXPLAIN 对照这几个问题看:

  1. 过滤是不是从你以为的索引入口开始的
  2. 条件比较方式有没有把索引顺序打断
  3. 排序有没有沿着同一条索引接下去
  4. 扫描量是不是仍然大得不合理
  5. 回表是不是把前面省下来的成本又抬回去了
  6. 这条 SQL 是不是已经属于查询模型本身太重

这样看,你就不会只停在一句空泛的“索引失效”。

你会更清楚地知道:这条 SQL 到底是入口不对、排序断了、扫描太大,还是回表和查询模型本身在拖后腿。

很多慢 SQL 真正需要的,不是一张“原因清单”,而是把执行计划里那一步和你预期不一致的地方找出来。

最后

所以我现在再遇到“明明建了索引,SQL 怎么还是慢”,基本不会先问“是不是索引失效了”。

我更关心的是:你原本期待数据库顺着索引完成的那条路径,到底断在了哪一步。

只要这件事说清楚,后面的优化方向通常也就清楚了:是要改联合索引顺序,改写条件,补齐过滤和排序的一致性,控制扫描范围,还是回头承认这条查询本身就不适合这样跑。

把问题定位到这一步,比背多少个“常见原因”都更有用。