第 5 章 / 共 57 章 · 约 6 分钟
案例 3:信息流与时间线
本页目录
先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。
先写你的思路
只存在这台设备上;登录后会存进你的账号。
Twitter 首页、朋友圈、Instagram 关注流本质上是同一道题:用户关注了一批人,打开首页时要很快看到这些人的最新内容。难点不在发帖本身,而在于“一个人发帖,成千上万人的首页都要跟着变”。
1. 需求澄清
功能需求
- 发帖(文字、图片、视频)
- 关注 / 取消关注
- 查看首页时间线:先做关注对象帖子的时间倒序,个性化排序作为扩展
- 支持下拉刷新和向下翻页
非功能需求
- 首页加载 p99 < 300 ms
- 高可用,读多写少
- 最终一致:发帖后几秒内出现在粉丝首页即可
- 存在粉丝数千万的“大 V”账号
2. 容量估算
| 指标 | 估算 |
|---|---|
| DAU | 2 亿 |
| 读时间线 | 每人每天 10 次 → 20 亿次/天 ≈ 23k QPS,峰值约 70k |
| 发帖 | 4000 万条/天 ≈ 460 QPS,峰值约 1.5k |
| 平均粉丝数 | 200 |
| 推模式写入量 | 4000 万 × 200 = 80 亿次/天 ≈ 9.3 万次/秒 |
| 时间线缓存 | 每人 500 个帖子 ID × 约 16 B ≈ 8 KB,2 亿人约 1.6 TB |
这组数字揭示了本题的核心矛盾:发帖 QPS 很低,扇出(fan-out)之后的写入却放大了两百倍。
另外,1.6 TB 只是原始字节。Redis 有序集合在元素不超过 128 个(zset-max-listpack-entries 的默认值)时使用紧凑的 listpack 编码,超过后会转成 skiplist 加哈希表,每个元素额外多出约 50–100 B,实际内存是原始字节的 5–10 倍,全量缓存 2 亿人要 8–16 TB。常见做法是只给近期活跃用户建缓存,或者把每人的时间线截断到 128 条以内以保持 listpack 编码,更早的内容翻页时再走拉取。
3. API
POST /v1/posts { text, media_ids[] } → { post_id }
PUT /v1/users/{id}/follow
DELETE /v1/users/{id}/follow
GET /v1/feed?cursor=<opaque>&limit=20
→ { items: [...], next_cursor }
翻页用游标而不是 offset:时间线头部不断插入新帖,用 offset 会导致重复或漏读。游标通常编码“上一页最后一条的 post_id”,配合按时间有序的 ID(类似 Snowflake:时间戳 + 机器号 + 序列号)就能直接比较新旧。
4. 数据模型
posts (post_id PK, author_id, text, media_urls, created_at)
-- 按 post_id 分片;另建 author_id → 最近 post_id 的索引
follows (follower_id, followee_id, created_at)
-- 两个方向都要能查:
-- followers_of(followee) 用于扇出
-- following_of(follower) 用于拉取
timeline Redis Sorted Set:key = user_id,member = post_id,score = 时间
-- 只存 ID,截断到 500 条
图片和视频放对象存储 + CDN,数据库只存 URL。
5. 三种扇出模式
推模式(fan-out on write):发帖时把 post_id 写进每个粉丝的时间线缓存。
- 读:一次
ZREVRANGE拿到 ID,再批量回填内容,很快 - 写:写放大严重;大 V 发一条要写几千万次;给不活跃用户的写入全部浪费
拉模式(fan-out on read):读首页时实时查询每个关注对象的最新帖子,再多路归并。
- 写:几乎零成本
- 读:关注 200 人就是 200 次查询再归并,延迟高、读压力大
混合模式(实际答案)
- 普通用户发帖 → 推
- 粉丝数超过阈值(如 10 万)的账号发帖 → 不推;粉丝读首页时再拉取这些大 V 的最新帖子并合并
- 30 天未登录的用户不接收推送,回来时用拉模式重建时间线
6. 高层架构
查看图表源码
flowchart LR
accTitle: 信息流:发帖与读首页
c1["Client 发帖"] --> ps["Post Service"] --> pdb[("Posts DB")]
ps --> kafka["Kafka: post_created"] --> fan["Fan-out Workers"]
fan -->|"查询关注关系"| tl[("Timeline Cache(Redis)")]
c2["Client 读首页"] --> fs["Feed Service"]
fs -->|"推来的 ID"| tl
fs -->|"拉"| celeb["关注的大 V 最新帖子"]
fs --> merge["归并、过滤、(可选)排序"]
fs --> hydrate["批量回填:Post / User 缓存、计数服务"]
7. 深入讨论
7.1 扇出管道:Kafka 按作者分区,worker 批量读取粉丝列表,用 Redis pipeline 批量写入。粉丝数接近阈值的账号也可能有几万粉丝,任务要切片并行。ZADD 以 post_id 为成员,天然幂等,消息重放不会产生重复。要监控队列积压,大 V 集中发帖时注意削峰。
7.2 回填(hydration):时间线只存 ID,读取时批量从帖子缓存取内容、从用户缓存取作者信息、从计数服务取点赞数。这样帖子被编辑、头像被修改时,不需要更新几千万份副本。
7.3 删除与取关:删帖写墓碑标记,回填时过滤,后台异步清理;取关在读取时按当前关注列表过滤,再惰性清理缓存。新关注某人时,补几条对方最近的帖子,否则用户会觉得“关注了也没变化”。
7.4 缓存是派生数据:时间线缓存丢了,可以从 posts + follows 用拉模式重建,所以它不是事实来源(source of truth),可以接受较弱的持久化配置。但要防止大面积失效时的重建风暴,重建需要限速。
7.5 排序:倒序时间线是基线。个性化排序通常是“召回 + 排序”两阶段:先从时间线和推荐源拿几百个候选,再用模型打分取前几十条。面试中点到即可,除非面试官主动深挖。
8. 关键取舍
| 选择 | 换来什么 | 付出什么 |
|---|---|---|
| 推 | 读快 | 写放大、存储成本、大 V 问题 |
| 拉 | 写便宜、数据最新 | 读慢、读压力大 |
| 混合 | 兼顾两者 | 系统更复杂,要维护阈值和两条路径 |
| 只存 ID | 更新简单、省内存 | 读时多一次批量查询 |
9. 常见追问
- 大 V 阈值怎么定? 不是拍脑袋的固定值,而是比较两条成本曲线:推送一条帖子的成本与粉丝数成正比,拉取的成本与粉丝读首页的次数成正比。粉丝极多的账号适合拉;也可以只推给活跃粉丝,其余粉丝读时再拉。
- “有 12 条新帖”的提示怎么做? 客户端定期轮询一个轻量接口,只返回比当前游标更新的帖子数量;实时性要求高时,用 WebSocket 推送一个“有更新”的信号,再由客户端主动拉取内容,避免通过长连接直接推送大量数据。
- 自己刚发的帖子在首页看不到? 这是“读己之写”(read-your-writes)问题。发帖成功后直接把帖子插入作者自己的时间线,或由客户端先在本地展示,不必等异步扇出完成。
10. 回答分级
Bad:一条 SQL JOIN follows ... ORDER BY created_at 解决一切;不估算;没意识到大 V 问题;用 offset 分页。
Good:给出推模式并指出写放大;提出混合模式;时间线只存 ID;游标分页;异步扇出。
Great:用数字说明为什么要混合、阈值怎么定;处理不活跃用户、删帖、取关、新关注补帖;指出缓存是派生数据并设计重建方案;讨论扇出队列积压和高峰期降级。
11. 不同级别的期望
| 级别 | 期望 |
|---|---|
| Mid | 讲清推和拉的区别,给出能工作的读写路径 |
| Senior | 主导混合方案设计,量化写放大,覆盖分页、回填、删除等细节 |
| Staff | 从产品目标出发权衡新鲜度、成本与排序质量,讨论多区域、容量规划、降级策略(如高峰期暂停向低活跃用户扇出)以及团队和服务边界 |
12. 面试节奏建议
这道题最容易在“推还是拉”上来回讨论太久。建议先用估算数字直接引出写放大和大 V 问题,顺势给出混合方案,再把剩下的时间用在扇出管道、回填和各种边界情况上。能把删帖、取关、不活跃用户这些细节讲清楚,往往比画一张更大的架构图更能体现经验。