在后端开发中,分页查询是最基础也最常用的功能之一。无论是运营后台各模块的列表,还是APP上商品列表、帖子列表、关注/粉丝列表等,几乎都离不开「按时间倒序+分页」的查询组合。但就是这个看似简单的需求,却因"锚点不稳定"而暗藏数据重复、丢失的陷阱,轻则影响体验,重则导致资损。本文将揭示这一问题的根本原因,对比分析三种主流解决方案的优劣,并最终给出可落地的工程规范,帮助你一劳永逸地解决分页稳定性问题。
案例
首先我们基于一些案例来感受一下,什么是"分页锚点不稳定"问题。
**案例1:**社交场景展示的帖子列表按「最新时间排序」,当有新帖子发布时,用户在翻页过程中会发现同一条帖子重复出现在不同的页码。
**案例2:**运营人员正在批量发放优惠券,此时新增了一批优惠券数据。发放完成后发现,部分用户收到了多张相同的优惠券,造成资损。
**案例3:**某支付流水查询页面,因为排序字段不唯一,导致数据展示顺序混乱,甚至出现数据丢失,给用户带来困扰。
当我们使用LIMIT offset, size(MySQL)或from + size(ES)时,分页的依据是「当前查询结果集的行数偏移」。而新数据插入或旧数据的删除会直接改变结果集的行数,进而导致下一页的偏移量失效。
案例1只是导致用户体验类的bug,而另外两个案例则影响更加严重,很容易出现资损。
分析
我们从案例1入手分析。假设用户A正在浏览帖子列表,操作流程如下:
第1步:加载第1页
执行SQL:select * from t order by create_time desc LIMIT 0,10
返回帖子 P1~P10(P1 为最新,P10 为第10新的帖子)。
第2步:加载第2页
此时,其他用户发布了一条新帖子 P0(时间比 P1 更新)。
用户A继续滑动,执行SQL:select * from t order by create_time desc LIMIT 10,10
按理应该返回 P11P20,但由于新插入了1条数据,整个结果集向后偏移,实际返回的是 P10P19。
结果:P10 在第1页和第2页都出现了,造成数据重复。
更极端的情况是,如果新数据插入量大于等于页大小,用户可能会遇到「连续多页显示相同数据」,甚至「永远无法看到后续数据」的问题。
解决方案
方案 1:「时间戳 + 唯一键」做「游标分页」
这是目前最主流、最彻底的方案,核心是放弃「偏移量(offset)」,改用「上一页最后一条数据的标记」作为分页锚点,彻底摆脱结果集变化的影响。
实现原理
- 确定「唯一排序键」:必须包含「时间字段(如create_time)+ 唯一键(如id)」,确保排序唯一(解决场景 3 的顺序混乱)。
- 分页时不传递offset,而是传递「上一页最后一条数据的create_time和id」。
- 下一页查询用「大于 / 小于」条件过滤,替代LIMIT offset, size。
SQL示例
- 第 1 页查询(无锚点,取最新 10 条)
sql
SELECT id, title, create_time FROM posts
ORDER BY create_time DESC, id DESC
LIMIT 10;
假设第 1 页最后一条数据为create_time='2024-05-20 14:30:00',id=100。
- 第 2 页查询(用锚点过滤):
sql
SELECT id, title, create_time FROM posts
WHERE create_time <= '2024-05-20 14:30:00' -- 时间早于上一页最后一条
AND id < 100 -- 时间相同则id更小
ORDER BY create_time DESC, id DESC
LIMIT 10;
方案优势
- 彻底解决重复/跳过:锚点是具体数据标记,不受新数据插入、旧数据删除影响。
- 性能优异:where 条件可创建联合索引(create_time, id),避免全表扫描。
- 兼容性强:同时解决排序不唯一问题。
方案劣势
- 不支持直接跳页:无法像LIMIT 40,10那样直接跳转到第 5 页,仅支持上一页/下一页或滑动加载。
适用场景
- 所有C端滑动加载场景(帖子、商品、评论列表等)。
- 数据量较大(万级以上),需优化分页性能的场景。
- 同样适合定时任务通过此方法遍历全表刷历史数据。
方案 2:时间戳过滤
由于方案1无法支持自由分页,可通过「固定查询时间范围」减少新数据影响,核心是让每次分页查询的「时间窗口」固定,避免新数据进入结果集。
实现原理
- 第一次查询时,记录「当前时间」作为max_create_time。
- 后续分页查询均加create_time <= max_create_time条件,新插入数据不满足条件被排除;
- 用户刷新页面时,重新获取最新max_create_time,更新时间窗口。
SQL示例
- 第 1 页查询(记录时间窗口):
sql
-- 假设当前时间为2024-05-20 15:00:00
SELECT id, title, create_time FROM posts
WHERE create_time <= '2024-05-20 15:00:00' -- 固定时间窗口
ORDER BY create_time DESC, id DESC
LIMIT 0,10;
- 第 2 页查询(沿用时间窗口):
sql
SELECT id, title, create_time FROM posts
WHERE create_time <= '2024-05-20 15:00:00' -- 不更新时间
ORDER BY create_time DESC, id DESC
LIMIT 10,10; -- 正常取第二页数据即可,区别**方案1**
方案优势
- 实现简单,成本低,只需记录首次查询时间,无需修改核心逻辑;首次查询的时间可以传给客户端,后续分页查询让客户端把首次查询的时间传给服务端即可,不需要服务器暂存此参数。
- 快速解决新数据重复:新数据被排除在时间窗口外,结果集稳定。
方案劣势
- 无法解决数据删除导致的跳过:若时间窗口内数据被删除,会导致部分数据被跳过。
- 数据滞后,用户滑动分页时看不到新数据,需刷新页面或者重新查询才能更新。
- 深分页性能问题,比如LIMIT 1000000, 10。
适用场景
- 所有C端滑动加载场景(帖子、商品、评论列表等)。
- 特别适合只增不删的场景,比如查看访客记录。
- 适合不存在深分页的业务场景,用户手动翻页一般很少翻到100页往后。
方案 3:适用于Elasticsearch的专属优化方案
上面讲到的方案1和2 同样适用于Elasticsearch,参考MySQL的实现方式,可以在Elasticsearch手动实现。但方案2在深分页场景下,Elasticsearch默认限制查询结果窗口大小为10000条记录,超过该值会触发错误提示"Result window is too large"。
为解决此类问题,Elasticsearch 提供 "滚动查询(Scroll)" 和"Search_after"功能。
-
Search_after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据,不需要指定偏移量from,直接取前size条即可。官方推荐使用的方式。和方案1实现原理类似。
-
Scroll:原理将排序后的文档ID形成快照,保存在内存,后续分页基于快照查询,不受数据更新影响。官方已经不推荐使用。
代码示例(search_after方式)
java
// 第1页查询
SearchRequest request = new SearchRequest("posts");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery());
// 按create_time(降序)、id(降序)排序
sourceBuilder.sort("create_time", SortOrder.DESC);
sourceBuilder.sort("id", SortOrder.DESC);
sourceBuilder.size(10);
request.source(sourceBuilder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 获取第1页最后一条数据的排序值(作为下一页的游标)
SearchHit lastHit = response.getHits().getHits()[response.getHits().getHits().length - 1];
// [1716215400000(create_time的时间戳), "100"(id)]
Object[] lastSortValues = lastHit.getSortValues();
// 第2页查询(用Search After)
sourceBuilder.searchAfter(lastSortValues); // 传入上一页的游标
sourceBuilder.size(10);
request.source(sourceBuilder);
SearchResponse page2Response = client.search(request, RequestOptions.DEFAULT);
方案优势
-
适合ES海量数据的获取:避免from + size在from较大时的性能问题(ES 会将前 N 条数据加载到内存)。
-
兼容性强:同时解决排序不唯一问题。
方案劣势
- 不支持直接跳页:无法像LIMIT 40,10那样直接跳转到第 5 页,仅支持上一页/下一页或滑动加载。
适用场景
-
ES 大数据量全量导出(如导出近 1 个月日志、批量导出 Excel)
-
同方案1列举的场景
总结
- 问题本质:分页重复/跳过源于「锚点不稳定」(用offset易受数据增删影响)和「排序不唯一」(单一字段排序规则不固定),解法是用「数据标记锚点」(如游标)和「唯一排序组合」(如create_time + id)。
- 方案选择逻辑:按「是否需跳页→数据量→更新频率」决策,如B端需跳页且数据量小用LIMIT + 时间戳,C 端滑动加载且数据量大用游标分页,ES 批量导出用Search_after。
- 规范价值:技术方案解决单次问题,建立工程规范(需求 - 编码 - 测试 - 监控)将个人经验转化为团队标准,CR分页查询代码重点关注排序项是否唯一、是否游标分页、分页是否有防重措施等,避免重复踩坑,保障分页功能稳定,提升用户体验与业务营收。
关于作者,黄敬乾,侠客汇Java开发工程师。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~