在日常开发中,分页查询是一个非常常见的需求。传统的基于数据库的Limit Offset分页方式虽然简单,但在数据量较大或数据实时变动(如插入新数据)的场景下,会出现重复数据数据遗漏的问题。本文将探讨如何利用Redis的Sorted Set(有序集合)数据结构来实现高效、准确的滚动分页查询,并结合具体Java代码进行详细解析。

一、传统分页的痛点:为什么需要滚动分页?

我们先回顾一下传统的Limit Offset分页。假设我们有一张博客表,要查询第2页数据(每页2条),SQL通常是:

1
2

SELECT * FROM blog ORDER BY create_time DESC LIMIT 2 OFFSET 2;

这种方式的问题在于:如果在查询第1页后、第2页前,有一条新的博客插入(create_time比第1页的部分数据更新),那么第2页的结果就会包含原本第1页的最后一条数据,导致重复查询。如下图所示:

  • 初始数据:[A(时间10), B(时间9), C(时间8), D(时间7)]

  • 第1页(LIMIT 2 OFFSET 0):[A, B]

  • 插入新数据E(时间11),数据变为:[E(11), A(10), B(9), C(8), D(7)]

  • 第2页(LIMIT 2 OFFSET 2):[B, C] → B重复出现

滚动分页(也叫游标分页)则通过上一页的最后一个标记(如时间戳)来定位下一页的起始位置,避免了Offset带来的问题。而Redis的Sorted Set恰好能完美支持这种场景。

二、Redis Sorted Set的特性:为什么适合滚动分页?

Redis的Sorted Set(有序集合)是一种特殊的数据结构,它为每个元素分配一个分数(Score),并按照分数对元素进行排序。其核心特性包括:

  1. 有序性:元素天然按照Score升序或降序排列,无需额外排序操作。

  2. 范围查询:支持通过Score范围(如0到maxScore)查询元素,这是实现滚动分页的关键。

  3. 高效性:无论是插入、查询还是删除操作,时间复杂度均为O(logN),适合大数据量场景。

在滚动分页场景中,我们可以将业务数据的唯一标识(如博客ID)作为Sorted Set的元素(Value),将排序字段(如创建时间戳)作为元素的分数(Score)。这样,通过Score范围就能快速定位下一页数据。

三、滚动分页的实现思路与代码解析

下面结合提供的Java代码,详细拆解利用Redis Sorted Set实现滚动分页的完整流程。本文以“查询关注的博客动态”为例进行说明。

3.1 核心流程概览

滚动分页的核心是通过“上一页的最小时间戳(minTime)”和“偏移量(offset)”来定位下一页数据,具体流程如下:

  1. 定义Redis的Sorted Set键:以用户ID为维度,存储该用户关注的博客ID集合(如feed:1001表示用户1001的关注动态)。

  2. 查询上一页数据:通过reverseRangeByScoreWithScores方法,查询Score在0到max(上一页的minTime)之间的元素,同时指定偏移量和每页数量。

  3. 处理查询结果:提取博客ID、计算当前页的最小时间戳和偏移量,用于下一页查询。

  4. 封装并返回结果:将博客详情、minTime、offset返回给前端,作为下一页请求的参数。

3.2 代码逐行解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1. 获取当前用户
UserDTO user = UserHolder.getUser();
Long userId = user.getId();
String key = "feed:" + userId; // Redis键:feed:用户ID
// 2. 从Redis查询blogId列表
// reverseRangeByScoreWithScores:倒序查询Score在[0, max]之间的元素
// 参数说明:key, 最小Score, 最大Score, 偏移量, 每页数量
Set<ZSetOperations.TypedTuple<String>> result = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 3. 处理空结果
if(result == null || result.isEmpty()) {
log.debug("无新数据");
return Result.ok();
}
// 4. 提取博客ID、计算当前页的minTime和offset
long minTime = System.currentTimeMillis(); // 初始化为当前时间
int offset1 = 1; // 初始偏移量为1
List<Long> ids = new ArrayList<>(result.size());
for (ZSetOperations.TypedTuple<String> tuple : result) {
// 提取博客ID
ids.add(Long.valueOf(Objects.requireNonNull(tuple.getValue())));
// 提取Score(即博客创建时间戳)
long time = Objects.requireNonNull(tuple.getScore()).longValue();
// 处理相同时间戳的情况:若时间等于当前minTime,偏移量+1;否则更新minTime和偏移量
if(time == minTime) {
offset1++;
} else {
minTime = time;
offset1 = 1;
}
}
// 5. 查询博客详情(批量查询避免N+1问题,此处为简化示例)
List<Blog> blogs = new ArrayList<>(ids.size());
ids.forEach(id -> {
Blog blog = getById(id); // 从数据库查询博客详情
setBlogIsLiked(blog); // 处理博客点赞状态
queryUser(blog); // 填充博客作者信息
blogs.add(blog);
});
// 6. 封装滚动分页结果
ScrollResult<Blog> scrollResult = new ScrollResult<>();
scrollResult.setList(blogs); // 当前页博客列表
scrollResult.setMinTime(minTime); // 当前页最小时间戳(下一页的max参数)
scrollResult.setOffset(offset1); // 当前页最小时间戳对应的偏移量
return Result.ok(scrollResult);
}

3.3 关键细节说明

**为什么用reverseRangeByScoreWithScores?** 因为我们通常需要按时间倒序展示数据(最新的在前),该方法会按照Score从大到小返回元素,正好符合需求。

**max和offset参数的作用?** - `max`:上一页返回的minTime,代表下一页数据的Score不能超过这个值(即时间不能晚于这个值)。 - `offset`:当存在多个Score相同的元素时,用于跳过前N个元素,避免重复。

**如何处理相同时间戳的数据?** 如果多条博客的创建时间戳相同(Score相同),下一页查询时需要通过offset跳过这些重复的元素。例如,当前页有3条数据的Score都是1620000000,那么minTime=1620000000,offset=3,下一页查询时会从第4个Score=1620000000的元素开始。

四、滚动分页的优势与注意事项

4.1 优势

  • 无重复无遗漏:基于时间戳定位,即使中间插入新数据,也不会影响下一页的查询结果。

  • 高性能:Redis的Sorted Set查询效率高,尤其适合大数据量场景,避免了数据库Limit Offset的全表扫描问题。

  • 实时性好:数据插入Redis后可立即查询,无需等待数据库同步。

4.2 注意事项

  • Score的唯一性问题:如果排序字段(如时间戳)存在大量重复,需要通过offset来处理,否则可能出现数据漏查。

  • Redis数据一致性:需要保证业务数据(如博客)与Redis中的Sorted Set同步。例如,博客删除时,要及时从Redis中移除对应的元素。

  • 分页参数的传递:前端需要将上一页返回的minTime和offset作为下一页请求的参数,因此需要在接口设计中明确这两个参数。

五、总结

相比传统的Limit Offset分页,基于Redis Sorted Set的滚动分页在实时性、准确性和高性能方面都有明显优势,尤其适合动态数据(如关注动态、消息流)的分页场景。其核心是利用Sorted Set的Score有序性和范围查询能力,通过“上一页的最小时间戳+偏移量”来定位下一页数据,从根本上解决了传统分页的重复和遗漏问题。

在实际开发中,我们还可以根据业务需求优化细节,例如结合Redis的过期策略清理历史数据、使用管道(Pipeline)减少Redis交互次数等,进一步提升系统性能。