黑马点评-Redis ZSet-实现关注 Feed 流
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 来计算下一次请求的 minTime 和 offset。
所以用:
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
-> 保存 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 收件箱,读取时直接从自己的收件箱按时间滚动分页查询。
更多推荐



所有评论(0)