Redis ZSet 实现关注 Feed 流:推模式、收件箱和滚动分页讲透

本文整理自黑马点评 Redis 实战篇第 9 章后半部分。前半部分解决了关注关系和共同关注,后半部分开始解决一个更像真实社交产品的问题:我关注的人发布了新内容,系统如何让我在关注页刷到?这就是 Feed 流。

1. 这篇文章解决什么问题

用户关注了博主之后,最自然的需求是:

我关注的人发布了探店笔记,我能在关注页看到。

比如:

用户 5 关注了用户 10 和用户 20。
用户 10 发布 blogA。
用户 20 发布 blogB。
用户 30 发布 blogC。

那么用户 5 的关注页应该看到:

blogA
blogB

不应该看到:

blogC

因为用户 5 没关注用户 30。

第 9.3 到 9.5 解决的就是这个问题:

关注 Feed 流怎么设计?
发布笔记时怎么推送给粉丝?
用户打开关注页时怎么分页查询?

2. Feed 流是什么

Feed 流可以理解成信息流,比如:

朋友圈
微博关注页
B站动态
小红书关注页

它和搜索不同。

搜索是:

用户主动找内容。

Feed 流是:

系统把内容组织好,持续推给用户。

黑马点评这里的关注 Feed 流不是智能推荐,而是基于关注关系的时间线:

只看我关注的人
按发布时间倒序展示

3. Timeline 和智能排序

Feed 产品常见两类模式:

Timeline:按时间排序,不做复杂推荐
智能排序:根据算法推荐用户可能感兴趣的内容

本项目采用 Timeline。

原因很简单:关注页的语义是:

我想看我关注的人最近发了什么。

不是:

系统猜你喜欢什么。

所以它只需要按关注关系过滤,再按发布时间排序。


4. Timeline 的三种实现方案

讲义里介绍了三种方案:

拉模式
推模式
推拉结合

拉模式:读扩散

作者发布内容时,只存到作者自己的发件箱。

用户打开关注页时,再临时去自己关注的人那里拉取内容、合并、排序。

写轻,读重,省空间,但读取延迟高。

推模式:写扩散

作者发布内容时,系统主动把内容推送到所有粉丝的收件箱。

用户打开关注页时,只读自己的收件箱。

写重,读轻,读取快,但粉丝多时写入压力大。

推拉结合

普通用户用推模式,大 V 用拉模式或部分推送。

兼顾读写压力,但实现复杂。

黑马点评项目采用的是推模式。


5. 为什么用 Redis ZSet 做收件箱

推模式需要给每个用户准备一个收件箱。

比如用户 5 的收件箱:

feed:5

当用户关注的人发布 blog 时,把 blogId 写入这个收件箱。

收件箱需要满足两个条件:

1. 存一批 blogId
2. 按发布时间排序

Redis ZSet 正好适合:

key    = feed:{粉丝id}
member = blogId
score  = 发布时间戳

例如:

feed:5
  blogId=101, score=1710000000000
  blogId=102, score=1710000005000
  blogId=103, score=1710000010000

score 越大,发布时间越晚。

查询时按 score 倒序,就能得到最新动态。


6. 发布博客时推送到粉丝收件箱

核心代码:

@Override
public Result saveBlog(Blog blog) {
    UserDTO user = UserHolder.getUser();
    blog.setUserId(user.getId());

    boolean isSuccess = save(blog);
    if(!isSuccess){
        return Result.fail("新增笔记失败!");
    }

    List<Follow> follows = followService.query()
            .eq("follow_user_id", user.getId())
            .list();

    for (Follow follow : follows) {
        Long userId = follow.getUserId();
        String key = FEED_KEY + userId;
        stringRedisTemplate.opsForZSet()
                .add(key, blog.getId().toString(), System.currentTimeMillis());
    }

    return Result.ok(blog.getId());
}

先保存 blog 到数据库,拿到 blogId。

然后查询作者的粉丝:

.eq("follow_user_id", user.getId())

为什么是 follow_user_id = 作者id

因为 tb_follow 中:

user_id        粉丝
follow_user_id 被关注的人,也就是作者

如果用户 5 关注用户 10:

user_id = 5
follow_user_id = 10

现在用户 10 发博客,应该推送给用户 5。所以要查:

SELECT * FROM tb_follow WHERE follow_user_id = 10;

查出来的 user_id 就是粉丝 id。

推送时:

String key = FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());

对应 Redis:

ZADD feed:5 当前时间戳 blogId

7. 为什么不能用传统分页

Feed 流不能简单用:

page=1,size=5
page=2,size=5

因为数据会不断插到顶部。

假设第一次查时:

10 9 8 7 6 5 4 3 2 1

第一页拿到:

10 9 8 7 6

这时有人新发了一条:

11 10 9 8 7 6 5 4 3 2 1

如果再查第二页,跳过前 5 条,会拿到:

6 5 4 3 2

6 重复了。

原因是传统分页依赖固定位置,但 Feed 流顶部会不断插入新数据,位置会变化。


8. 滚动分页:lastId + offset

Feed 流更适合滚动分页,也叫游标分页。

它不问:

我要第几页?

而是问:

我上次读到哪里了?

在本项目里:

lastId = 上一次查询结果中的最小时间戳
offset = 在这个最小时间戳下,已经读过几条

为什么还需要 offset?

因为多个 blog 可能有相同时间戳。

例如:

blogId  score
5       1000
4       900
3       900
2       900
1       800

每次查 2 条。

第一次拿到:

5(score=1000)
4(score=900)

下一次要从 score=900 继续查,但 score=900 的第一条已经读过,所以要传:

lastId=900
offset=1

这样才能跳过已读数据。


9. 查询关注流接口

Controller:

@GetMapping("/of/follow")
public Result queryBlogOfFollow(
        @RequestParam("lastId") Long max,
        @RequestParam(value = "offset", defaultValue = "0") Integer offset){
    return blogService.queryBlogOfFollow(max, offset);
}

注意:参数名叫 lastId,但它实际表示的是时间戳,不是 blogId。

Service 核心查询:

Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
        .reverseRangeByScoreWithScores(key, 0, max, offset, 2);

它对应 Redis 思路:

ZREVRANGEBYSCORE feed:{userId} max 0 LIMIT offset count

含义是:

查 score <= max 的数据
按 score 从大到小排序
跳过 offset 条
最多查 2 条
并返回 score

这里 2 是教学演示中的每页大小。


10. 为什么要 WithScores

Redis 收件箱里存的是:

member = blogId
score = 时间戳

查询时不只需要 blogId,还需要 score 来计算下一次请求的 minTimeoffset

所以用:

reverseRangeByScoreWithScores(...)

返回的每个 TypedTuple 里都有:

tuple.getValue() = blogId
tuple.getScore() = 时间戳

11. 解析 minTime 和 offset

代码:

List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
    ids.add(Long.valueOf(tuple.getValue()));
    long time = tuple.getScore().longValue();
    if(time == minTime){
        os++;
    }else{
        minTime = time;
        os = 1;
    }
}

这段做两件事:

1. 收集 blogId,后面回 MySQL 查博客详情
2. 统计本页最小时间戳 minTime,以及这个时间戳出现了几次 os

比如本页 score 是:

1000, 900, 900

最终:

minTime = 900
os = 2

也就是下一次从 score=900 继续查,并跳过已经读过的 2 条。

补充提醒:讲义代码里有一行 offset 修正逻辑容易让人困惑。如果本页仍然处在上一页的同一个时间戳分组中,下一次 offset 应该累加旧 offset。理解原则比死记代码更重要:

下一次 offset = 到目前为止,在 minTime 这个时间戳下已经读过几条。

12. 为什么又要 ids 和 idStr

Redis 只存 blogId,不存完整 Blog。

所以要回 MySQL 查完整博客:

String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query()
        .in("id", ids)
        .last("ORDER BY FIELD(id," + idStr + ")")
        .list();

这里有两个变量:

ids   给 SQL 的 IN 查询用
idStr 给 ORDER BY FIELD 保持顺序用

为什么要 ORDER BY FIELD

因为 Redis 返回的顺序是按时间排好的:

[5, 3, 9]

但 MySQL 的 IN 查询不保证顺序,可能返回:

[3, 5, 9]

这样 Feed 流顺序就乱了。

所以加:

ORDER BY FIELD(id, 5, 3, 9)

强制 MySQL 按 Redis 返回顺序返回。


13. 完整链路

作者发布博客

保存 blog 到 MySQL

查询作者所有粉丝

把 blogId 写入粉丝 feed ZSet

粉丝打开关注页

从 feed:{userId} 按时间倒序查 blogId

计算 minTime 和 offset

根据 blogId 回 MySQL 查 Blog

补充作者信息和点赞状态

返回 ScrollResult

文字版:

发布 blog
  -> 保存 tb_blog
  -> 查 tb_follow 找粉丝
  -> ZADD feed:{粉丝id} timestamp blogId
  -> 粉丝查询 /blog/of/follow
  -> ZSet 滚动分页取 blogId
  -> 回表查 Blog
  -> 返回列表和下一次游标

14. 易错点

1. lastId 不是 blogId

它实际是上一次查询结果中的最小时间戳。

2. offset 不是普通页码偏移

它只处理相同时间戳下已经读过几条数据的问题。

3. feed:{userId} 是粉丝的收件箱

不是作者的发件箱。

4. Redis 只存 blogId 和时间戳

完整 Blog 仍然在 MySQL。

5. 先关注再发布才会进入收件箱

如果博主先发博客,用户后关注,历史博客不会自动进入用户收件箱。这是推模式的正常结果。


15. 面试怎么说

如果面试官问:关注 Feed 流怎么实现?

可以回答:

我们采用 Timeline + 推模式。用户发布博客后,先保存博客到 MySQL,然后根据 tb_follow 查询该作者的所有粉丝,把 blogId 写入每个粉丝自己的 Redis ZSet 收件箱,key 是 feed:{userId},member 是 blogId,score 是发布时间戳。用户打开关注页时,从自己的 ZSet 中按 score 倒序滚动分页查询 blogId,再回 MySQL 查询博客详情。

如果问为什么不用普通分页:

Feed 流顶部会不断插入新数据,普通 offset 分页容易出现重复或漏数据,所以用滚动分页。每次返回本页最小时间戳 minTime 和相同时间戳下的 offset,下一次请求从这个位置继续查。


16. 总结

第 9 章后半部分的核心是:

用写扩散换读性能。发布时把 blogId 推送到粉丝的 Redis ZSet 收件箱,读取时直接从自己的收件箱按时间滚动分页查询。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐