⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 程序员小富 「xiaofu」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

写 SQL 的时候,大家都有个肌肉记忆:如果只需要一条数据,一定要加上 LIMIT 1。

这听起来非常合理,毕竟数据库只要找到一条满足条件的记录,就可以收工回家,不用再把剩下的几百万行数据扫一遍,既省 IO 又省 CPU。

但前两天排查线上问题时,我遇到了一个非常有意思的案例:加了 LIMIT 1,查询反而慢了 50 倍。

这就好比你为了抄近道走了一条小路,结果发现这条路堵得水泄不通,比走大路还慢。

排查一圈发现是 MySQL 优化器的问题

1. 还原一下现场

业务场景很简单:我们要查某个用户最近的一笔“处理中”的订单。

订单表 orders 大概有 500 万数据,表里有两个关键索引:

  1. idx_user_status(user_id, status):用来根据用户和状态过滤数据。
  2. idx_create_time(create_time):用来按时间排序。

代码里的 SQL 是这么写的:

SELECT id, order_no, amount 
FROM orders
WHERE user_id = 10086 AND status = 1
ORDER BY create_time DESC
LIMIT 1;

这条 SQL 上线后,直接触发了慢查询报警,耗时飙到了 2.5 秒。

为了搞清楚原因,我试着把 LIMIT 1 去掉,裸跑了一次:

SELECT id, order_no, amount 
FROM orders
WHERE user_id = 10086 AND status = 1
ORDER BY create_time DESC;

结果只要 50 毫秒。

加了 LIMIT 也就是想省点事,反而变慢了呢?

2. Explain 分析

遇到这种诡异的事,第一反应肯定是看执行计划。对比了一下两条 SQL 的 EXPLAIN 结果,真相立刻浮出水面:

  • 没加 LIMIT时: MySQL 选择了 idx_user_status 索引。它先精准地把这个用户状态为 1 的订单找出来(只有几十条),然后在内存里排个序。因为数据量少,这个排序几乎瞬间完成。
  • 加了 LIMIT 后: MySQL 居然放弃了精准过滤,改用了 idx_create_time 索引。 它的思路变成了,按时间倒序扫描全表,一边扫一边检查这是不是该用户的订单。

为什么优化器会觉得第二种方案更好?

这里我们得站在 MySQL 优化器的角度想一想。它在做决策时,其实是在做一道算术题:

  • 方案 A 走过滤索引:先把符合条件的数据全找出来,再排序。 缺点:如果符合条件的数据很多,排序成本会很高。
  • 方案 B 走时间索引 + LIMIT:既然你只要 1 条数据,而且要求按时间倒序,那我就顺着时间索引往回找。优点:天然有序,不用再排序了。

优化器其实在赌,优化器觉得,运气只要不是太差,应该很快就能碰到一条满足 user_id 和 status 的记录。

问题就出在这个赌注上。

在这个案例里,用户 10086 是个老用户,他最近的一笔“处理中”的订单,其实是一年前下的。

于是,MySQL 顺着时间索引,从今天的数据开始往回扫,扫了昨天、上周、上个月……一直扫了 200 多万行数据,才终于在去年的数据里找到了那条记录。

这就是为什么加了 LIMIT 1 反而变成了全表扫描级别的慢查询。

3. 怎么解决?

既然知道了是优化器选错路了,那我们的思路就是帮它纠正过来。

方法一:简单粗暴 FORCE INDEX

既然优化器甚至不清楚,那我们就直接教它做事。

SELECT ... FROM orders FORCE INDEX (idx_user_status) ...

这就相当于在导航里强制选定路线。优点是立竿见影,缺点是代码不够优雅,如果以后索引名改了,这行代码会报错。

方法二:最稳妥的联合索引

优化器之所以纠结,是因为现有的索引没法同时满足“过滤”和“排序”。

我们可以建一个联合索引:(user_id, status, create_time)。

在这个索引里,数据先按用户和状态聚在一起,内部再按时间排序。MySQL 只要用这个索引,既能精准定位,又不用额外排序,这才是最完美的解法。

方法三:子查询的小技巧

如果你不想改表结构,还有一个巧妙的写法:

SELECT * FROM (
SELECT ... FROM orders
WHERE user_id = 10086 AND status = 1
ORDER BY create_time DESC
) AS tmp
LIMIT 1;

我们用一个子查询先把数据找出来,这时候 MySQL 会乖乖走过滤索引,然后再在外层取 LIMIT 1。这就相当于人为地切断了 LIMIT 对内层索引选择的干扰。

写在最后

LIMIT 1 确实是个好习惯,但也要看场景。

在这个案例里,MySQL 的优化器因为过度自信,觉得“很快就能找到这一条”,结果在数据分布不均匀的情况下翻了车。

下次如果再遇到加了限制反而变慢的问题,直接用 EXPLAIN 看看,因为优化器有时候也是会做出抽风的事!

文章目录
  1. 1. 1. 还原一下现场
  2. 2. 2. Explain 分析
  3. 3. 3. 怎么解决?
  4. 4. 写在最后