一、案例
首先我们基于一些案例来感受一下,什么是“分页锚点不稳定”问题。
案例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
按理应该返回 P11~P20,但由于新插入了1条数据,整个结果集向后偏移,实际返回的是 P10~P19。
结果:P10 在第1页和第2页都出现了,造成数据重复。
更极端的情况是,如果新数据插入量大于等于页大小,用户可能会遇到「连续多页显示相同数据」,甚至「永远无法看到后续数据」的问题。
三、解决方案
这是目前最主流、最彻底的方案,核心是放弃「偏移量(offset)」,改用「上一页最后一条数据的标记」作为分页锚点,彻底摆脱结果集变化的影响。
1)实现原理
确定「唯一排序键」:必须包含「时间字段(如create_time)+ 唯一键(如id)」,确保排序唯一(解决场景 3 的顺序混乱)。
分页时不传递offset,而是传递「上一页最后一条数据的create_time和id」。
下一页查询用「大于 / 小于」条件过滤,替代LIMIT offset, size。
2)SQL示例
第 1 页查询(无锚点,取最新 10 条)
SELECT id, title, create_time FROM postsORDER BY create_time DESC, id DESCLIMIT 10;
假设第 1 页最后一条数据为create_time='2024-05-20 14:30:00',id=100。
第 2 页查询(用锚点过滤):
SELECT id, title, create_time FROM postsWHERE create_time <= '2024-05-20 14:30:00' -- 时间早于上一页最后一条AND id < 100 -- 时间相同则id更小ORDER BY create_time DESC, id DESCLIMIT 10;
3)方案优势
彻底解决重复/跳过:锚点是具体数据标记,不受新数据插入、旧数据删除影响。
性能优异:where 条件可创建联合索引(create_time, id),避免全表扫描。
兼容性强:同时解决排序不唯一问题。
4)方案劣势
不支持直接跳页:无法像LIMIT 40,10那样直接跳转到第 5 页,仅支持上一页/下一页或滑动加载。
5)适用场景
所有C端滑动加载场景(帖子、商品、评论列表等)。
数据量较大(万级以上),需优化分页性能的场景。
同样适合定时任务通过此方法遍历全表刷历史数据。
由于方案1无法支持自由分页,可通过「固定查询时间范围」减少新数据影响,核心是让每次分页查询的「时间窗口」固定,避免新数据进入结果集。
1)实现原理
第一次查询时,记录「当前时间」作为max_create_time。
后续分页查询均加create_time <= max_create_time条件,新插入数据不满足条件被排除;
用户刷新页面时,重新获取最新max_create_time,更新时间窗口。
2)SQL示例
第 1 页查询(记录时间窗口):
-- 假设当前时间为2024-05-20 15:00:00SELECT id, title, create_time FROM postsWHERE create_time <= '2024-05-20 15:00:00' -- 固定时间窗口ORDER BY create_time DESC, id DESCLIMIT 0,10;
第 2 页查询(沿用时间窗口):
SELECT id, title, create_time FROM postsWHERE create_time <= '2024-05-20 15:00:00' -- 不更新时间ORDER BY create_time DESC, id DESCLIMIT 10,10; -- 正常取第二页数据即可,区别**方案1**
3)方案优势
实现简单,成本低,只需记录首次查询时间,无需修改核心逻辑;首次查询的时间可以传给客户端,后续分页查询让客户端把首次查询的时间传给服务端即可,不需要服务器暂存此参数。
快速解决新数据重复:新数据被排除在时间窗口外,结果集稳定。
4)方案劣势
无法解决数据删除导致的跳过:若时间窗口内数据被删除,会导致部分数据被跳过。
数据滞后,用户滑动分页时看不到新数据,需刷新页面或者重新查询才能更新。
深分页性能问题,比如LIMIT 1000000, 10。
5)适用场景
所有C端滑动加载场景(帖子、商品、评论列表等)。
特别适合只增不删的场景,比如查看访客记录。
适合不存在深分页的业务场景,用户手动翻页一般很少翻到100页往后。
上面讲到的方案1和2同样适用于Elasticsearch,参考MySQL的实现方式,可以在Elasticsearch手动实现。但方案2在深分页场景下,Elasticsearch默认限制查询结果窗口大小为10000条记录,超过该值会触发错误提示“Result window is too large”。
为解决此类问题,Elasticsearch 提供 “滚动查询(Scroll)” 和“Search_after”功能。
Search_after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据,不需要指定偏移量from,直接取前size条即可。官方推荐使用的方式。和方案1实现原理类似。
Scroll:原理将排序后的文档ID形成快照,保存在内存,后续分页基于快照查询,不受数据更新影响。官方已经不推荐使用。
1)代码示例(search_after方式)
// 第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);
2)方案优势
适合ES海量数据的获取:避免from + size在from较大时的性能问题(ES 会将前 N 条数据加载到内存)。
兼容性强:同时解决排序不唯一问题。
3)方案劣势
不支持直接跳页:无法像LIMIT 40,10那样直接跳转到第 5 页,仅支持上一页/下一页或滑动加载。
4)适用场景
ES 大数据量全量导出(如导出近 1 个月日志、批量导出 Excel)
同方案1列举的场景
四、总结
问题本质:分页重复/跳过源于「锚点不稳定」(用offset易受数据增删影响)和「排序不唯一」(单一字段排序规则不固定),解法是用「数据标记锚点」(如游标)和「唯一排序组合」(如create_time + id)。
方案选择逻辑:按「是否需跳页→数据量→更新频率」决策,如B端需跳页且数据量小用LIMIT + 时间戳,C 端滑动加载且数据量大用游标分页,ES 批量导出用Search_after。
规范价值:技术方案解决单次问题,建立工程规范(需求 - 编码 - 测试 - 监控)将个人经验转化为团队标准,CR分页查询代码重点关注排序项是否唯一、是否游标分页、分页是否有防重措施等,避免重复踩坑,保障分页功能稳定,提升用户体验与业务营收。
作者介绍
张丛丛,侠客汇Java开发工程师。
如果字段的最大可能长度超过255字节,那么长度值可能…
只能说作者太用心了,优秀
感谢详解
一般干个7-8年(即30岁左右),能做到年入40w-50w;有…
230721