cswork / 系统设计

第 5 章 / 共 57 章 · 约 6 分钟

案例 3:信息流与时间线

本页目录
  1. 1. 需求澄清
  2. 2. 容量估算
  3. 3. API
  4. 4. 数据模型
  5. 5. 三种扇出模式
  6. 6. 高层架构
  7. 7. 深入讨论
  8. 8. 关键取舍
  9. 9. 常见追问
  10. 10. 回答分级
  11. 11. 不同级别的期望
  12. 12. 面试节奏建议
  13. 章节测验

先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。

先写你的思路

只存在这台设备上;登录后会存进你的账号。

Twitter 首页、朋友圈、Instagram 关注流本质上是同一道题:用户关注了一批人,打开首页时要很快看到这些人的最新内容。难点不在发帖本身,而在于“一个人发帖,成千上万人的首页都要跟着变”。

1. 需求澄清

功能需求

  • 发帖(文字、图片、视频)
  • 关注 / 取消关注
  • 查看首页时间线:先做关注对象帖子的时间倒序,个性化排序作为扩展
  • 支持下拉刷新和向下翻页

非功能需求

  • 首页加载 p99 < 300 ms
  • 高可用,读多写少
  • 最终一致:发帖后几秒内出现在粉丝首页即可
  • 存在粉丝数千万的“大 V”账号

2. 容量估算

指标估算
DAU2 亿
读时间线每人每天 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 问题,顺势给出混合方案,再把剩下的时间用在扇出管道、回填和各种边界情况上。能把删帖、取关、不活跃用户这些细节讲清楚,往往比画一张更大的架构图更能体现经验。

章节测验

6 道选择题
  1. 粉丝 5000 万的账号发帖,在混合模式下通常如何处理?
  2. 首页翻页为什么用游标而不是 offset?
  3. 时间线缓存只存 post_id 而不存完整内容,主要好处是?
  4. 每天 4000 万条帖子,平均 200 个粉丝,纯推模式下时间线写入约为?
  5. Redis 时间线缓存集群丢失了大部分数据。正确的认识是?
  6. 用户发帖后刷新首页却看不到自己的帖子,最简单合理的解决方式是?

章节讨论

正在加载讨论…