外观
05 - Feed 流与 Timeline
知识图谱
Feed 流模型选择
┌──────────┼──────────┐
▼ ▼ ▼
拉模式 推模式 推拉结合
(读扩散) (写扩散) (大V拉/普通推)
│ │
│ ┌────┘
│ ▼
│ 本项目采用: 推模式
│
│ 发布博客时:
│ 1. 保存到 DB
│ 2. 查询所有粉丝
│ 3. ZADD feed:{粉丝id} blogId timestamp
│ (推送到每个粉丝的收件箱)
│
▼
读取 Feed:
ZREVRANGEBYSCORE feed:{userId} max 0 LIMIT offset count
│
▼
┌──────────────────┐
│ ScrollResult │
│ list: 博客列表 │
│ minTime: 最小分数 │ → 下次请求的 max
│ offset: 偏移量 │ → 同分数元素个数
└──────────────────┘推模式实现
发布:Fan-out 到粉丝收件箱
文件: src/main/java/com/hmdp/service/impl/BlogServiceImpl.java
java
@Override
public Result saveBlog(Blog blog) {
// 1. 保存博客到 DB
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
boolean isSuccess = save(blog);
if (!isSuccess) {
return Result.fail("新增笔记失败!");
}
// 2. 查询所有粉丝
List<Follow> follows = followService.query()
.eq("follow_user_id", user.getId()).list();
// 3. 推送 blogId 到每个粉丝的 Feed 收件箱 (ZSet)
for (Follow follow : follows) {
Long userId = follow.getUserId();
String key = FEED_KEY + userId; // feed:{粉丝id}
stringRedisTemplate.opsForZSet().add(
key,
blog.getId().toString(),
System.currentTimeMillis() // score = 时间戳
);
}
return Result.ok(blog.getId());
}读取:游标分页
java
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
Long userId = UserHolder.getUser().getId();
String key = FEED_KEY + userId;
// ZREVRANGEBYSCORE feed:{userId} max 0 LIMIT offset 2
// 从 max(上次最小时间戳) 开始, 跳过 offset 个, 取 2 条
Set<ZSetOperations.TypedTuple<String>> typedTuples =
stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok();
}
// 解析结果: 收集 blogId, 计算新的 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++; // 同分数 → offset 递增
} else {
minTime = time;
os = 1; // 新分数 → offset 重置
}
}
// 查询博客详情 (保持 Redis 返回的顺序)
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query().in("id", ids)
.last("ORDER BY FIELD(id," + idStr + ")").list();
// 封装返回
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}ScrollResult 游标算法详解
假设 Feed ZSet 中有以下数据 (score 降序):
blogId=10, score=1000
blogId=9, score=900
blogId=8, score=900 ← 两个 blog 同一时刻发布
blogId=7, score=800
blogId=6, score=700
第一次请求: max=Long.MAX, offset=0, count=2
结果: [blogId=10(1000), blogId=9(900)]
minTime=900, offset=1 (score=900 出现了1次)
第二次请求: max=900, offset=1, count=2
ZREVRANGEBYSCORE key 900 0 LIMIT 1 2
跳过 offset=1 个 score=900 的元素(即 blogId=9)
结果: [blogId=8(900), blogId=7(800)]
minTime=800, offset=1
第三次请求: max=800, offset=1, count=2
结果: [blogId=6(700)]
minTime=700, offset=1
第四次请求: max=700, offset=1, count=2
结果: 空 → 结束关键点:offset 不是传统的页码偏移,而是"上一批结果中最小 score 出现的次数"。这样即使有多个相同时间戳的博客,也不会漏掉或重复。
博客点赞(ZSet)
java
@Override
public Result likeBlog(Long id) {
Long userId = UserHolder.getUser().getId();
String key = BLOG_LIKED_KEY + id; // blog:liked:{blogId}
// 检查是否已点赞 (ZSet score 查询)
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if (score == null) {
// 未点赞 → 点赞
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
if (isSuccess) {
stringRedisTemplate.opsForZSet().add(key, userId.toString(),
System.currentTimeMillis());
}
} else {
// 已点赞 → 取消
boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
if (isSuccess) {
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}面试 Q&A
Q1: 推模式和拉模式有什么区别?为什么选推模式?
维度 推模式(写扩散) 拉模式(读扩散) 写入 发布时推给所有粉丝 O(粉丝数) 只写自己的发件箱 O(1) 读取 直接读自己的收件箱 O(1) 聚合所有关注人的发件箱 O(关注数) 延迟 读取无延迟 读取需要实时聚合排序 存储 每个粉丝一份副本,冗余大 无冗余 选推模式的原因:
- 本项目是点评平台,不是微博——用户粉丝数中等(几百到几千),写扩散成本可控
- Feed 读取频率远高于发布频率(读多写少),优化读端更重要
- 实现简单,不需要复杂的聚合排序逻辑
追问:如果一个大 V 有 100 万粉丝,推模式还行吗?
不行。100 万次
ZADD操作耗时可能达到秒级,阻塞发布接口。解决方案:推拉结合
- 粉丝数 < 阈值(如 10000):推模式
- 粉丝数 > 阈值(大V):粉丝主动拉取
- 或者发布时异步推送(放入消息队列分批推送)
再追问:为什么不用消息队列异步推送?
当前的同步推送
for (Follow follow : follows)确实会阻塞发布接口。可以改为:
- 发布时只写 DB
- 发 Kafka 消息
- 消费者分批推送到粉丝的 Feed ZSet
项目中秒杀已经有了 Kafka 基础设施,复用即可。
Q2: 为什么用 ZREVRANGEBYSCORE 而不是 ZREVRANGE?
ZREVRANGE按下标分页(类似 LIMIT offset, count),但 Feed 流是动态数据——两次请求之间可能有新博客推送进来,导致下标偏移:第一次: ZREVRANGE feed 0 1 → [blog10, blog9] 此时新推送了 blog11 第二次: ZREVRANGE feed 2 3 → [blog9, blog8] ← blog9 重复了!
ZREVRANGEBYSCORE按 score(时间戳)范围查询,不受新元素插入影响:第一次: ZREVRANGEBYSCORE feed +inf 0 LIMIT 0 2 → [blog10(1000), blog9(900)] 此时新推送了 blog11(1100) 第二次: ZREVRANGEBYSCORE feed 900 0 LIMIT 1 2 → [blog8(800), blog7(700)]
追问:offset 参数的作用是什么?
处理同分数元素。如果多个博客恰好在同一毫秒发布(score 相同),LIMIT 的 offset 可以跳过上一批已经返回过的同分数元素。
具体逻辑:
- 每次查询后,记录最小 score (
minTime) 和该 score 出现的次数 (os)- 下次查询时,
max = minTime,offset = os- 这样跳过已经返回过的同分数元素,不重复不遗漏
Q3: Feed 流的数据会无限增长吗?
是的,当前实现没有清理机制。每个用户的
feed:{userId}ZSet 会随时间无限增长。改进方案:
- 定时清理:定期 ZREMRANGEBYSCORE 删除 N 天前的数据
- 限制大小:保持 ZSet 最多 1000 条,ZREMRANGEBYRANK 删除最旧的
- 冷热分离:近期 Feed 在 Redis,历史 Feed 查 DB
踩坑点
| 踩坑点 | 说明 | 面试官考察意图 |
|---|---|---|
| 同步推送阻塞 | saveBlog 中 for 循环同步 ZADD | 大V场景性能 |
| Feed 无限增长 | 没有清理策略 | 内存管理 |
| 页大小硬编码为 2 | offset, 2 写死在代码中 | 可配置性 |
System.currentTimeMillis() 精度 | 毫秒级,高并发下可能多个博客同 score | 时间精度 |
ORDER BY FIELD 拼接 | 直接拼接 id 到 SQL,理论上有注入风险 | 安全意识 |
加分回答
- 对比 Twitter 的 Fanout Service:推模式 + 异步 + 优先级队列
- 提到 Feed 流的三种模型及其适用场景(推/拉/推拉结合)
- 分析 ZSet 的内存占用:每个元素 score(8 bytes) + member(变长),大规模场景需要估算
- 提到可以用 Redis Stream 替代 ZSet 做 Feed 流(原生支持消费组和游标)
- 讨论
isBlogLiked在未登录时的处理(UserHolder.getUser()可能为 null)
关联文档
- 08-关注与社交 — Follow 数据是 Feed 推送的基础
- 00-项目总览与架构 — ZSet 在项目中的全局使用