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

在后端开发中,分页查询是最基础也最常用的功能之一。无论是运营后台各模块的列表,还是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)分页查询代码重点关注排序项是否唯一、是否游标分页、分页是否有防重措施等,避免重复踩坑,保障分页功能稳定,提升用户体验与业务营收。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值