在后端开发中,分页查询是最基础也最常用的功能之一。无论是运营后台各模块的列表,还是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
按理应该返回 P11~P20,但由于新插入了1条数据,整个结果集向后偏移,实际返回的是 P10~P19。
结果:P10 在第1页和第2页都出现了,造成数据重复。
更极端的情况是,如果新数据插入量大于等于页大小,用户可能会遇到「连续多页显示相同数据」,甚至「永远无法看到后续数据」的问题。
解决方案
方案 1:「时间戳 + 唯一键」做「游标分页」
这是目前最主流、最彻底的方案,核心是放弃「偏移量(offset)」,改用「上一页最后一条数据的标记」作为分页锚点,彻底摆脱结果集变化的影响。
实现原理
确定「唯一排序键」:必须包含「时间字段(如create_time)+ 唯一键(如id)」,确保排序唯一(解决场景 3 的顺序混乱)。
分页时不传递offset,而是传递「上一页最后一条数据的create_time和id」。
下一页查询用「大于 / 小于」条件过滤,替代LIMIT offset, size。
SQL示例
第 1 页查询(无锚点,取最新 10 条)
SELECT id, title, create_time FROM posts
ORDER BY create_time DESC, id DESC
LIMIT 10;
假设第 1 页最后一条数据为create_time=‘2025-12-07 14:30:00’,id=100。
第 2 页查询(用锚点过滤):
SELECT id, title, create_time FROM posts
WHERE create_time <= '2025-12-0714: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 页查询(记录时间窗口):
-- 假设当前时间为2024-05-20 15:00:00
SELECT id, title, create_time FROM posts
WHERE create_time <= '2025-12-07 15:00:00' -- 固定时间窗口
ORDER BY create_time DESC, id DESC
LIMIT 0,10;
第 2 页查询(沿用时间窗口):
SELECT id, title, create_time FROM posts
WHERE create_time <= '2025-12-07 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方式)
使用了 olivere/elastic/v7 库,这是 Go 语言中最流行的 Elasticsearch 客户端之一,API 设计友好且功能完整。
注意事项
- 使用 Search After 时,From参数必须设置为 0 或 - 1
- 排序字段必须与第一页完全一致
- 建议使用唯一字段(如 id)作为最后一个排序字段,确保结果一致性
- 该实现适用于 Elasticsearch 7.x 版本
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/olivere/elastic/v7"
)
func main() {
// 创建Elasticsearch客户端
client, err := elastic.NewClient(
elastic.SetURL("http://localhost:9200"), // Elasticsearch地址
elastic.SetSniff(false), // 禁用sniffing
elastic.SetHealthcheckInterval(10*time.Second),
)
if err != nil {
log.Fatalf("创建Elasticsearch客户端失败: %v", err)
}
defer client.Stop()
// 检查客户端连接
info, code, err := client.Ping("http://localhost:9200").Do(context.Background())
if err != nil {
log.Fatalf("连接Elasticsearch失败: %v", err)
}
log.Printf("连接成功,Elasticsearch版本: %s,状态码: %d", info.Version.Number, code)
// 第1页查询
firstPageResults, err := searchFirstPage(client)
if err != nil {
log.Fatalf("第1页查询失败: %v", err)
}
// 获取第1页最后一条数据的排序值(作为下一页的游标)
if len(firstPageResults.Hits.Hits) == 0 {
log.Println("第1页没有数据")
return
}
lastHit := firstPageResults.Hits.Hits[len(firstPageResults.Hits.Hits)-1]
lastSortValues := lastHit.Sort // 获取排序值数组
log.Printf("第1页最后一条数据的排序值: %v", lastSortValues)
// 第2页查询(用Search After)
secondPageResults, err := searchWithSearchAfter(client, lastSortValues)
if err != nil {
log.Fatalf("第2页查询失败: %v", err)
}
// 处理结果
log.Printf("第1页查询到 %d 条数据", len(firstPageResults.Hits.Hits))
log.Printf("第2页查询到 %d 条数据", len(secondPageResults.Hits.Hits))
}
// searchFirstPage 第一页查询
func searchFirstPage(client *elastic.Client) (*elastic.SearchResult, error) {
// 创建matchAll查询
matchAllQuery := elastic.NewMatchAllQuery()
// 构建搜索请求
searchResult, err := client.Search().
Index("posts"). // 索引名
Query(matchAllQuery).
// 按create_time(降序)、id(降序)排序
Sort("create_time", false). // false表示降序
Sort("id", false). // false表示降序
Size(10). // 每页10条数据
Do(context.Background()) // 执行查询
if err != nil {
return nil, fmt.Errorf("执行搜索失败: %w", err)
}
return searchResult, nil
}
// searchWithSearchAfter 使用Search After进行分页查询
func searchWithSearchAfter(client *elastic.Client, searchAfter []interface{}) (*elastic.SearchResult, error) {
// 创建matchAll查询
matchAllQuery := elastic.NewMatchAllQuery()
// 构建搜索请求,使用searchAfter
searchResult, err := client.Search().
Index("posts"). // 索引名
Query(matchAllQuery).
// 排序条件必须与第一页完全一致
Sort("create_time", false).
Sort("id", false).
SearchAfter(searchAfter...). // 传入上一页的游标值
From(0). // 使用searchAfter时必须设置为0或-1
Size(10). // 每页10条数据
Do(context.Background()) // 执行查询
if err != nil {
return nil, fmt.Errorf("执行searchAfter搜索失败: %w", err)
}
return searchResult, nil
}
// 处理搜索结果的辅助函数
func processSearchResults(results *elastic.SearchResult) {
for i, hit := range results.Hits.Hits {
fmt.Printf("第 %d 条数据 - ID: %s, Score: %f, Sort Values: %v\n",
i+1, hit.Id, *hit.Score, hit.Sort)
}
}
方案优势
-
适合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(code review)分页查询代码重点关注排序项是否唯一、是否游标分页、分页是否有防重措施等,避免重复踩坑,保障分页功能稳定,提升用户体验与业务营收。
112

被折叠的 条评论
为什么被折叠?



