MySQL分页结果不稳定:唯一键兜底与游标分页

大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。

上个月同一周来了两个投诉。运营说订单列表翻到第三页,看到了第一页的订单。财务说月度导出少了几十单,金额对不上。

一个多,一个少,看着是两件事。查到最后,问题都出在那句 SQL 上:ORDER BY 里只有一个不唯一的字段,翻页的那几秒里数据还在动。

这套导出脚本跑了两年,一直是对的。它就这么错了两年,没人发现,因为没人核对过顺序。

一、顺序不归你管

很多人把 ORDER BY created_at DESC 读成"按时间倒序"。准确的说法是:按时间倒序,时间相同的行之间没有约定。SQL 标准没规定同值行谁在前,MySQL 也没补一条,谁先谁后由执行计划决定。

执行计划会变。数据量涨了会变,统计信息更新了会变,加个筛选条件、改个索引、升个版本,都可能变。所以"排序不稳定"这句话,说得再准一点:顺序没有规则。没有规则的东西,没法用"再跑一次"去验证。

麻烦在于它平时很稳。created_at 上有索引,InnoDB 的二级索引叶子按"索引列 + 主键"有序。走索引扫描时,同值行恰好按主键排好,看着跟写了唯一键一样。测试环境一直对,多半就是这个原因。

哪次优化器改走 filesort,顺序立刻换一副样子。filesort 更不讲究,带 LIMIT 时它用优先队列,只维护前 N+M 行。同值行谁留下、谁被替换,没有任何对外承诺。

二、为什么两年都没事,突然就出事

两年前这张表一秒只落几条单,同一秒里基本没有并列。没有并列,顺序就是唯一的,怎么写都对。两年里数据涨上来了,批量导入一进来,同一秒几百条订单,并列出现了,问题才有机会露头。这一年表上还改过索引,也加过筛选条件,执行计划悄悄换过一次。三个变化叠在一起,就是"一直对,突然错"。不是脚本变了,是它脚下的东西变了。

翻页还有第二个麻烦,经常被混进来一起骂:你翻页的时候,数据在动。第一页拿完,有人新插了 5 条 created_at 更大的单,这 5 条挤到最前面,原来的第 21 行被推到第 26 位。第二页从第 21 行开始取,开头这 5 行就是第一页的结尾。反过来,期间删了 5 条,就跳过 5 条。这个叫翻页位移,列表页上它只是"看着别扭",到了导出和对账,就是缺数据。

还有一层是主从。读写分离的库,分页查询落在从库上,从库延迟两秒,这两秒里的新行就看不到。这时候就算你补了唯一键,行集本身还是变了。所以稳定性分两层:排序稳定是 SQL 层的,数据一致是复制层的。这三件事里,只有排序能靠改 SQL 治,位移和延迟都得换取数方式。

三、复现:把计划换掉,看两批行

复现不用造复杂数据。一张一百万行的订单表,created_at 精确到秒,批量导入让同一秒塞进几百条。先看执行计划,同一条 SQL,用 hint 把两种计划分别固定下来:

sql 复制代码
-- 表结构(示意)
CREATE TABLE t_order_demo (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  order_no VARCHAR(32) NOT NULL,
  status TINYINT NOT NULL,
  created_at DATETIME NOT NULL,
  KEY idx_created (created_at)
) ENGINE=InnoDB;

-- 走索引:Extra 是 Using index
EXPLAIN SELECT id, created_at FROM t_order_demo FORCE INDEX (idx_created)
ORDER BY created_at DESC LIMIT 20 OFFSET 40;

-- 退到排序:Extra 是 Using filesort
EXPLAIN SELECT id, created_at FROM t_order_demo IGNORE INDEX (idx_created)
ORDER BY created_at DESC LIMIT 20 OFFSET 40;

看 Extra 那一列。走索引时是 Using index,退到排序时是 Using filesort。走索引,顺序由索引定;退到 filesort,顺序由扫描顺序和排序算法定。两条计划各跑一次,把第 41 到 60 行的主键集合拿出来比对,对不上。

同一个计划跑一百次,结果都一样。换个计划,结果就换一批。所以判断这件事,别用"再跑一次对不对",要看计划有没有变。

两页是不是同一批行,可以直接量。集合运算里,单个查询块带 ORDER BY 和 LIMIT 要加括号,不加直接报语法错。

sql 复制代码
-- 相邻两页有没有交集,正常应该为 0
SELECT COUNT(*) FROM (
  (SELECT id FROM t_order_demo ORDER BY created_at DESC LIMIT 20 OFFSET 20)
  INTERSECT
  (SELECT id FROM t_order_demo ORDER BY created_at DESC LIMIT 20 OFFSET 40)
) x;

INTERSECT 和 EXCEPT 是 MySQL 8.0.31 才有的,8.4 和 9.x 也支持。版本低就把两页结果落到临时表,再 JOIN 一下。还有一种误判要避开:页内的 COUNT(*) 和 COUNT(DISTINCT id) 在这类场景下永远相等,一页不会自己重复,重复只出现在跨页、跨次。量错了对象,报警永远不响。

四、三种改法,各治一段

补唯一键最省事。把排序改成 ORDER BY created_at DESC, id DESC,同值行之间就有了确定的先后。代价没有想象中大。InnoDB 的二级索引本来就带主键后缀,KEY(created_at) 反向扫描时天然满足 created_at DESC, id DESC。多数情况不用另建联合索引,先 EXPLAIN 看一眼 Extra 里有没有 Using filesort 再决定。真正需要组合索引的是另一种场景:排序前面还挂着筛选。比如 WHERE status = 1 ORDER BY created_at DESC,要让过滤和排序共用一条索引,才谈得上 (status, created_at)。补唯一键治并列,治不了翻页位移,也治不了主从延迟。

游标分页把"跳过前 M 行"换成"从上一页最后一行继续"。插入和删除都不会让行错位,代价是不能跳页,只能上一页下一页。

sql 复制代码
-- 第一页
SELECT id, created_at FROM t_order_demo
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 后续页:把上一页最后一行的 (created_at, id) 带进来
SELECT id, created_at FROM t_order_demo
WHERE (created_at, id) < ('2026-11-04 10:00:00', 88123456)
ORDER BY created_at DESC, id DESC
LIMIT 20;

created_at 如果允许 NULL,行比较会退化成三值逻辑,游标会漏行。业务上一般不允许为空,建表时顺手确认一次。游标这条要看 EXPLAIN:排序能靠索引后缀,范围落在两列上,走不了就把 (created_at, id) 建成联合索引。

主键区间适合对账、备份、跑批这类不需要页面的场景。按主键切块,每块固定大小,天然不重不漏。

sql 复制代码
-- 按主键切块导出,一块 5000 行
SELECT id, order_no, created_at FROM t_order_demo
WHERE id >= 1 AND id < 5001
ORDER BY id;

-- 下一块从上一块的上界继续
SELECT id, order_no, created_at FROM t_order_demo
WHERE id >= 5001 AND id < 10001
ORDER BY id;

按时间做增量导出时,条件不能写成 created_at >= ? AND id > ?。它会漏:边界之后 created_at 更新、但 id 比游标小的行,会被 id > ? 挡掉。

sql 复制代码
-- 对:两个字段一起推进
WHERE (created_at, id) > ('2026-11-04 10:00:00', 88123456)

-- 错:会漏掉 created_at 更新、id 更小的行
WHERE created_at >= '2026-11-04 10:00:00' AND id > 88123456
方案 治什么 能不能跳页 适合场景 代价
OFFSET + 唯一键 排序并列 能 后台列表、页数少 翻页位移还在,深页慢
游标分页 排序 + 位移 不能 信息流、无限滚动、导出 要带游标,接口得改
主键区间 排序 + 位移 不适用 对账、备份、跑批 只能顺序推进,不能按时间随机取

别把这件事跟"深翻页慢"混在一起。OFFSET 100000 慢是性能问题,解法是延迟关联或者游标。今天说的是结果不对:索引全在,SQL 也不慢,照样会重复。

五、上线前自检

给每个分页接口加一条自检,成本很低。同一条件跑两次,比对主键集合,差集不为空就报警。跑的时候挑没有写入的窗口,否则误报的是数据变化,不是排序。导出脚本更要做,导出的错不会有人当场发现,要等到对账。

自检不能拿一句 SQL 自己减自己,那永远是 0。要落两份快照:

sql 复制代码
CREATE TEMPORARY TABLE snap_a (id BIGINT PRIMARY KEY);
CREATE TEMPORARY TABLE snap_b (id BIGINT PRIMARY KEY);

-- 第一次导出后
INSERT INTO snap_a SELECT id FROM t_order_demo WHERE status = 1;

-- 隔一段时间,第二次导出后
INSERT INTO snap_b SELECT id FROM t_order_demo WHERE status = 1;

-- 正常应为 0,不为 0 说明两次拿到的不是同一批行
SELECT COUNT(*) FROM (
  SELECT id FROM snap_a
  EXCEPT
  SELECT id FROM snap_b
) d;

EXCEPT 的版本要求和 INTERSECT 一样,8.0.31 起。低版本换成 LEFT JOIN:

css 复制代码
SELECT COUNT(*) FROM snap_a a
LEFT JOIN snap_b b ON a.id = b.id
WHERE b.id IS NULL;

顺带把索引过一遍。EXPLAIN 的 Extra 里有没有 Using filesort,rows 是不是比上个月大了一个量级。排序和过滤能不能共用一条索引,比多建一条索引更重要。主从环境再加一条:同一条 SQL 在主库和从库各跑一次,比主键集合,别只比条数。条数一样、行不一样,是最难发现的那种。

六、避坑清单

排序字段后面永远补一个唯一键,id 就行。补完先看计划,别急着建联合索引。这条改动小,收益是长期的,我现在的建表规范里固定写这一条。

跨页导出、对账、跑批,一律不要用 OFFSET。游标或者主键区间,选一个。写脚本的时候顺手加一条"两次结果一致"的断言,比事后查对账便宜得多。

最后一条是我自己踩的。有次做数据订正,我用 LIMIT 1000 OFFSET n 循环更新,跑了二十轮。跑完核对,有三批行被修了两遍,另有两批一次没碰。原因还是排序不唯一,加上订正过程本身改了数据。后来改成按主键区间推进,脚本开头先 SELECT id 拍一份快照。订正类脚本比查询更怕这个,它自己会改数据,错一次就留痕。

写在最后

分页不是"取第几页",是"每次重新算一次前 N 行"。排序里有并列,这个"前 N 行"就不唯一。补个唯一键、换成游标,两行代码的事,能省掉一场对账。

这套改法不挑库。换任何关系库,只要 ORDER BY 最后没有唯一键,翻页还是这个样子。

回到开头那两件事。导出脚本补了主键,又改成按主键切块;列表接口补了唯一键,翻到第三页不再撞见第一页。两个投诉,一套改法。

你们的分页接口,排序最后一段写的是什么?有没有过"翻页又看到同一条"?评论区说说,我按场景帮你看看是排序还是延迟。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

相关推荐
风翼靓崽40 分钟前
mysql数据备份/恢复
数据库·mysql
贝锐1 小时前
金仓、达梦、TDSQL全适配:向日葵补齐信创数据库层远程运维能力
运维·数据库·信创
xixiaoyunya2 小时前
Redis缓存三大问题实战:穿透、击穿、雪崩,我用一个电商秒杀场景讲清楚
数据库·redis·缓存
这个DBA有点耶3 小时前
Merge还是Materialize?一文讲透MySQL 8.0 CTE底层的执行计划博弈与性能陷阱
数据库·mysql·架构
Thanks_ks4 小时前
MySQL 的 REPEATABLE READ 到底能不能防幻读——从快照读、当前读到 Next-Key Lock
数据库·mysql·innodb·mvcc·锁机制·事务隔离级别·间隙锁
千桐科技4 小时前
qKnow智能体构建平台专业版 v3.1.5 发布:扩容工作流编排调用能力,上线Text2SQL插件,拓宽数据类智能应用构建边界
服务器·数据库·oracle·qknow
宇灬宇4 小时前
oracle直方图测试
数据库·oracle
Henry-SAP4 小时前
SAP PP中的核心业务流程——离散制造、重复制造、流程制造三大模式解析
前端·数据库·人工智能·制造