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 来计算下一次请求的 minTime 和 offset。
所以用:
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 收件箱,读取时直接从自己的收件箱按时间滚动分页查询。