经典案例:社交网络 Feed 流与时间线系统 (Feed System)¶
Feed 流(News Feed / Timeline)是 Twitter、微博、微信朋友圈、Instagram 与抖音等现代社交网络产品的绝对核心。用户通过下拉刷新获取关注者发布的按时间倒序排列的图文与视频内容。
设计一个支持数亿活跃用户、能够抵御“明星大 V 粉丝千万级写放大”的高并发 Feed 流系统,是系统设计面试中最具代表性的高阶经典题目。
1. 业务场景与核心挑战¶
graph TD
subgraph "发布动态 (Publish Post)"
U1["用户 A (普通用户)"] --> P1["发动态"]
U2["用户 B (超级大 V, 5000 万粉丝)"] --> P2["发动态"]
end
subgraph "聚合时间线 (Read News Feed)"
Reader["用户 C (关注了 A 和 B)"] --> ReadFeed["刷新 Feed 流: 按时间倒序聚合 A 和 B 的最新动态"]
end
1.1 核心需求梳理¶
- 发布动态(Post Status):用户可以发布包含文本、图片的动态;
- 查看时间线(News Feed):按时间倒序分页浏览自己所关注好友的最新动态;
- 读多写少且高度不均衡:读取频次远大于发帖频次(读写比通常在 \(50:1 \sim 100:1\));
- 粉丝数量的极端倾斜:普通用户可能只有数十个粉丝,而超级明星(如顶流明星)拥有数千万甚至上亿粉丝。
2. 核心架构模型:推模式 vs 拉模式 vs 混合模式¶
Feed 流的核心本质在于动态的扩散(Fan-out)。
2.1 推模式 (Fan-out on Write / Push Model)¶
“写扩散”模式:用户发布动态时,系统主动将动态 ID 推送到所有粉丝的专属收件箱中。
- 读取流程:极度轻量快速!用户只需要从自己的收件箱(Redis
ZSet)按分数(时间戳)倒序直接拉取前 20 条,读取耗时 \(< 5\text{ ms}\); - 致命缺陷:写放大(Write Amplification)! 若一个拥有 5000 万粉丝的大 V 发帖,系统必须在瞬间向 5000 万个不同的收件箱各写入一条记录。这会引发巨大延迟,产生网络与存储风暴,甚至导致消息队列长时间积压;
- 存储浪费:大量长时间不上线的“僵尸用户”,系统仍为其收件箱同步数据,白白浪费内存。
2.2 拉模式 (Fan-out on Read / Pull Model)¶
“读扩散”模式:用户发帖时只写入自己的个人发件箱(Outbox),不做任何额外推送。
用户刷新 Feed 流
|
v
查询关注列表: 关注了 [A, B, C]
/ | \
v v v
[ A 发件箱 ] [ B 发件箱 ] [ C 发件箱 ]
\ | /
v v v
[ 内存多路归并排序并分页 ]
|
v
返回最新 20 条
- 写入流程:极快且绝对稳定(\(\mathcal{O}(1)\) 单次落库);
- 致命缺陷:读延迟高、计算繁重! 若用户关注了 500 个人,每次刷新 Feed 必须向 500 个不同用户的发件箱并发查询最新动态,并在内存中进行 500 路归并排序。高并发读取下,服务器 CPU 与数据库连接池极易被耗尽。
2.3 工业级标准方案:混合推拉模式 (Hybrid Model)¶
综合两种模式的优缺点,现代大型互联网平台(如微博、Twitter)普遍采用分级混合推拉架构:
flowchart TD
Post["用户发布新动态"] --> CheckUser{"是否为超级大 V? (如粉丝数 > 10 万)"}
CheckUser -- "否 (普通用户, 粉丝少)" --> Push["推模式 (Fan-out on Write)<br>异步推送到活跃粉丝的收件箱"]
CheckUser -- "是 (超级大 V, 粉丝多)" --> Pull["拉模式 (Fan-out on Read)<br>仅写入大 V 个人发件箱, 不做广播"]
Feed["用户刷新 Feed 流"] --> Step1["1. 从自身收件箱读出普通好友动态 (预排序)"]
Feed --> Step2["2. 获取所关注的大 V 列表,拉取各发件箱最新动态"]
Step1 --> Merge["3. 内存聚合归并 (K 极小, 毫秒级)"]
Step2 --> Merge
Merge --> Output["返回最终排序分页动态"]
活跃度感知优化(Inactive User Optimization):¶
对于普通用户的推模式,不向长期不登录的非活跃用户(如超过 7 天未打开 App)推送。当非活跃用户重新登录时,系统再按需触发一次按时间范围的拉取补齐。
3. 存储设计与多级缓存体系¶
3.1 为什么 Redis Sorted Set (ZSet) 是 Feed 流的天作之合?¶
用户的收件箱和发件箱通常存放在 Redis ZSet 中:
- Key:timeline:user:{user_id}
- Score:动态发布的时间戳毫秒值(created_at)
- Member:动态唯一 ID(post_id)
ZSet (timeline:user:10086)
---------------------------------------------------------
Score (时间戳): 1672531200000 1672531205000 1672531210000
Member (PostID): "post_991" "post_992" "post_993"
---------------------------------------------------------
- 读取优势:使用
ZREVRANGEBYSCORE可以在对数时间 \(\mathcal{O}(\log N + M)\) 内极速获取最新的一页动态 ID; - 容量控制:通过
ZREMRANGEBYRANK严格将收件箱长度截断在最新 800 条内(绝大多数用户不会翻看数百条以前的历史 Feed),冷数据自动落盘数据库。
4. 分页机制设计:游标分页 (Cursor) vs 偏移量分页 (Offset)¶
Feed 流是实时动态插入的流水线,分页机制绝不能简单使用传统的 SQL LIMIT offset, count。
传统 Offset 分页在 Feed 流中的致命缺陷:
T1 时刻: 用户在看第 1 页 (第 1-10 条)
-----------------------------------------------
T2 时刻: 有人发布了 2 条新动态插入顶部!
-----------------------------------------------
T3 时刻: 用户请求第 2 页 (LIMIT 10 OFFSET 10)
原先第 9、第 10 条动态被顺移到了第 11、第 12 位置!
=> 用户在第 2 页再次看到了第 9、第 10 条 (数据重复)!
4.1 游标分页(Cursor-based Pagination)¶
客户端每次请求携带上一页最后一条记录的游标(通常为 last_post_id 与时间戳):
服务端底层查询:
SELECT post_id, user_id, content, created_at
FROM posts
WHERE user_id IN (...) AND created_at < :cursor_timestamp
ORDER BY created_at DESC
LIMIT 20;
- 彻底杜绝重复与遗漏:即使顶部插入了无数条新动态,游标指针始终严格锁定在之前浏览位置的时间戳之后;
- 极致的数据库性能:充分利用
(user_id, created_at)联合索引的 B+ 树定位,永远不会产生深分页跳过无效行的性能浪费。
5. 整体系统架构蓝图¶
flowchart TD
Client["移动端 / 网页端 App"] --> Gateway["API 网关 / 负载均衡"]
Gateway --> FeedService["Feed 聚合服务集群 (无状态)"]
Gateway --> PostService["发帖服务 (Post Service)"]
PostService --> Kafka["消息队列 (Kafka Topic: post_created)"]
PostService --> PostDB["Posts 关系数据库 (分库分表)"]
Kafka --> FanoutWorker["异步扇出集群 (Fan-out Workers)"]
FanoutWorker --> SocialGraph["社交关系缓存 (Redis Sets: 关注与粉丝)"]
FanoutWorker --> TimelineCache["Timeline 缓存集群 (Redis ZSets)"]
FeedService --> TimelineCache
FeedService --> PostCache["动态详情内容缓存 (Redis Strings / Memcached)"]
FeedService -- 缓存未命中 --> PostDB