做列表接口时,很多人先写出这句:
SELECT id, title, created_at
FROM article
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;
数据少的时候没毛病。数据一多,用户偶尔会在第二页看见第一页的记录,或者有一条怎么翻都翻不到。
这事经常被一句“深分页慢”带过去,其实是两个不同的坑:排序不稳定,以及翻页期间数据发生了变化。只优化 OFFSET 的扫描量,修不好前一个;只在排序后补主键,也修不好后一个。
相同时间的记录,谁排前面没有保证
假设文章表里有三条数据的 created_at 都是 2026-08-08 10:00:00。SQL 只要求按时间倒序,并没有规定这三条内部怎么排。
MySQL 8.4 的官方手册写得很直白:如果多行的 ORDER BY 列值相同,服务端可以按任意顺序返回它们;执行计划变化时,这个顺序也可能变化。LIMIT 本身就可能影响执行计划。
所以这不是数据库“偶尔抽风”。查询写出来的顺序本来就不完整。
先把排序补成唯一的:
SELECT id, title, created_at
FROM article
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;
只要 id 唯一,任何两行都能分出先后。这里还有个小细节:两个字段必须保持同一套方向。时间倒序、同一时间下 id 也倒序,后面的游标条件才能原样对上。
这一步解决的是“同一份数据,多查几次顺序别变”。但线上列表不是一张静止的照片。
补了 id,为什么还会重复
第一页刚查完,顺序是:
105, 104, 103, 102
页面大小是 2,客户端拿到了 105, 104。
这时新文章 106 插到最前面。第二页继续执行 OFFSET 2,数据库看到的顺序已经成了:
106, 105, 104, 103, 102
跳过前两行以后,第二页返回 104, 103。104 就重复了。删除或更新排序字段时,同样可能漏行。
OFFSET 记录的是“跳过几行”,不是“上一页停在哪里”。列表头部只要发生变化,这把尺子的零点就跟着动。
接口不要求跳页时,我会改成复合游标
第一页仍然这样查:
SELECT id, title, created_at
FROM article
ORDER BY created_at DESC, id DESC
LIMIT 20;
返回数据时,把最后一条的 created_at 和 id 一起交给客户端。假设最后一条是:
created_at = 2026-08-08 10:00:00
id = 104
下一页从这个位置之后继续:
SELECT id, title, created_at
FROM article
WHERE created_at < '2026-08-08 10:00:00'
OR (created_at = '2026-08-08 10:00:00' AND id < 104)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这里不能只传时间。时间会重复,只用 created_at < ? 会直接漏掉同一秒里排在 104 后面的记录。游标、WHERE 条件、ORDER BY,三处都得是同一组字段。
MySQL 8.0 可以配对应的降序复合索引:
CREATE INDEX idx_article_created_id
ON article (created_at DESC, id DESC);
这样查询可以沿着索引从游标位置继续向后读,不必每一页都从头数到 OFFSET。实际有没有用到,还是看 EXPLAIN,别看见建了索引就默认优化器一定选它。
游标分页也不是“绝对不漏”
它解决的是最常见的滚动列表:用户往下翻时,头部不断有新数据进来。新行排在游标前面,不会把已经读过的行挤进下一页。
但如果翻页期间有人修改了旧记录的 created_at,它可能跨过游标;记录被删除后,也不可能继续返回。导出、对账这类要求严格一致的任务,应该固定快照或先固化待导出的主键集合,不能把一个在线列表接口硬当快照。
还有一个取舍。游标分页很适合“下一页”和无限滚动,不擅长直接跳到第 5000 页。产品真要求随机跳页,就得接受 OFFSET 的成本,或者另做页码到锚点的映射。
面试里被问到 MySQL 分页,我不会只答“深分页用游标”。先问排序键是否唯一,再问翻页期间有没有并发写入,最后才是扫描成本。三个问题对上的 SQL 不一样。