分页查询的稳定性陷阱与根治方案

在后端开发中,分页查询是最基础也最常用的功能之一。无论是运营后台各模块的列表,还是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)」,改用「上一页最后一条数据的标记」作为分页锚点,彻底摆脱结果集变化的影响。

实现原理

  1. 确定「唯一排序键」:必须包含「时间字段(如create_time)+ 唯一键(如id)」,确保排序唯一(解决场景 3 的顺序混乱)。
  2. 分页时不传递offset,而是传递「上一页最后一条数据的create_time和id」。
  3. 下一页查询用「大于 / 小于」条件过滤,替代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无法支持自由分页,可通过「固定查询时间范围」减少新数据影响,核心是让每次分页查询的「时间窗口」固定,避免新数据进入结果集。

实现原理

  1. 第一次查询时,记录「当前时间」作为max_create_time。
  2. 后续分页查询均加create_time <= max_create_time条件,新插入数据不满足条件被排除;
  3. 用户刷新页面时,重新获取最新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);

方案优势

  1. 适合ES海量数据的获取:避免from + size在from较大时的性能问题(ES 会将前 N 条数据加载到内存)。

  2. 兼容性强:同时解决排序不唯一问题。

方案劣势

  1. 不支持直接跳页:无法像LIMIT 40,10那样直接跳转到第 5 页,仅支持上一页/下一页或滑动加载。

适用场景

  1. ES 大数据量全量导出(如导出近 1 个月日志、批量导出 Excel)

  2. 同方案1列举的场景

总结

  1. 问题本质:分页重复/跳过源于「锚点不稳定」(用offset易受数据增删影响)和「排序不唯一」(单一字段排序规则不固定),解法是用「数据标记锚点」(如游标)和「唯一排序组合」(如create_time + id)。
  2. 方案选择逻辑:按「是否需跳页→数据量→更新频率」决策,如B端需跳页且数据量小用LIMIT + 时间戳,C 端滑动加载且数据量大用游标分页,ES 批量导出用Search_after。
  3. 规范价值:技术方案解决单次问题,建立工程规范(需求 - 编码 - 测试 - 监控)将个人经验转化为团队标准,CR分页查询代码重点关注排序项是否唯一、是否游标分页、分页是否有防重措施等,避免重复踩坑,保障分页功能稳定,提升用户体验与业务营收。

关于作者,黄敬乾,侠客汇Java开发工程师。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~

相关推荐
百***17072 小时前
Spring Boot spring.factories文件详细说明
spring boot·后端·spring
倚肆3 小时前
HttpServletResponse 与 ResponseEntity 详解
java·后端·spring
虎子_layor3 小时前
告别JMeter!我用 k6 5 分钟完成高并发压测
后端·测试
依_旧3 小时前
【玩转全栈】----Django基本配置和介绍
java·后端
爱可生开源社区3 小时前
SCALE | 2025 年 10 月《大模型 SQL 能力排行榜》发布
后端
q***82913 小时前
图文详述:MySQL的下载、安装、配置、使用
android·mysql·adb
radient3 小时前
Agent的"思考" - 智能体
后端·架构·ai编程
百***35513 小时前
什么是Spring Boot 应用开发?
java·spring boot·后端
梅花144 小时前
基于Django的博客系统
后端·python·django·毕业设计·博客·博客系统·毕设