前言
有个 bug 特别诡异:一条 ORDER BY ... LIMIT 的分页 SQL,测试环境好好的,上线后偶尔出现"第 2 页的数据,第 1 页也出现过"或者"翻页时有数据莫名跳过"。
更让人抓狂的是,把 LIMIT 去掉,ORDER BY 的结果看着又是对的。于是很多人得出结论:"是 LIMIT 把顺序搞乱了。"
这个锅 LIMIT 不背。真正的原因是:你的排序字段有重复值,而重复值之间的顺序,MySQL 从不保证。 LIMIT 只是把这个一直存在的隐患暴露了出来。
这篇文章讲清楚:为什么排序结果会"不稳定",LIMIT 在其中扮演了什么角色,以及怎么写才不会翻车。
环境说明:本文基于 MySQL 8.0,存储引擎 InnoDB。继续复用前几篇的
orders表(100 万行)。
一、先复现:同一条 SQL,两次结果不一样
orders 表的 status 字段只有 0/1/2/3 四种值,大量重复。我们按 status 排序分页:
sql
-- 第 1 页
SELECT id, status FROM orders ORDER BY status LIMIT 0, 5;
text
+--------+--------+
| id | status |
+--------+--------+
| 337012 | 0 |
| 15 | 0 |
| 998233 | 0 |
| 41 | 0 |
| 672319 | 0 |
+--------+--------+
看起来没问题,status 都是 0,有序。但问题在于:这 5 行,是从几十万个 status = 0 的行里"随便"挑出来的 5 个。 它们的 id 毫无规律。
换个时间再执行同样的 SQL,或者数据发生增删后,返回的可能就是另外 5 行。更麻烦的是分页:
sql
-- 第 2 页
SELECT id, status FROM orders ORDER BY status LIMIT 5, 5;
如果第 1 页和第 2 页两次查询之间,MySQL 选择的排序方式不同,就可能出现第 1 页的某行,第 2 页又冒出来 ,或者有些行被跳过。这就是分页错乱的现场。
二、根因:ORDER BY 只保证排序字段有序
问题的核心是一句话:ORDER BY status 只保证 status 这一列有序,对于 status 相同的行,它们之间谁前谁后,MySQL 不做任何承诺。
status = 0 的行有几十万条,ORDER BY status 只要求这几十万条排在 status = 1 的前面即可------至于这几十万条内部怎么排,SQL 标准和 MySQL 都没有规定。
这在数据库里叫不稳定排序(unstable sort):相等的元素,排序后的相对顺序不确定。
这不是 MySQL 的"bug",而是官方明确定义的行为。MySQL 官方文档在 LIMIT 优化那一节里写得清清楚楚:
If multiple rows have identical values in the
ORDER BYcolumns, the server is free to return those rows in any order, and may do so differently depending on the overall execution plan. In other words, the sort order of those rows is nondeterministic with respect to the nonordered columns.
翻译过来就是:如果多行在 ORDER BY 的列上值相同,服务器可以按任意顺序返回这些行,而且会因整体执行计划的不同而不同。 换句话说,这些行相对于"未参与排序的列"来说,顺序是不确定的。
官方都把话说到这份上了------所以别指望"相等行的顺序"会稳定,它从设计上就不稳定。
那这个"不确定的顺序"实际由什么决定?取决于 MySQL 当时选择的执行方式:
- 如果走了某个索引,顺序可能跟着索引走
- 如果做全表扫描 + filesort,顺序可能跟数据的物理存储、扫描顺序有关
- 数据量、
LIMIT大小不同,优化器可能选不同的排序算法,顺序又会变
这些因素一变,"相等行"的相对顺序就变了。所以不是结果"乱"了,而是它本来就没被定义过。

三、LIMIT 为什么会"放大"这个问题
既然顺序不确定,为什么不加 LIMIT 时看着又"正常"?LIMIT 到底做了什么?
关键在于 MySQL 对 ORDER BY ... LIMIT 有一个特殊优化:优先队列排序(priority queue,基于堆)。
- 不带
LIMIT的ORDER BY:要把所有满足条件的行全部排好序再返回。虽然相等行的顺序仍不保证,但同一份数据、同样的执行方式下,多跑几次看起来往往是"稳定"的,不容易察觉问题。 - 带
LIMIT n的ORDER BY:MySQL 发现"我只要前 n 行,何必全排",于是可能改用堆排序 只维护 Top n。这条优化路径下,相等元素被取到堆里的顺序,和全量排序很可能不一样。
结果就是:加不加 LIMIT、LIMIT 取多少,可能触发不同的排序算法,导致相等行的顺序在不同查询间不一致。 这正是"翻页时数据重复或跳过"的直接原因------第 1 页和第 2 页恰好走了会产生不同相对顺序的路径。
所以 LIMIT 不是元凶,它只是触发了不同的排序策略,把"相等行顺序未定义"这个一直存在的隐患引爆了。
小结:根因是"排序字段有重复 + 顺序未定义",
LIMIT是放大器。两者叠加,才有了诡异的分页错乱。
四、正解:给排序加一个"唯一的兜底字段"
解决办法出奇地简单:让 ORDER BY 的整体是唯一确定的,就不会有相等行。
做法是在排序末尾加一个唯一字段 (通常是主键 id)作为最后的 tie-breaker(打破平局):
sql
-- 有坑:status 有大量重复,相等行顺序不确定
SELECT id, status FROM orders ORDER BY status LIMIT 5, 5;
-- 正解:加主键 id 兜底,排序结果全局唯一确定
SELECT id, status FROM orders ORDER BY status, id LIMIT 5, 5;
加上 , id 之后:status 相同的行,再按 id 排------而 id 是主键、绝对唯一,所以任意两行都有明确的先后 ,排序结果全局唯一。无论查多少次、怎么翻页、数据怎么变,同样的偏移永远返回同样的行。
这个原则可以推广:只要用了 ORDER BY ... LIMIT 做分页,就要保证 ORDER BY 的字段组合能唯一确定每一行的位置。 如果业务排序字段本身可能重复(时间、金额、状态、排序权重......),就在最后补一个唯一字段。
配合索引更好。如果经常按 created_at 倒序翻页,可以建 (created_at, id) 联合索引,ORDER BY created_at DESC, id DESC 既稳定又能走索引避免 filesort(这一点在《深分页优化》里详细讲过)。
五、常见误区与面试高频问答
Q:不加 LIMIT 的 ORDER BY 就一定稳定吗?
也不是。相等行的顺序同样没有保证 ,只是不带 LIMIT 时不容易触发不同的排序算法,表现得"看起来稳定"。别依赖这种表象,只要排序字段可能重复,就该加唯一兜底字段。
Q:加了 id 兜底,性能会变差吗?
几乎不会,反而可能更好。如果有 (排序字段, id) 的联合索引,排序能直接走索引、免掉 filesort。即便没有,多比较一个主键的成本也微乎其微,远比"分页错乱"的 bug 划算。
Q:主键是 UUID(无序)也能当兜底字段吗?
能。兜底字段要的是唯一性,不是有序性------只要能唯一确定两行的先后即可。UUID 唯一,所以能保证排序稳定。只是无序主键在其他方面(如页分裂)有别的问题,那是另一个话题。
Q:为什么测试环境不出问题,上线才出?
测试环境数据量小、并发低,优化器往往稳定地选同一条执行路径,相等行顺序"碰巧"一致。上线后数据量、并发、LIMIT 大小都变了,优化器可能切换排序策略,隐患才暴露。这类 bug 的典型特征就是"本地复现不了"。
总结
"加了 LIMIT 顺序还乱",真相是:
ORDER BY 字段只保证该字段有序 ,对于该字段值相等的行 ,相互顺序从不保证(不稳定排序)。LIMIT会触发优先队列/堆排序 等不同的排序策略,让"相等行的顺序"在不同查询间不一致,从而放大这个隐患,表现为分页重复或跳过。- 根因不是
LIMIT,是排序字段有重复 + 顺序未定义。
正解一句话:ORDER BY 分页时,末尾补一个唯一字段(如主键 id)兜底,让排序结果全局唯一确定,翻页永不错乱。
一句话记忆: 排序字段可能重复,就一定要加主键兜底------ORDER BY status 有坑,ORDER BY status, id 才踏实。