Skip to content

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(关注数)
延迟读取无延迟读取需要实时聚合排序
存储每个粉丝一份副本,冗余大无冗余

选推模式的原因:

  1. 本项目是点评平台,不是微博——用户粉丝数中等(几百到几千),写扩散成本可控
  2. Feed 读取频率远高于发布频率(读多写少),优化读端更重要
  3. 实现简单,不需要复杂的聚合排序逻辑

追问:如果一个大 V 有 100 万粉丝,推模式还行吗?

不行。100 万次 ZADD 操作耗时可能达到秒级,阻塞发布接口。

解决方案:推拉结合

  • 粉丝数 < 阈值(如 10000):推模式
  • 粉丝数 > 阈值(大V):粉丝主动拉取
  • 或者发布时异步推送(放入消息队列分批推送)

再追问:为什么不用消息队列异步推送?

当前的同步推送 for (Follow follow : follows) 确实会阻塞发布接口。可以改为:

  1. 发布时只写 DB
  2. 发 Kafka 消息
  3. 消费者分批推送到粉丝的 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 会随时间无限增长。

改进方案:

  1. 定时清理:定期 ZREMRANGEBYSCORE 删除 N 天前的数据
  2. 限制大小:保持 ZSet 最多 1000 条,ZREMRANGEBYRANK 删除最旧的
  3. 冷热分离:近期 Feed 在 Redis,历史 Feed 查 DB

踩坑点

踩坑点说明面试官考察意图
同步推送阻塞saveBlog 中 for 循环同步 ZADD大V场景性能
Feed 无限增长没有清理策略内存管理
页大小硬编码为 2offset, 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)

关联文档