跳到主要内容

面试题:设计一个 Feed 流系统:关注关系怎么存、推拉模式怎么选、百万粉丝的大 V 怎么扛?

· 阅读需 10 分钟

这是「每日一题」专栏的第十二篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇讲了 Cache-Aside 缓存一致性,今天按轮换来到系统设计方向,聊上一篇预告的题:Feed 流系统设计。

题目​

系统设计面试的常青树,和秒杀系统并列的高频题:

设计一个类微博/推特的 Feed 流系统:关注关系怎么存?推拉模式怎么选?百万甚至千万粉丝的大 V 发一条微博,怎么保证粉丝时间线秒级可见、又不把系统打垮?

这道题考察四件事:会不会先澄清需求再动手、懂不懂读写放大的本质、能不能说清推拉模式各自的代价、知不知道大 V 场景的工程解法。很多人上来就背「推拉结合」,但被追问「为什么分界点是粉丝数而不是别的」就卡壳。

先给结论,再拆开讲:

  • 关注关系:Redis Set 或 MySQL 表,关注/粉丝两个方向都要存,按 uid 分片。
  • 核心权衡:Push(写扩散)用写放大换读延迟,Pull(读扩散)用读放大换写成本;Feed 场景读多写少(读写比约 100:1),所以默认 Push。
  • 大 V:粉丝数超过阈值(约 100 万)自动转 Pull,读时间线时把「Push 时间线 + 大 V 最新推文」多路归并。
  • 时间线:Redis ZSet 按时间戳排序,游标分页,Cache-Aside 缓存。

一、先澄清需求,别急着画架构​

面试官给完题,第一件事是反问,这本身就占分:

功能需求:

  • 发微博(写自己的发件箱)
  • 关注 / 取关
  • 拉取首页时间线(我关注的所有人的最新内容,按时间倒序)
  • 个人主页(只看我自己的微博)

非功能需求:

  • 读写比:Feed 是典型的读多写少,读:写约 100:1 甚至更高——这是后面选型最关键的数字。
  • 延迟:首页时间线 P99 要低于 200 毫秒,刷不出来用户就跑了。
  • 数据规模:用户亿级、日活千万级、大 V 粉丝千万级。
  • 峰值:热点事件(明星塌房、体育决赛)流量是平时的几十倍,且集中在少数大 V 身上。

把需求钉死之后,答案就有了骨架:读多写少 → 把复杂度往写侧放;热点集中在少数人 → 对少数人做特判。

二、关注关系怎么存​

两种主流选择,面试都说得过去,关键是说出取舍:

方案 A:MySQL 关系表​

CREATE TABLE follow (
follower_id BIGINT NOT NULL, -- 谁关注
followee_id BIGINT NOT NULL, -- 关注了谁
create_time DATETIME NOT NULL,
PRIMARY KEY (follower_id, followee_id),
KEY idx_followee (followee_id, follower_id) -- 粉丝列表查询
);
  • 优点:强一致、支持事务(取关即生效)、好扩展(按 follower_id 分库分表)。
  • 缺点:热点行——千万粉丝的大 V 在 followee_id 上对应千万行,按 followee_id 查粉丝列表是热点读。

方案 B:Redis Set​

following:{uid} → Set of followee_id 我关注了谁
followers:{uid} → Set of follower_id 谁关注了我
  • 关注:SADD following:{A} B + SADD followers:{B} A
  • 判断互关:SINTER following:{A} following:{B}
  • 优点:毫秒级读写,天然适合「判断是否关注」这种高频小查询;缺点:Redis 内存贵,全量粉丝关系放 Redis 成本高,持久化弱。

面试结论:关系元数据(全量、要事务)放 MySQL 分库分表,热点查询(是否关注、粉丝数)用 Redis 缓存。两个方向都要存——「我关注了谁」和「谁关注了我」是两组 key/两张表,只存一个方向是常见错误。

三、核心:推拉模式,本质是读写放大的权衡​

这是全题的题眼。先给定义:

Pull 读扩散:发微博只写自己的发件箱;粉丝刷时间线时,现场去拉所有关注对象的最新微博,合并排序。

发微博: write own_timeline:{me} ← 写 1 次
刷时间线: for each followee → 拉取 → 归并排序 ← 读 N 次(N = 关注数)

Push 写扩散(fanout):发微博时,把微博写进每个粉丝的时间线;粉丝刷时间线直接读自己的,O(1)。

发微博: for each follower → write timeline:{follower} ← 写 N 次(N = 粉丝数)
刷时间线: read timeline:{me} ← 读 1 次

对比表:

维度Push 写扩散Pull 读扩散
写路径放大 N 倍(N = 粉丝数)只写 1 次
读路径O(1),延迟最低放大 N 倍(N = 关注数)+ 归并排序
大 V 发微博灾难:千万级写放大无压力
僵尸粉 / 取关浪费存储、清理麻烦天然免疫
适合场景读多写少、粉丝少的普通用户粉丝多、关注少的用户

为什么默认 Push? 因为 Feed 读多写少。放大是守恒的——总成本 = 写次数 × 写放大 + 读次数 × 读放大。读写比 100:1 时,把放大放在写侧(Push),总成本最低;反过来放在读侧(Pull),热点读会直接打爆数据库。

一个容易被追问的点:Pull 的「关注 1000 人 = 1000 次查询」听起来很贵,但可以优化——只拉最近活跃的几十个关注对象(Redis 里每人存最近 N 条),归并 30 路和归并 1000 路成本差一个量级。这也是很多小体量产品(早期 Twitter、RSS 阅读器)能用 Pull 的原因:规模没到,Pull 的简单性更值钱。

四、大 V 问题:混合模式​

纯 Push 的死穴是大 V。明星发一条微博,1000 万粉丝 = 1000 万次写,直接把消息队列和 Redis 打垮;而且其中相当一部分是几个月不登录的僵尸粉,写了也白写。

工程标准答案是混合模式(Push + Pull),分界点用粉丝数:

粉丝数 低于阈值(如 100 万)→ Push:发微博时 fanout 到所有粉丝的时间线
粉丝数 超过阈值 → Pull:不 fanout,粉丝刷时间线时现场拉取

大 V 侧的配套设计:

  1. 大 V 最新微博进独立缓存:recent_tweets:{大V} 用 ZSet 存最近几百条,粉丝拉取时只读这个 key,不碰 MySQL。
  2. 粉丝读时间线 = 多路归并:自己的 Push 时间线(ZSet)+ 每个关注的大 V 的 recent_tweets,按时间戳合并取前 N 条。普通用户关注的大 V 一般不超过几十个,归并成本可控。
  3. 关注时判断:follow 操作时查对方粉丝数,决定自己进对方的 Push 名单还是 Pull 名单;取关同理。判断用 Redis 计数,毫秒级。

进阶方案(能说出来就是加分项):

  • 只推活跃粉丝:fanout 前过滤 90 天未登录用户,写放大直接砍掉一个数量级。
  • 分桶异步 fanout:大 V 的粉丝分片(比如 100 万一桶),发微博时逐桶异步推、削峰;用消息队列解耦,桶之间互不阻塞。
  • 多级缓存大 V 时间线:大 V 主页时间线本身是热点读,套一层 Cache-Aside(呼应上一篇),秒级可见。

五、时间线的存储与读取​

Push 模式下的时间线本体,业界标准是 Redis ZSet:

key: timeline:{uid}
member: tweet_id
score: 发微博的时间戳(毫秒)
  • 发微博:ZADD timeline:{每个粉丝} ts tweet_id(异步批量)
  • 刷时间线:ZREVRANGE timeline:{me} 0 99 取最近 100 条
  • 分页必须用游标(ZREVRANGEBYSCORE 带上一次的最小 score),不能用 offset——深翻页在 ZSet 里是 O(N) 扫描,且新微博插入会让 offset 分页重复或漏数据。

两个设计细节:

  1. 推文本体和索引分离:ZSet 里只存 tweet_id,正文/图片放另一个存储(MySQL 分表或 Cassandra),按 id 批量查。时间线只负责「排序」,内容走缓存。这样时间线 key 很小,一个 ZSet 存 1000 条也就几 KB。
  2. 取关 / 拉黑的懒处理:取关后不必立刻从粉丝的 ZSet 里删(ZREM 很贵),靠「读时间线时过滤已取关用户」+ 定时清理兜底;拉黑同理。先保证读的正确性,清理异步做。

未读数是个加分扩展:每个用户记一个 last_read_cursor,未读数 = 时间线当前最大 score 与游标之间新增的条数,Redis 计数即可,不需要精确。

六、整体架构串一遍​

发微博:
微博服务 → 写推文存储(MySQL / Cassandra)→ 发 MQ
消费者:粉丝数低于阈值 → ZADD 到每个粉丝的 timeline:{uid}
粉丝数超过阈值 → ZADD 到 recent_tweets:{大V}

刷时间线:
读 Redis:timeline:{me}(Push 部分)
+ 我关注的大 V 的 recent_tweets:{大V}
→ 多路归并按时间倒序 → 取前 100 条 → 按 tweet_id 批量查正文(缓存)

组件清单:微博服务(写)、MQ(fanout 削峰)、Redis(时间线 ZSet + 大 V 缓存 + 关注关系缓存)、推文存储(MySQL 分表 / Cassandra)、关注关系(MySQL 分库 + Redis 缓存)。推文 ID 用雪花算法保证时间有序(见 分布式 ID 生成器),ZSet 的 score 直接用 ID 的时间部分也行。

七、面试回答模板​

按这个顺序答,逻辑闭环:

  1. 先澄清:确认读写比(100:1)、延迟要求(P99 低于 200 毫秒)、大 V 规模——「读多写少 + 热点集中」决定了后面所有选择。
  2. 再存关系:关注/粉丝双向存储,MySQL 分库保事务 + Redis 缓存热点查询。
  3. 推拉选型:默认 Push(放大放写侧,总成本最低);说清 Pull 的适用场景(早期/小体量、关注少)。
  4. 大 V 特判:粉丝数阈值转 Pull,多路归并读时间线;只推活跃粉丝、分桶异步 fanout 是加分项。
  5. 收尾金句:Feed 流设计的本质是读写放大的守恒——Push 用写放大换读延迟,Pull 用读放大换写成本,混合模式不是「又推又拉」,而是把放大精确地集中到少数大 V 身上。

明日预告:AI/LLM 方向 ——「RAG 检索增强生成:文档怎么切块、向量索引怎么建、混合检索怎么排、幻觉怎么治?」