面试题:设计一个 Feed 流系统:关注关系怎么存、推拉模式怎么选、百万粉丝的大 V 怎么扛?
这是「每日一题」专栏的第十二篇。每天一道面试题,后端 + 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 侧的配套设计:
- 大 V 最新微博进独立缓存:
recent_tweets:{大V}用 ZSet 存最近几百条,粉丝拉取时只读这个 key,不碰 MySQL。 - 粉丝读时间线 = 多路归并:自己的 Push 时间线(ZSet)+ 每个关注的大 V 的 recent_tweets,按时间戳合并取前 N 条。普通用户关注的大 V 一般不超过几十个,归并成本可控。
- 关注时判断: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 分页重复或漏数据。
两个设计细节:
- 推文本体和索引分离:ZSet 里只存 tweet_id,正文/图片放另一个存储(MySQL 分表或 Cassandra),按 id 批量查。时间线只负责「排序」,内容走缓存。这样时间线 key 很小,一个 ZSet 存 1000 条也就几 KB。
- 取关 / 拉黑的懒处理:取关后不必立刻从粉丝的 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 的时间部分也行。
七、面试回答模板
按这个顺序答,逻辑闭环:
- 先澄清:确认读写比(100:1)、延迟要求(P99 低于 200 毫秒)、大 V 规模——「读多写少 + 热点集中」决定了后面所有选择。
- 再存关系:关注/粉丝双向存储,MySQL 分库保事务 + Redis 缓存热点查询。
- 推拉选型:默认 Push(放大放写侧,总成本最低);说清 Pull 的适用场景(早期/小体量、关注少)。
- 大 V 特判:粉丝数阈值转 Pull,多路归并读时间线;只推活跃粉丝、分桶异步 fanout 是加分项。
- 收尾金句:Feed 流设计的本质是读写放大的守恒——Push 用写放大换读延迟,Pull 用读放大换写成本,混合模式不是「又推又拉」,而是把放大精确地集中到少数大 V 身上。
明日预告:AI/LLM 方向 ——「RAG 检索增强生成:文档怎么切块、向量索引怎么建、混合检索怎么排、幻觉怎么治?」