Skip to content

设计 Twitter(LeetCode 355)— 最小可面试的 Feed 流系统

哈希 ⭐⭐⭐ 进阶 🔥 高频

💡 核心要点

关注关系(HashMap<userId, Set<followeeId>>)+ 用户推文列表(HashMap<userId, List<Tweet>>)+ 拉取时小顶堆/大顶堆多路归并。这是面试官把"系统设计 Twitter Feed"压缩到 30 分钟手撕代码的版本,会写它,讲推 / 拉 / 推拉结合三种模型就能聊半小时。

为什么单独讲它

这是少数数据结构题 + 系统设计题混合的题:核心数据结构能写出来即可拿分;但面试官几乎一定会追问"如果 follower 几亿怎么办" → 这时你要能说出推模型 / 拉模型 / 推拉结合的权衡。

系统设计视角的两大模型

模型写时读时代价
拉模型(Pull)推文写入自己的 timeline读取时实时聚合所有关注者的 timeline写轻读重;适合关注少 / 大 V 系统
推模型(Push / Fan-out)写入时立即推送到所有 follower 的收件箱直接读自己收件箱写重读轻;适合普通用户 / 写少读多
推拉结合(Hybrid)普通用户用推、大 V(>10w follower)用拉读时收件箱 + 大 V 实时聚合生产标准,Twitter / 微博都是

LC 355 的代码就是"拉模型"的最小实现——发推文存自己列表,拉 timeline 时实时合并关注列表的推文,用堆按时间排序取前 10。

数据结构设计

followees:  Map<Integer, Set<Integer>>          // 谁关注了谁
                userId → set of followee ids

tweets:     Map<Integer, List<Tweet>>           // 每个用户的推文(按时间正序)
                userId → list of Tweet(id, timestamp)

timestamp:  全局自增 long                       // ★ 时间戳必须全局,否则跨用户排序失效

为什么时间戳要全局:如果每个用户自己维护时间戳,不同用户的推文之间无法比较先后。最简方案:一个静态 AtomicLong,每条推文 getAndIncrement

完整代码(必背手撕)

java
class Twitter {
    private static class Tweet {
        int id;
        long time;
        Tweet(int i, long t) { id = i; time = t; }
    }

    private static long globalTime = 0;                  // ★ 全局时间戳
    private final Map<Integer, List<Tweet>> tweets = new HashMap<>();
    private final Map<Integer, Set<Integer>> followees = new HashMap<>();

    public void postTweet(int userId, int tweetId) {
        tweets.computeIfAbsent(userId, k -> new ArrayList<>())
              .add(new Tweet(tweetId, globalTime++));
    }

    public List<Integer> getNewsFeed(int userId) {
        // ★ 关注列表 + 自己
        Set<Integer> users = new HashSet<>(followees.getOrDefault(userId, Set.of()));
        users.add(userId);

        // ★ 小顶堆做多路归并的相反:用大顶堆按时间倒序,取前 10
        PriorityQueue<Tweet> pq = new PriorityQueue<>((a, b) -> Long.compare(b.time, a.time));

        for (int u : users) {
            List<Tweet> list = tweets.get(u);
            if (list == null) continue;
            // ★ 优化:每个用户只看最后 10 条,其余不可能进前 10
            int start = Math.max(0, list.size() - 10);
            for (int i = start; i < list.size(); i++) {
                pq.offer(list.get(i));
            }
        }

        List<Integer> result = new ArrayList<>();
        for (int i = 0; i < 10 && !pq.isEmpty(); i++) {
            result.add(pq.poll().id);
        }
        return result;
    }

    public void follow(int followerId, int followeeId) {
        if (followerId == followeeId) return;            // ★ 不能关注自己
        followees.computeIfAbsent(followerId, k -> new HashSet<>()).add(followeeId);
    }

    public void unfollow(int followerId, int followeeId) {
        Set<Integer> set = followees.get(followerId);
        if (set != null) set.remove(followeeId);
    }
}

优化:多路归并代替全堆

上面"每个用户取最后 10 条全部进堆"在关注数极多时仍然慢。真正的多路归并

java
// 每个用户当成一条按时间倒序的链表,K 路归并取前 10
public List<Integer> getNewsFeed(int userId) {
    Set<Integer> users = new HashSet<>(followees.getOrDefault(userId, Set.of()));
    users.add(userId);

    // 堆里存 (Tweet, 用户列表, 当前下标)
    PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> Long.compare(
            tweets.get(b[1]).get(b[2]).time,
            tweets.get(a[1]).get(a[2]).time));

    for (int u : users) {
        List<Tweet> list = tweets.get(u);
        if (list != null && !list.isEmpty()) {
            pq.offer(new int[]{0, u, list.size() - 1});  // 从最新开始
        }
    }

    List<Integer> result = new ArrayList<>();
    while (!pq.isEmpty() && result.size() < 10) {
        int[] curr = pq.poll();
        int u = curr[1], idx = curr[2];
        result.add(tweets.get(u).get(idx).id);
        if (idx > 0) {                                  // 推下一条(更早的)
            pq.offer(new int[]{0, u, idx - 1});
        }
    }
    return result;
}

复杂度从 O(N × 10) 降到 O(K × log K + 10 × log K),K = 关注数。这是合并 K 个有序链表(LC23)的同构题

关键细节与陷阱

陷阱后果正确做法
时间戳不全局跨用户排序失效静态 AtomicLongvolatile long
follow 不检查 followerId == followeeId自己关注自己出现两次直接 return
getNewsFeed 漏掉自己的推文看不到自己刚发的把自己 add 进 users 集合
把所有推文都进堆用户发了 1 万条,堆爆炸每个用户只看最后 10 条(足以覆盖前 10)
LinkedList<Tweet>list.get(i) 是 O(n)ArrayList

复杂度

  • postTweet:O(1) 均摊
  • getNewsFeed(多路归并版):O(K log K + 10 log K),K = 关注数
  • follow / unfollow:O(1)
  • 空间:O(总推文数 + 总关注边数)

业界 Feed 流的三大模型

1. 拉模型(Pull / Fan-out on Read)

  • 写时:推文只入自己的 outbox
  • 读时:当前用户拉取所有关注者的 outbox,实时排序合并
  • 代价写很轻、读很重。100 关注 → 读时要拉 100 个用户的最新数据
  • 何时用:关注数少(IM / 小社交)、写多读少、用户重度大 V

2. 推模型(Push / Fan-out on Write)

  • 写时:把推文复制写入所有 follower 的 inbox
  • 读时:直接读自己的 inbox
  • 代价写很重、读很轻。一条推文有 10w follower → 写 10w 次
  • 何时用:写少读多、follower 普遍少(< 1w)的普通用户

3. 推拉结合(Hybrid,Twitter / 微博生产方案)

普通用户(< 1w follower)→ 推模型,写入 follower inbox
大 V(> 1w follower)  → 拉模型,follower 在读时实时聚合
读 Feed 时:
  本地 inbox(普通用户推过来)
  + 实时拉取关注的大 V outbox
  → 合并排序

这是面试时被追问"几亿用户怎么办"的标准答案。Twitter 早期纯推、被特朗普这种几千万 follower 的账号打爆后改成 Hybrid。

面试常问 & 怎么答

推模型怎么处理"大 V 写一条要扇出 10w 次"

异步写:写自己 outbox 后立即返回,扇出走 MQ(Kafka)异步消费。读 inbox 时如果还没扇出完,就退化到"实时聚合"兜底。

怎么避免"取消关注后历史推文还出现在 Feed"

推模型不删历史(删起来太贵),Feed 端按"当前是否还关注"二次过滤。这是为什么"取消关注后还能看到旧推文"是产品共识。

Feed 流怎么分页

按 timestamp 游标分页(cursor-based),不要用 offset:

  • offset 在数据变化时(新推文)位置不稳,容易跳过 / 重复
  • cursor (lastTimestamp) 稳定,是流式 Feed 的标准做法

时间线推送的延迟容忍

Feed 不是强一致:用户能接受"刚发的推文 5 秒后才出现在所有 follower 时间线"。这给了"异步扇出 + 最终一致"巨大的工程空间。

看到什么就先想到这类

  • "设计 Twitter / 微博 / 朋友圈 Feed" → 推/拉/推拉结合
  • "合并多个有序列表取 Top K" → 小顶堆 K 路归并(LC23 同构)
  • "时间线分页" → cursor-based timestamp
  • "大 V 扇出爆炸" → 推拉结合 + 异步 MQ 扇出
  • "feed 排序按时间" → 全局时间戳 + 多路归并堆