如何设计一个 Feed 流系统?

🎯 面试题:设计一个微博/朋友圈/Twitter 样式的信息流

Feed 流是内容分发的核心形态,本质是”谁发了什么内容,推给谁看”。


一、Feed 流的核心概念

什么是 Feed?

Timeline(时间线)
┌──────────────────────────────────────┐
│  张三 发了动态                         │
│  李四 发了动态                         │  ← 按时间倒序排列
│  王五 发了动态                         │
│  赵六 发了动态                         │
└──────────────────────────────────────┘

Feed 流的几种模式

模式 说明 代表产品 核心挑战
Timeline 每个用户看到自己的关注列表,按时间排序 微博、朋友圈 如何快速拉取
Rank 按算法重新排序,不严格按时间 抖音、微信视频号 排序算法
Inbox 消息先聚合,再推送 微博 Push 模式 推送量大
Pull 用户拉取时实时聚合 微博 Timeline 读延迟高

核心性能指标

- 写入吞吐量:每秒发布消息数
- 读取延迟:用户打开 Feed 到内容展示 < 500ms
- 存储成本:每条消息的存储空间
- 推送成本:消息写放大比例(1:N 扩散)

二、整体架构设计

┌──────────────┐    ┌──────────────┐    ┌──────────────────┐
│  内容发布     │───▶│  Feed 写入服务 │───▶│  Message Queue  │
│ (发微博/发动态)│    │ (写扩散/读扩散)│    │ (Kafka/RocketMQ)│
└──────────────┘    └──────────────┘    └────────┬─────────┘
                                                  │
                           ┌──────────────────────┴──────────┐
                           ▼                              ▼
                    ┌──────────────┐              ┌──────────────┐
                    │  写入扩散     │              │  读取聚合     │
                    │ (Push Model) │              │ (Pull Model) │
                    └──────┬───────┘              └──────┬───────┘
                           │                              │
                           ▼                              ▼
                    ┌──────────────┐              ┌──────────────┐
                    │  收件箱存储   │              │  内容表       │
                    │ (Redis List) │              │ (MySQL/ES)   │
                    └──────────────┘              └──────────────┘
                           │                              │
                           ▼                              ▼
                    ┌──────────────┐              ┌──────────────┐
                    │  用户请求     │◀────────────│  关注列表     │
                    │ (读取收件箱)  │              │ (Redis Set)   │
                    └──────────────┘              └──────────────┘

三、核心存储方案

方案一:写扩散(Push)

思路:发布时主动把内容写入所有粉丝的收件箱。

发布流程:
用户A 发布内容
    ↓
查询 A 的所有粉丝列表(Redis Set)
    ↓
遍历粉丝列表,向每个粉丝的收件箱 LPUSH 新内容
    ↓
如果粉丝数 > 阈值,改用异步任务队列

优点:读取极快,直接从自己的收件箱拿数据
缺点:大 V 发布的写放大问题(1 粉丝 = N 次写操作)

大 V 问题:
粉丝数 = 1000 万
每次发微博 = 1000 万次写操作 ❌

解决方案:阈值截断
  - 粉丝数 < 1 万 → 实时写扩散
  - 粉丝数 >= 1 万 → 写扩散降级为只写自己收件箱
@Service
public class FeedPushService {

    @Autowired private FeedMapper feedMapper;
    @Autowired private FollowMapper followMapper;
    @Autowired private RedisTemplate<String, String> redisTemplate;
    @Autowired private RocketMQTemplate mqTemplate;

    // 大 V 粉丝数阈值,超过则降级
    private static final long PUSH_THRESHOLD = 10_000;

    public void publish(PublishRequest request) {
        Long userId = request.getUserId();

        // 1. 写入内容表
        Feed feed = new Feed();
        feed.setUserId(userId);
        feed.setContent(request.getContent());
        feedMapper.insert(feed);

        Long feedId = feed.getId();

        // 2. 判断是否走写扩散
        long fanCount = followMapper.selectFanCount(userId);

        if (fanCount < PUSH_THRESHOLD) {
            // 小 V:实时写扩散,遍历粉丝写入各自收件箱
            List<Long> fanIds = followMapper.selectFanIds(userId);
            for (Long fanId : fanIds) {
                String inboxKey = "inbox:" + fanId;
                redisTemplate.opsForList().leftPush(inboxKey, String.valueOf(feedId));
                // 限制收件箱长度,只保留最近 500 条
                redisTemplate.opsForList().trim(inboxKey, 0, 499);
            }
        } else {
            // 大 V:写扩散降级
            // 只写自己的收件箱,粉丝通过读扩散拉取
            String selfInboxKey = "inbox:" + userId;
            redisTemplate.opsForList().leftPush(selfInboxKey, String.valueOf(feedId));

            // 发 MQ 通知离线粉丝(可选)
            mqTemplate.convertAndSend("fan-notify-topic",
                new FanNotifyEvent(userId, feedId, fanIds));
        }
    }
}

方案二:读扩散(Pull)

思路:发布时只写自己的内容表,读的时候实时聚合关注列表。

读取流程:
用户请求 Feed
    ↓
获取关注列表(Redis Set,1000 个用户)
    ↓
批量查询每个用户最近 N 条内容
    ↓
归并排序(按时间/热度)
    ↓
返回前 M 条

优点:写入成本极低,大 V 无压力
缺点:读取时计算量大,关注列表大时延迟高

@Service
public class FeedPullService {

    @Autowired private FeedMapper feedMapper;
    @Autowired private FollowMapper followMapper;

    public List<FeedVO> getFeed(Long userId, int offset, int limit) {
        // 1. 获取关注列表(带缓存)
        List<Long> followingIds = getFollowingWithCache(userId);
        if (followingIds.isEmpty()) {
            return Collections.emptyList();
        }

        // 2. 批量查询每个用户最近的 feeds
        int pageSize = 20;
        List<Feed> feeds = feedMapper.selectByUsersAndLimit(
            followingIds,
            offset,
            pageSize * followingIds.size()
        );

        // 3. 内存归并排序
        feeds.sort((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt()));

        // 4. 截取分页
        return feeds.stream()
            .skip(offset)
            .limit(limit)
            .collect(Collectors.toList());
    }

    private List<Long> getFollowingWithCache(Long userId) {
        String key = "following:" + userId;
        List<String> ids = redisTemplate.opsForList().range(key, 0, -1);
        if (ids == null || ids.isEmpty()) {
            List<Long> followingIds = followMapper.selectFollowingIds(userId);
            if (!followingIds.isEmpty()) {
                redisTemplate.opsForList().rightPushAll(key, followingIds.stream()
                    .map(String::valueOf).toList());
            }
            return followingIds;
        }
        return ids.stream().map(Long::parseLong).toList();
    }
}

方案三:混合模式(推荐生产方案)

核心思路:分层存储 + 分级读取

小 V(粉丝 < 1 万)→ 写扩散,粉丝收件箱直接拿到内容
大 V(粉丝 >= 1 万)→ 写自己收件箱,粉丝用读扩散拉取

读取时:
  1. 先取自己的收件箱(小 V 写进来的)
  2. 再批量拉取大 V 朋友的最新内容
  3. 归并排序后展示
Redis 存储:
inbox:{userId}  → List(收件箱消息 ID 列表,最大 500 条)
user:feeds:{userId} → List(自己发布的内容,备份用)

内容存储:
feed:{feedId}   → Hash(Feed 详情缓存)
feed:comments:{feedId} → List(评论列表)

四、缓存设计

多级缓存策略

L1: 用户本地缓存(App 端,LRU,100 条)
L2: Redis 分布式缓存(热内容,TTL 1 小时)
L3: MySQL/ES 持久化存储
// 读取流程:先本地 → 再 Redis → 最后 DB
public List<FeedVO> getFeedWithCache(Long userId, int page) {
    int pageSize = 20;
    String cacheKey = "feed:page:" + userId + ":" + page;

    // L1: 本地缓存(Guava Cache / Caffeine)
    List<FeedVO> localCached = localCache.getIfPresent(cacheKey);
    if (localCached != null) {
        return localCached;
    }

    // L2: Redis 缓存
    List<String> cachedIds = redisTemplate.opsForList().range(cacheKey, 0, pageSize - 1);
    if (cachedIds != null && !cachedIds.isEmpty()) {
        List<FeedVO> feeds = batchGetFeedDetail(cachedIds);
        localCache.put(cacheKey, feeds);
        return feeds;
    }

    // L3: 读取收件箱
    String inboxKey = "inbox:" + userId;
    long start = (long) page * pageSize;
    List<String> feedIds = redisTemplate.opsForList().range(inboxKey, start, start + pageSize - 1);

    if (feedIds == null || feedIds.isEmpty()) {
        return Collections.emptyList();
    }

    List<FeedVO> feeds = batchGetFeedDetail(feedIds);
    localCache.put(cacheKey, feeds);
    redisTemplate.opsForList().rightPushAll(cacheKey, feedIds);
    redisTemplate.expire(cacheKey, 1, TimeUnit.HOURS);

    return feeds;
}

五、点赞/评论的写入优化

点赞操作:
❌ 每点赞一次 → 写入 Feed 记录
✅ 点赞不写 Feed 表,只更新点赞计数 + 发 MQ 通知被赞用户

评论操作:
✅ 评论写 Feed 表 → 进入关注者的信息流
// 评论时写 Feed(评论比点赞重要,进入信息流)
public void comment(CommentRequest request) {
    Feed feed = new Feed();
    feed.setUserId(request.getUserId());
    feed.setContent(request.getContent());
    feed.setType(FeedType.COMMENT);
    feed.setRootId(request.getRootId());    // 关联原动态
    feedMapper.insert(feed);

    // 评论扩散:只通知原动态作者
    notifyAuthor(request.getRootId(), feed);
}

六、分页设计

游标分页(推荐)

避免 Offset 分页的深度翻页性能问题
Feed 是实时性强的数据,Offset 翻到第 1000 页时数据已过时

方案:用 timestamp + id 做游标
// 请求参数:lastTime=1700000000, lastId=123
// 返回时带上 nextCursor
public FeedPageResult getFeed(Long userId, String cursor, int limit) {
    long timestamp = 0L;
    long lastId = Long.MAX_VALUE;

    if (cursor != null) {
        String[] parts = cursor.split("_");
        timestamp = Long.parseLong(parts[0]);
        lastId = Long.parseLong(parts[1]);
    }

    List<Feed> feeds = feedMapper.selectWithCursor(
        userId, timestamp, lastId, limit + 1
    );

    boolean hasMore = feeds.size() > limit;
    if (hasMore) {
        feeds = feeds.subList(0, limit);
    }

    List<FeedVO> vos = feeds.stream().map(this::toVO).toList();

    String nextCursor = null;
    if (hasMore && !feeds.isEmpty()) {
        Feed last = feeds.get(feeds.size() - 1);
        nextCursor = last.getCreatedAt().getTime() + "_" + last.getId();
    }

    return new FeedPageResult(vos, nextCursor, hasMore);
}

七、高频面试题

Q1: 微博的大 V 发微博时,写扩散会不会把系统打挂?

会,所以有阈值截断:粉丝数 < 1 万实时写扩散;超过阈值改走读扩散,大 V 只写自己的收件箱,粉丝读时实时拉取大 V 的最新 N 条内容。

Q2: 如何保证 Feed 的分页不重复/不漏?

核心是用游标分页(timestamp + id),而非 offset。offset 翻页在插入新内容时会错位,导致重复或遗漏。游标基于数据主键,稳定性强。

Q3: Feed 流的热更新(刚发完立刻看到)和冷启动(首次加载)怎么处理?

热更新走 WebSocket 推送新内容到客户端,本地列表头部插入。冷启动走 Pull 模式拉取收件箱数据,结合 L1/L2 多级缓存加速。

Q4: 写扩散的收件箱容量满了怎么办?

定期 trim,只保留最近 500-1000 条。历史内容通过搜索/归档页访问。也可以按时间分桶,访问归档桶时再加载。

Q5: 如何实现个性化推荐排序(Rank 模式)?

在 Pull 链路中,读完基础内容后加一层 Rank 服务:用 ML 模型对内容打分(互动率、点击率、内容质量),按分数重排序后返回。Rank 服务可前置缓存热门内容的结果。


参考链接: