黑马点评-Redis ZSet-实现关注 Feed 流

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

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

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

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

text 复制代码
我关注的人发布了探店笔记,我能在关注页看到。

比如:

text 复制代码
用户 5 关注了用户 10 和用户 20。
用户 10 发布 blogA。
用户 20 发布 blogB。
用户 30 发布 blogC。

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

text 复制代码
blogA
blogB

不应该看到:

text 复制代码
blogC

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

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

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

2. Feed 流是什么

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

text 复制代码
朋友圈
微博关注页
B站动态
小红书关注页

它和搜索不同。

搜索是:

text 复制代码
用户主动找内容。

Feed 流是:

text 复制代码
系统把内容组织好,持续推给用户。

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

text 复制代码
只看我关注的人
按发布时间倒序展示

3. Timeline 和智能排序

Feed 产品常见两类模式:

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

本项目采用 Timeline。

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

text 复制代码
我想看我关注的人最近发了什么。

不是:

text 复制代码
系统猜你喜欢什么。

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


4. Timeline 的三种实现方案

讲义里介绍了三种方案:

text 复制代码
拉模式
推模式
推拉结合

拉模式:读扩散

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

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

text 复制代码
写轻,读重,省空间,但读取延迟高。

推模式:写扩散

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

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

text 复制代码
写重,读轻,读取快,但粉丝多时写入压力大。

推拉结合

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

text 复制代码
兼顾读写压力,但实现复杂。

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


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

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

比如用户 5 的收件箱:

text 复制代码
feed:5

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

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

text 复制代码
1. 存一批 blogId
2. 按发布时间排序

Redis ZSet 正好适合:

text 复制代码
key    = feed:{粉丝id}
member = blogId
score  = 发布时间戳

例如:

text 复制代码
feed:5
  blogId=101, score=1710000000000
  blogId=102, score=1710000005000
  blogId=103, score=1710000010000

score 越大,发布时间越晚。

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


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

核心代码:

java 复制代码
@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。

然后查询作者的粉丝:

java 复制代码
.eq("follow_user_id", user.getId())

为什么是 follow_user_id = 作者id

因为 tb_follow 中:

text 复制代码
user_id        粉丝
follow_user_id 被关注的人,也就是作者

如果用户 5 关注用户 10:

text 复制代码
user_id = 5
follow_user_id = 10

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

sql 复制代码
SELECT * FROM tb_follow WHERE follow_user_id = 10;

查出来的 user_id 就是粉丝 id。

推送时:

java 复制代码
String key = FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());

对应 Redis:

redis 复制代码
ZADD feed:5 当前时间戳 blogId

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

Feed 流不能简单用:

text 复制代码
page=1,size=5
page=2,size=5

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

假设第一次查时:

text 复制代码
10 9 8 7 6 5 4 3 2 1

第一页拿到:

text 复制代码
10 9 8 7 6

这时有人新发了一条:

text 复制代码
11 10 9 8 7 6 5 4 3 2 1

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

text 复制代码
6 5 4 3 2

6 重复了。

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


8. 滚动分页:lastId + offset

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

它不问:

text 复制代码
我要第几页?

而是问:

text 复制代码
我上次读到哪里了?

在本项目里:

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

为什么还需要 offset?

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

例如:

text 复制代码
blogId  score
5       1000
4       900
3       900
2       900
1       800

每次查 2 条。

第一次拿到:

text 复制代码
5(score=1000)
4(score=900)

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

text 复制代码
lastId=900
offset=1

这样才能跳过已读数据。


9. 查询关注流接口

Controller:

java 复制代码
@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 核心查询:

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

它对应 Redis 思路:

redis 复制代码
ZREVRANGEBYSCORE feed:{userId} max 0 LIMIT offset count

含义是:

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

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


10. 为什么要 WithScores

Redis 收件箱里存的是:

text 复制代码
member = blogId
score = 时间戳

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

所以用:

java 复制代码
reverseRangeByScoreWithScores(...)

返回的每个 TypedTuple 里都有:

text 复制代码
tuple.getValue() = blogId
tuple.getScore() = 时间戳

11. 解析 minTime 和 offset

代码:

java 复制代码
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;
    }
}

这段做两件事:

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

比如本页 score 是:

text 复制代码
1000, 900, 900

最终:

text 复制代码
minTime = 900
os = 2

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

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

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

12. 为什么又要 ids 和 idStr

Redis 只存 blogId,不存完整 Blog。

所以要回 MySQL 查完整博客:

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

这里有两个变量:

text 复制代码
ids   给 SQL 的 IN 查询用
idStr 给 ORDER BY FIELD 保持顺序用

为什么要 ORDER BY FIELD

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

text 复制代码
[5, 3, 9]

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

text 复制代码
[3, 5, 9]

这样 Feed 流顺序就乱了。

所以加:

sql 复制代码
ORDER BY FIELD(id, 5, 3, 9)

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


13. 完整链路

#mermaid-svg-ycYWlCKlqCj6Bhec{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ycYWlCKlqCj6Bhec .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ycYWlCKlqCj6Bhec .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ycYWlCKlqCj6Bhec .error-icon{fill:#552222;}#mermaid-svg-ycYWlCKlqCj6Bhec .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ycYWlCKlqCj6Bhec .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ycYWlCKlqCj6Bhec .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ycYWlCKlqCj6Bhec .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ycYWlCKlqCj6Bhec .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ycYWlCKlqCj6Bhec .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ycYWlCKlqCj6Bhec .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ycYWlCKlqCj6Bhec .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ycYWlCKlqCj6Bhec .marker.cross{stroke:#333333;}#mermaid-svg-ycYWlCKlqCj6Bhec svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ycYWlCKlqCj6Bhec p{margin:0;}#mermaid-svg-ycYWlCKlqCj6Bhec .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ycYWlCKlqCj6Bhec .cluster-label text{fill:#333;}#mermaid-svg-ycYWlCKlqCj6Bhec .cluster-label span{color:#333;}#mermaid-svg-ycYWlCKlqCj6Bhec .cluster-label span p{background-color:transparent;}#mermaid-svg-ycYWlCKlqCj6Bhec .label text,#mermaid-svg-ycYWlCKlqCj6Bhec span{fill:#333;color:#333;}#mermaid-svg-ycYWlCKlqCj6Bhec .node rect,#mermaid-svg-ycYWlCKlqCj6Bhec .node circle,#mermaid-svg-ycYWlCKlqCj6Bhec .node ellipse,#mermaid-svg-ycYWlCKlqCj6Bhec .node polygon,#mermaid-svg-ycYWlCKlqCj6Bhec .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ycYWlCKlqCj6Bhec .rough-node .label text,#mermaid-svg-ycYWlCKlqCj6Bhec .node .label text,#mermaid-svg-ycYWlCKlqCj6Bhec .image-shape .label,#mermaid-svg-ycYWlCKlqCj6Bhec .icon-shape .label{text-anchor:middle;}#mermaid-svg-ycYWlCKlqCj6Bhec .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ycYWlCKlqCj6Bhec .rough-node .label,#mermaid-svg-ycYWlCKlqCj6Bhec .node .label,#mermaid-svg-ycYWlCKlqCj6Bhec .image-shape .label,#mermaid-svg-ycYWlCKlqCj6Bhec .icon-shape .label{text-align:center;}#mermaid-svg-ycYWlCKlqCj6Bhec .node.clickable{cursor:pointer;}#mermaid-svg-ycYWlCKlqCj6Bhec .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ycYWlCKlqCj6Bhec .arrowheadPath{fill:#333333;}#mermaid-svg-ycYWlCKlqCj6Bhec .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ycYWlCKlqCj6Bhec .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ycYWlCKlqCj6Bhec .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ycYWlCKlqCj6Bhec .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ycYWlCKlqCj6Bhec .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ycYWlCKlqCj6Bhec .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ycYWlCKlqCj6Bhec .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ycYWlCKlqCj6Bhec .cluster text{fill:#333;}#mermaid-svg-ycYWlCKlqCj6Bhec .cluster span{color:#333;}#mermaid-svg-ycYWlCKlqCj6Bhec div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ycYWlCKlqCj6Bhec .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ycYWlCKlqCj6Bhec rect.text{fill:none;stroke-width:0;}#mermaid-svg-ycYWlCKlqCj6Bhec .icon-shape,#mermaid-svg-ycYWlCKlqCj6Bhec .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ycYWlCKlqCj6Bhec .icon-shape p,#mermaid-svg-ycYWlCKlqCj6Bhec .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ycYWlCKlqCj6Bhec .icon-shape .label rect,#mermaid-svg-ycYWlCKlqCj6Bhec .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ycYWlCKlqCj6Bhec .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ycYWlCKlqCj6Bhec .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ycYWlCKlqCj6Bhec :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 作者发布博客
保存 blog 到 MySQL
查询作者所有粉丝
把 blogId 写入粉丝 feed ZSet
粉丝打开关注页
从 feed:{userId} 按时间倒序查 blogId
计算 minTime 和 offset
根据 blogId 回 MySQL 查 Blog
补充作者信息和点赞状态
返回 ScrollResult

文字版:

text 复制代码
发布 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 收件箱,读取时直接从自己的收件箱按时间滚动分页查询。

相关推荐
天疆说1 小时前
在 Ubuntu 24.04 上安装 MATLAB R2021b
数据库·ubuntu·matlab
码云数智-大飞1 小时前
Go Channel 详解:并发通信的正确姿势
前端·数据库·git
yyuuuzz1 小时前
2026游戏云服务器推荐的技术判断思路
运维·服务器·开发语言·网络·人工智能·游戏·php
江华森1 小时前
Linux 运维新手入门课
linux·运维·服务器
勇敢牛牛_1 小时前
Zeplyn:通过P2P构建服务共享网络
网络·网络协议·p2p·服务
Volunteer Technology1 小时前
Flink Table API与SQL(二)
大数据·数据库·flink
杨云龙UP1 小时前
Spotlight 接入 Oracle 数据库监控操作指南 2026-06-16
数据库·oracle·性能监控·预警·阈值·spotlight·瓶颈分析
正在走向自律2 小时前
KingbaseES MySQL模式深度解析,从语法兼容到迁移的全栈指南
数据库·数据库架构·kingbasees·电科金仓
叫我:松哥2 小时前
基于Python flask的中学可控智能命题系统设计与实现,整合遗传算法、DeepSeek 大模型及数据库技术构建一体化应用
数据库·人工智能·python·算法·机器学习·flask·遗传算法