【实战】搞定大厂面试必问的 Feed 流:基于 Redis ZSet 实现“滚动分页” (解决数据重复读问题)
·
一、 为什么传统的“页码分页”不行?
在做后台管理系统时,我们习惯用 page = 1, size = 10 这种方式。但在 Feed 流(动态列表)场景下,它是致命的。
1.1 场景模拟
假设你的数据库里按时间倒序排有 5 条动态:[5, 4, 3, 2, 1]。
- 用户刷新第 1 页:查出
[5, 4]。 - 此时,有一个新动态
6发布了。列表变成了[6, 5, 4, 3, 2, 1]。 - 用户下拉加载第 2 页:数据库执行
LIMIT 2, 2(跳过前 2 个,取 2 个)。 - 结果:跳过了
6, 5,取出了[4, 3]。
问题出现了:用户在第 1 页看过了 4,在第 2 页又看到了 4! 如果是新删除了数据,用户可能会漏看数据。
1.2 解决方案:滚动分页 (Scroll Pagination)
我们不再使用“第几页”这个概念,而是使用**“游标 (Cursor)”**。 每次查询都告诉后端:“我上一页看到的最后一条数据是谁,你从它后面接着给我找。”
二、 核心技术选型:Redis ZSet
为了支撑高并发的读写,Feed 流通常使用 Redis 的 Sorted Set (ZSet) 存储。
- Key:
feed:用户ID - Value:
blogId(动态ID) - Score:
timestamp(发布时间戳)
利用 ZSet 的天然排序特性,我们可以实现按时间倒序排列。而 Redis 提供的以下命令是实现滚动分页的基石:
Bash
# 从大到小查询,指定分数范围,并限制数量
ZREVRANGEBYSCORE key max min LIMIT offset count
- max: 上一次查询的最后一条动态的时间戳。
- min: 0。
- offset: 偏移量(核心难点,下文详解)。
- count: 每次查几条。
三、 难点攻克:时间戳一样怎么办?
如果仅仅记录“最后一条的时间戳”作为下一次的 max,会有一个边界 Bug: 如果恰好有 3 条动态是同一毫秒发布的,它们的 Score 一样。
- 上一页查到了第 1 条(时间戳 1000)。
- 下一页从 1000 开始查,如果不做偏移,会把刚才那条又查一遍。
- 如果直接
offset = 1,那万一有 5 条都是 1000 呢?直接跳过可能会漏数据。
最终策略: 我们需要记录两个参数传递给前端:
minTime:当前查询结果中,最后一条数据的 Score(时间戳)。offset:当前查询结果中,最后这个 Score 出现了几次。
四、 代码实现 (Java + RedisTemplate)
4.1 Controller 层参数定义
前端请求时,如果是第一次刷新,max 传当前时间,offset 传 0。之后每次请求,都把上一次后端返回的 minTime 和 offset 带回来。
4.2 Service 层核心逻辑 (硬核部分)
这段代码完美解决了分数重复时的偏移量计算问题。
Java
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1. 获取当前用户
Long userId = UserHolder.getUser().getId();
// 2. 查询收件箱:按分数倒序获取 ID 和 Score
// 对应 Redis 命令:ZREVRANGEBYSCORE key Max Min LIMIT offset count
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2); // 这里为了演示设为 2,实际可设为 10
// 3. 非空判断
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// 4. 解析数据:核心算法 —— 线性扫描 + 状态重置
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1; // 这里的 os 代表下一次查询的 offset
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
// 4.1 获取 id
ids.add(Long.valueOf(tuple.getValue()));
// 4.2 获取分数(时间戳)
long time = tuple.getScore().longValue();
// 4.3 计算 offset 的核心逻辑
if(time == minTime){
// 如果当前元素的时间 == 最小时间,说明发生了重复,计数器 +1
os++;
}else{
// 如果遇到了新的时间,说明上一组已经结束,重置 minTime 和 os
minTime = time;
os = 1;
}
}
// 特殊处理:如果查询结果里的所有数据时间戳都一样(或者延续了上一次的重复),
// 这里的 os 需要累加给前端传来的 offset,否则游标会原地踏步。
// 注意:这里的逻辑需根据你的实际业务场景微调,通常 minTime == max 时才需要累加
// 5. 根据 ID 批量查询数据库 (注意 MySQL 的 IN 查询无序问题)
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")") // 必须手动指定顺序!!
.list();
// ... (此处省略填充用户信息和点赞状态的代码) ...
// 6. 封装并返回 ScrollResult
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os); // 告诉前端:下次来的时候,跳过 os 个
r.setMinTime(minTime); // 告诉前端:下次从 minTime 开始找
return Result.ok(r);
}
五、 算法深度解析
在解析数据的 for 循环中,我们用到了一种类似 Run-Length Encoding (游程统计) 的算法思维。
- 目标:我们需要知道列表中最后一个时间戳,在列表末尾连续出现了几次。
- 过程:
- 遍历数据,
minTime始终更新为当前遍历到的时间(因为它是有序的,越往后越小)。 - 如果
time没变,os累加。 - 如果
time变了(遇到了新时间),os重置为 1。
- 遍历数据,
- 结果:循环结束时,
os恰好就是最后那组相同分数的元素个数。这就完美计算出了下一次 Redis 查询需要的OFFSET。
六、 总结与避坑指南
- ZSet 的 Score 精度陷阱:Redis 的 Score 是 Double 类型,如果使用雪花算法 ID 直接作为分数,可能会发生精度丢失。最佳实践是使用
System.currentTimeMillis()作为分数,如果需要更细粒度,可以将 ID 拼接到分数的小数位,或者使用上述的offset逻辑来处理重复。 - MySQL 的顺序问题:从 Redis 拿到的 IDs 是有序的,但
SELECT * FROM table WHERE id IN (...)返回的结果通常是按主键 ID 排序的,会导致顺序错乱。一定要配合ORDER BY FIELD使用。 - 推拉模式选择:本文实现的是推模式 (Push)。如果是千万级粉丝的大 V,建议结合拉模式 (Pull) 混合使用,避免写扩散导致 Redis 瞬间过载。
更多推荐


所有评论(0)