Skip to main content

演练:设计信息流系统

从候选人视角完整演练信息流系统设计题——从 push vs pull 扇出决策,到大V问题、排序和故障模式。

题目背景

面试官让你设计一个信息流(News Feed)系统——类似 Facebook 首页时间线、Twitter(X)的 Home Timeline,或 Instagram 的关注动态。面试官大概会这样说:“设计一个服务,向用户展示他们所关注的人发布的帖子,按时间或相关性排序。支持发帖和查看信息流。”

听起来像一个列表。但它不是。这是经典的”大规模扇出”问题,核心陷阱是大V问题(celebrity problem):如果一个用户有 1 亿粉丝,一个简单的设计要么在每次发帖时写入 1 亿个收件箱(写入风暴),要么在每次阅读时扫描 1 亿个作者(读取风暴)。选择 push、pull 还是混合策略——并为这个系统合理论证——才是这道题真正考察的。

Tip

中文社交平台的类比: 这种”关注者信息流”在海外社交产品中非常普遍(Facebook、Twitter、Instagram)。在中文生态中,最接近的是微博的”关注”时间线、小红书的”关注”tab、以及抖音的”关注”页面。微信朋友圈虽然也是信息流,但它是双向好友关系(不是单向关注),且规模较小(好友上限 5000),所以设计约束完全不同。这道面试题的核心假设是单向关注、幂律分布的粉丝数——这些特征在微博和 Twitter 上表现最为明显。

Important

核心要点: 信息流系统在写和读之间本质上是不对称的。粉丝数分布服从幂律——极少数用户产生了绝大多数扇出成本。整个架构设计都源于你如何处理这种不对称性。

下面是我从头到尾的演练流程。

1. 需求分析

开始 3–5 分钟。我会问这些问题来明确要构建什么:

功能性需求

  • R1. 用户创建帖子(文字、图片、视频)。
  • R2. 用户关注其他用户。
  • R3. 每个用户看到一个由他关注的人发布的帖子组成的信息流。
  • R4. 信息流有排序——按时间或按相关性。
  • R5. 用户可以点赞、评论和转发帖子。

范围界定

本设计只覆盖信息流获取。明确不在范围内的:

  • 搜索/发现(独立系统,不同的访问模式)
  • Stories/短视频(有自己 TTL 管道的临时内容)
  • 通知(共享扇出基础设施的并行系统)
  • 内容审核/反垃圾(真实系统需要,但不是这里的设计驱动力)

非功能性需求

  • 规模。 多少用户?每用户每天多少帖子?平均关注数?最大关注数(大V)?System Design Primer 的数字是合理参考,但对信息流来说分布比平均值更重要。
  • 读写比。 时间线是经典的读多写少系统——通常 100:1 或更高。这决定了我们优化读路径还是写路径。
  • 信息流新鲜度。 信息流可以有多陈旧?秒级?分钟级?按时间排序的信息流对新鲜度要求更严格。
  • 可用性优先于一致性。 用户可以容忍看到稍微过时的信息流,但信息流挂了是不可接受的。AP 优先于 CP。
  • 排序方式。 严格按时间?相关性排序(ML)?混合?高级面试的默认选择:相关性排序,因为这是真实产品的做法,且迫使设计更丰富。

假设面试官确认:5 亿 DAU,平均每用户每天发 2 条帖子(每天 10 亿帖子),平均关注数约 300,大V最多约 1 亿粉丝,相关性排序,新鲜度容忍度约 30 秒,支持文字+图片,支持转发。

Note

面试信号: 询问粉丝数的分布——而不仅仅是平均值——是区分强需求分析和泛泛而谈的关键。粉丝数分布是决定 push、pull 还是混合策略的唯一输入。面试官会注意到你优先问这个。

2. 容量估算

  • 帖子:10 亿/天 ≈ 平均 12K 写/秒,峰值约 50K/秒
  • 信息流读取:每个 DAU 每天加载信息流约 10 次 ≈ 50 亿读/天 ≈ 平均 60K 读/秒,峰值约 300K/秒。读写比已经是约 5:1,这还没算”刷新”和滑动翻页。
  • 存储:10 亿帖子/天 × 365 天 × 约 500 字节(文字+元数据,不含媒体)≈ 180 TB/年(仅帖子内容)。媒体单独存对象存储。
  • 扇出:如果用纯 push,10 亿帖子 × 300 平均粉丝 = 3000 亿次收件箱写入/天。即约 350 万写/秒。这个数字决定了架构选择。

我会大声说出来:“纯 push 情况下 350 万写/秒是最大的设计约束。整个扇出问题的本质是:我们能否避免为所有人支付这个成本。“

3. 高层架构

在确定任何扇出策略之前,先画出组件拓扑。信息流系统有三个逻辑子系统:

  1. 帖子摄入 — 接收新帖子,权威存储,发出事件。
  2. 信息流生成 — 接收原始帖子事件,为每个用户生成个性化、有序的候选帖子列表。扇出发生在这里。
  3. 信息流服务 — 通过 API 向客户端提供生成好的信息流,处理分页和数据填充。
flowchart TD
  Client[客户端] --> API[API 网关]

  API --> PostSvc[帖子服务]
  API --> FeedSvc[信息流服务]

  PostSvc --> PostsDB[(帖子 DB)]
  PostSvc -->|发出事件| Queue[(消息队列)]
  Queue --> FeedGen[信息流生成]
  FeedGen --> FeedStore[(信息流存储)]

  FeedSvc --> FeedStore
  FeedSvc --> PostSvc

信息流生成是决定系统特性的部分。

4. 架构演进

一个信息流系统在生产环境中会经历可辨识的演进阶段。提前命名这些阶段有助于解释设计为什么是这个样子——以及下一步是什么:

阶段架构触发下一阶段的原因
1. 按时间 pull读取时获取所有关注者的帖子,按时间排序读延迟随关注数增长
2. Push 模型写入时扇出到每用户的收件箱大V导致写入风暴
3. 混合 push/pull普通用户 push,大V pull按时间排序不再驱动用户参与
4. 排序信息流在收集和组装之间加入打分层候选池超过实时排序预算
5. 多阶段排序召回 → 打分 → 组装流水线(生产系统;超出面试范围)

每个阶段保留前一阶段作为降级回退——阶段 4 在排序器故障时降级到阶段 3;阶段 3 对不关注大V的用户降级到阶段 2。

本演练的设计是阶段 4:混合扇出 + 排序层。在面试中,落在阶段 3–4 并展示对阶段 5 的认知,是 Staff+ 级别的强信号。

我会大声说出来:“我的设计目标是阶段 4——混合+排序。但系统应该在故障时优雅降级到阶段 3,存储架构不应该在我们后续演进到阶段 5 时需要修改。”

Note

面试信号: 命名架构演进阶段——并定位你的设计在其中的位置——展示了你理解系统是一个活的有机体,而非静态蓝图。这也为你提供了一个框架来回答”下一步你会做什么?“的追问,而不需要即兴发挥。

目标架构(阶段 4)

这是我们要设计的完整系统。后续每个小节解释这个图的一个部分:

flowchart TD
  Client[客户端] --> API[API 网关]

  API --> PostSvc[帖子服务]
  API --> FeedSvc[信息流服务]

  PostSvc --> PostsDB[(帖子)]
  PostSvc -->|事件| Kafka[(Kafka)]
  Kafka --> Fanout[扇出 Workers]
  Fanout --> FollowsDB[(粉丝)]
  Fanout --> InboxDB[(收件箱)]

  FeedSvc --> InboxCache[(收件箱缓存)]
  InboxCache -->|miss| InboxDB
  FeedSvc --> Outbox[(发件箱)]
  FeedSvc --> Ranker[排序器]
  FeedSvc --> PostCache[(帖子缓存)]
  PostCache -->|miss| PostsDB

  classDef store fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
  class PostsDB,Kafka,FollowsDB,InboxDB,InboxCache,Outbox,PostCache store

5. 核心设计选择:扇出策略

Important

核心要点: 信息流系统的中心问题是扇出发生在哪里——写路径(push)、读路径(pull),还是选择性地两者兼有(混合)。大多数架构决策都源于这个选择。

这是核心问题。三个选项:

(a) 写入时扇出(push)。 用户发帖时,将 post_id 写入每个粉丝的收件箱。每个用户的信息流读取就是平凡的操作:扫描自己的收件箱,填充帖子内容,返回。

(b) 读取时扇出(pull)。 帖子保存在每个作者的发件箱中。读信息流时,获取每个关注对象的最近发件箱,合并、排序、返回。

(c) 混合。 普通用户用 push;大V用 pull。用户的信息流是 (已 push 的收件箱) ∪ (拉取的大V发件箱) — 读取时合并并排序。

为什么混合策略胜出

论证与问题本身相关:社交网络中粉丝数的分布服从幂律——极少数用户占了绝大部分扇出成本。Push 那 99.9%,pull 那 0.1%。这正是 Twitter 描述的 Home Timeline 架构,其推理具有普适性:在任何成本分布服从幂律的系统中,解决方案是针对每个实体的策略,而非一刀切的协议。

阈值是可调的(比如 100 万粉丝)。超过阈值的用户在写入时不扇出;他们的帖子在读取时被拉取并合并到粉丝的信息流中。低于阈值则标准 push。

Warning

生产实践: 真实系统很少使用单一固定的大V阈值。阈值通常是动态的,考虑:当前粉丝数、作者发帖频率(一天发 50 条的大V比一天发 1 条的成本高得多)、当前系统负载(流量高峰时阈值可以收紧以减负)、以及每帖的边际扇出成本。一些系统计算每个作者的”扇出预算”而非简单的粉丝数阈值。

阈值跨越是面向未来的。 当一个作者从 99.9 万粉丝增长到 110 万时,已有的收件箱条目(之前 push 的)保持不动——它们会通过 TTL 自然过期或被新条目覆盖。只有未来的帖子切换到 pull。这就是为什么读路径需要去重:在过渡窗口期间,某些帖子同时存在于收件箱(阈值前 push 的)和发件箱(阈值后 pull 的)。

权衡矩阵

维度PushPull混合
写放大每帖 O(粉丝数)——对大V是灾难性的O(1)——只写作者的发件箱O(粉丝数) 仅适用于低于阈值的作者
读放大单分区扫描一个收件箱O(关注数)——获取每个关注对象的发件箱O(大V关注数)——受用户关注的少数大V限制
信息流新鲜度受扇出管道延迟限制总是最新的(从权威发件箱读取)混合——push 的帖子有延迟;pull 的帖子是最新的
运维复杂度扇出 workers、收件箱存储、背压读取时的合并逻辑、热点发件箱分区两者都有——加上需要管理的阈值策略
大V行为写入风暴;单条帖子触发百万次收件箱写入写入无特殊成本;热门发件箱上的读取集中大V免于 push;成本转移到有限的读时 pull

混合的代价:运维复杂度。你同时运行两条管道并维护阈值策略。回报:你避免了任一极端的无界成本。对于粉丝分布服从幂律的社交网络,这个权衡是明确值得的——否则就要承受平均 350 万写/秒、峰值高出数量级的成本。

为什么收件箱和发件箱共存

这值得明确说明,因为面试官会问:“为什么不只用其中一个?”

  • 收件箱存在是因为读取频繁且必须快速。将用户的信息流预物化到收件箱中使读路径成为单分区扫描。
  • 发件箱存在是因为某些作者 push 的成本太高。发件箱是作者的权威帖子日志。它总是正确的、总是最新的,维护成本几乎为零——概念上是作者的帖子日志,物理上实现为一个针对按作者时间排序访问优化的独立表。
  • 它们服务不同的人群。收件箱服务来自普通作者的 99.9% 帖子。发件箱服务来自大V的 0.1%。单独任何一个都无法处理两者。

收件箱和发件箱都是同一个社交图的物化视图,针对不同的访问方向优化。收件箱物化”我应该读什么?“(为读者优化)。发件箱物化”我写了什么?“(为作者的粉丝 pull 优化)。

Caution

常见错误: 用单一 follows 表同时服务”我关注了谁?“和”谁关注了我?“的查询。这是相反的访问模式,需要不同的分区键。单表要么强制使用二级索引(高负载下的 scatter-gather 查询),要么全表扫描。两个反规范化的表——followingfollowers——各自使用正确的分区键,是唯一能为扇出 workers 和信息流服务都提供单分区读取的设计。

架构决策

决策选择否决理由
扇出策略混合Push / Pull幂律粉丝分布使纯 push 对大V灾难性,纯 pull 对普通用户太慢
排序时机读时写时排序信号(互动、亲和度)是动态的;预计算的分数在几分钟内就过时
消息队列KafkaSQS / RabbitMQ重放能力是扇出正确性的承重墙;消费者组适配 worker 扩缩
大V阈值动态固定阈值发帖频率和系统负载与粉丝数同等重要

核心数据结构

在深入读写路径之前,先看使混合扇出设计成为可能的四张表:

分区键角色
inbox(收件箱)user_id预物化的候选帖子,push 到每个读者
outbox(发件箱)author_id每作者的帖子日志,读时 pull 用于大V
followers(粉丝表)followee_id”谁关注了我?“——驱动扇出写入
following(关注表)follower_id”我关注了谁?“——驱动读时的大V pull

完整 schema 和查询模式在 §10。现在重要的是:每张表都按其主要访问模式分区为单分区读取——热路径上无二级索引。

6. 信息流生成管道 [R3, R4]

Important

核心要点: 候选收集决定完整性。排序决定顺序。保持它们分离——这种分离使排序器可以独立故障而不破坏信息流。

信息流生成是原始帖子和用户可见信息流之间的桥梁。它有三个阶段:

候选收集

组装可能出现在用户信息流中的帖子池:

  • 已 push 的帖子 — 已经在用户收件箱中,来自写入时扇出。
  • pull 的帖子 — 读取时从大V发件箱获取。
  • 注入的帖子(可选) — 广告、来自非关注账号的推荐帖子。

候选集通常有 500–2000 条帖子。收集的职责是完整性——不要对没收集到的帖子排序。

排序

给每个候选帖子打分。信号按对社交信息流的重要性粗略排序:

信号为什么对信息流重要
时效性时间基线——较旧帖子衰减
作者-观看者亲和度观看者与这个作者内容互动的频率
互动速度帖子全局积累点赞/评论的速度
内容类型视频、图片、文字——各有不同的互动模式
多样性连续展示同一作者 5 条帖子的惩罚

排序器为每个候选产生一个分数。在面试中,我会命名信号并说模型是一个在互动结果上训练的学习函数。ML 管道本身不在范围内——实现排序模型是另一道系统设计题。

对于面试规模的系统(阶段 4),候选收集充当召回阶段。大型生产系统(阶段 5)将召回分离为多个检索管道(如兴趣图谱召回、热门内容召回、协同过滤召回),然后统一打分。边界是相同的——只是更多的来源汇入其中。

Warning

生产实践: 亲和度和互动速度等排序特征通常是最终一致的。更新流经管道:互动事件 → Kafka → 流式聚合任务(Flink/Spark Streaming) → 特征存储(Redis 或 Feast)。排序器从特征存储读取——稍有延迟但持续刷新。典型延迟:互动速度为秒级,亲和度为分钟级。这是可接受的,因为排序是”尽力而为”的优化——稍微过时的特征产生稍微次优的排序,而非不正确的信息流。这里描述的特征存储是在线服务存储(信息流生成时的低延迟读取)。离线训练管道从单独的批处理存储读取——相同的特征,不同的 SLO。

组装

取排序最高的 K 个候选,强制多样性约束(同一作者不超过连续 2 条帖子),附加分页续传令牌。

一个轻量级的预过滤可以在排序前减少候选集(如移除用户已看过的帖子,应用内容策略过滤器)。这使排序器的输入保持有界,即使候选池增长。

降级行为: 如果排序服务不可用,回退到对候选集的时间排序。信息流仍然可用——只是个性化程度降低。这就是为什么将排序与收集在架构上分离很重要:你可以移除排序器,系统仍然工作。

7. 写路径 [R1, R2 → R3]

扩展高层架构的写路径组件:

flowchart TD
  Author[作者] --> API[API 网关]
  API --> PostSvc[帖子服务]
  PostSvc --> PostsDB[(帖子 · Cassandra)]
  PostSvc -->|帖子事件| Kafka[(Kafka)]

  Kafka --> Fanout[扇出 Workers]
  Fanout --> Follows[(关系 DB)]
  Fanout --> InboxDB[(收件箱 · Cassandra)]
  Fanout --> InboxCache[(收件箱 · Redis)]

  classDef store fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
  class PostsDB,Kafka,Follows,InboxDB,InboxCache store

逐步说明:

  1. 作者通过 API 发帖。
  2. 帖子服务写入 posts(权威存储)并向 Kafka 发出帖子事件。按 author_id 逻辑排序确保每作者事件有序;物理分区策略是生产细节。
  3. 扇出 workers 消费事件。它们在关系 DB 中检查作者的粉丝数。
  4. 如果低于阈值(如 < 100 万粉丝):获取粉丝列表,批量将 post_id 写入每个粉丝的 inbox(Redis 和 Cassandra 都写)。
  5. 如果高于阈值:完全跳过扇出。帖子留在 posts 中;任何查看信息流的粉丝会在读取时 pull 它。

Tip

为什么通过 Kafka 异步: 三个原因,每个都与扇出问题直接相关:

  • 将写入延迟与扇出工作解耦。 作者的 POST /posts 在权威写入落地后就返回——不用等待百万次收件箱写入。没有这个解耦,一个有 50 万粉丝的用户发帖要等好几秒。
  • 吸收接近阈值的突发。 接近阈值的用户(如 80 万粉丝)仍然扇出。当多个接近阈值的用户同时发帖,队列平滑负载尖峰。
  • 支持重放。 如果扇出 worker bug 丢失事件,可以从 Kafka 重放。如果新增下游消费者(通知、分析),可以重放来引导启动。

8. 读路径 [R3, R4]

Important

核心要点: 读路径合并两个数据源(push 的收件箱 + pull 的大V发件箱),去重、排序、填充——按这个顺序。每个步骤可以独立失败,且系统通过跳过失败的步骤仍然可用。

flowchart TD
  Client[客户端] --> API[API 网关]
  API --> FeedSvc[信息流服务]

  FeedSvc --> InboxCache[(收件箱 · Redis)]
  InboxCache -->|miss| InboxDB[(收件箱 · Cassandra)]
  FeedSvc --> CelebOutbox[(大V发件箱)]

  FeedSvc --> Ranker[排序器]
  Ranker --> FeedSvc

  FeedSvc --> PostCache[(帖子 · Redis)]
  PostCache -->|miss| PostsDB[(帖子 · Cassandra)]

  classDef store fill:#eef2f1,stroke:#2c5f5d,stroke-width:2px,color:#1f2937;
  class InboxCache,InboxDB,CelebOutbox,PostCache,PostsDB store

信息流读取,逐步说明:

  1. 信息流服务从用户的 push 收件箱获取最近 N 个 post_id(Redis 有序集合,按 created_at 排序)。
  2. 信息流服务从 following 表查询用户的大V列表(已缓存),然后获取每个大V发件箱的最近帖子。
  3. 合并和去重: 组合 push 和 pull 的候选。合并步骤需要基于 post_id 的去重pass(阈值跨越可能导致同一帖子同时出现在两个来源中)。
  4. 预过滤: 移除已看帖子并应用内容策略。这限制了排序器的输入。
  5. 排序: 排序器使用第 6 节的信号动态给候选集打分。排序发生在这里——不在收件箱中。
  6. 组装: 取 top K,强制多样性,附加分页令牌。
  7. 填充: 从帖子缓存获取完整帖子内容(Redis hash,按 post_id,miss 时回退 Cassandra)。
  8. 返回给客户端。

Caution

常见错误: 跳过合并步骤后的去重。如果一个作者跨越大V阈值,而某些粉丝的收件箱中仍有其最近帖子(来自之前的扇出),这些帖子会同时出现在收件箱和 pull 的发件箱中。不按 post_id 去重,用户会看到重复帖子——这是一个可见的、面向用户的 bug。

为什么两层缓存

  • 收件箱缓存(每活跃用户一个 Redis 有序集合,按 created_at 排分)。保存最近 N 个 post_id 作为排序的候选。基于 TTL:读取时预热,冷却时驱逐。
  • 帖子内容缓存(按 post_id 的 Redis hash)。保存填充好的帖子正文。帖子实际上是不可变的(编辑少见,删除更少),所以这个缓存几乎不需要失效——长 TTL 即可。

它们有不同的失效属性。收件箱缓存必须是写穿的(新 push 必须立即可见);帖子缓存本质上是只读的。分离的缓存让每个使用与其数据变更频率匹配的失效策略。

9. API 设计 [R1, R2, R3, R4]

三个端点完成大部分工作:

POST /api/posts                              [R1 — 创建帖子]
  body:    { author_id, text, media_ids?, parent_post_id? }
  returns: { post_id, created_at }

GET /api/feed?next_token=<opaque>&limit=20   [R3, R4 — 查看排序信息流]
  returns: { posts: [...], next_token }

POST /api/follow                             [R2 — 关注用户]
  body:    { follower_id, followee_id }
  returns: { ok }

非显而易见的决策:基于 token 的分页,而非基于 offset。 Offset 分页在页面之间有新帖子到达时会崩溃——用户看到重复或遗漏帖子。next_token 是一个不透明值,编码了足够的状态来恢复(如最后一条的 ranked_at 时间戳加上作为 tie-breaker 的 post_id)。它对插入是稳定的。值得大声说出来;面试官会注意到。

Caution

常见错误: 对信息流使用基于 offset 的分页(?page=2&limit=20)。在第 1 页和第 2 页请求之间,新帖子到达并移动了整个列表。基于 token 的分页锚定在有序集合中的稳定点,对并发插入免疫。

GET /api/feed 端点抽象了整个信息流生成管道——客户端不知道也不关心一个帖子是 push 还是 pull 的,或者什么排序模型给它打了分。这个抽象边界意味着后端可以从阶段 3 演进到阶段 5 而无需 API 变更。

10. 数据模型和存储

需求 → 组件映射

在深入存储之前,每个需求如何映射到服务它的组件:

需求组件角色
R1 — 创建帖子帖子服务权威写入 + 事件发出
R2 — 关注用户关系图(两张表)维护粉丝/关注关系
R3 — 查看信息流信息流服务合并收件箱 + 发件箱,去重,填充
R4 — 排序排序器读时给候选打分
R5 — 互动互动管道异步计数器供排序器使用
新鲜度(NFR)Kafka + 扇出 workers帖子到信息流出现的有界延迟
可用性(NFR)缓存层 + 降级路径即使子系统故障也能提供信息流

访问频率

相对查询频率驱动每个 schema 决策——高频查询必须是无二级索引的单分区读取:

查询频率驱动
获取信息流候选(收件箱)极高(峰值 300K/秒)收件箱按 user_id 分区
获取大V帖子(发件箱)发件箱按 author_id 分区
获取粉丝列表用于扇出高(350 万写/秒经过这里)粉丝表按 followee_id 分区
获取关注列表用于大V pull关注表按 follower_id 分区
按 ID 填充帖子极高帖子按 post_id 分区
获取用户-作者亲和度中(排序器读缓存快照)互动表按 user_id 分区

存储技术选择

存储技术服务为什么选这个
帖子CassandraR1写优化,按 post_id 分区保证均匀分布,无需 join
收件箱Cassandra + Redis 有序集合R3, R4Cassandra 做持久存储;Redis 有序集合做热工作集
发件箱CassandraR3每作者时间排序日志;访问模式(按作者取最近 N 条)直接映射到 clustering key
关注/粉丝Cassandra(两张表)R2, R3同一数据的两种访问模式,各需要单分区读取
用户资料CassandraR2低写入、高读取;扇出 workers 在内存中缓存 is_celebrity
消息队列KafkaR1, R3持久、可重放、按作者有序

访问模式矩阵

所有存储访问一览——这是驱动每个 schema 决策的总表:

需求访问模式使用的键调用者
R1写入新帖子postspost_id(分区键)帖子服务
R3按 ID 填充帖子postspost_id(分区键)信息流服务
R3获取大V最近帖子outboxauthor_id(分区键),created_at DESC(clustering key)信息流服务
R3获取信息流候选inboxuser_id(分区键),created_at DESC(clustering key)信息流服务
R3Push 帖子到收件箱inboxuser_id(分区键)扇出 workers
R2列出我关注的人followingfollower_id(分区键)信息流服务
R2列出我的粉丝followersfollowee_id(分区键)扇出 workers
R2检查大V状态user_profileuser_id(分区键)扇出 workers
R4获取用户-作者亲和度user_engagementuser_id(分区键),author_id(clustering key)排序器

Schema

帖子表(Posts) [R1]

posts (Cassandra):
  partition key:  post_id
  columns:        author_id, text, media_ids[], parent_post_id,
                  created_at, engagement_counts { likes, comments, shares }

post_id 作为分区键保证均匀分布(Snowflake ID)。所有作者时间线访问通过下面的 outbox 表——posts 不需要二级索引。

发件箱(Outbox) [R3]

outbox (Cassandra):
  partition key:   author_id
  clustering key:  created_at DESC
  columns:         post_id

同时服务读时的大V pull 和作者主页。这是每作者的时间排序日志,使 posts.author_id 上的二级索引变得不必要。

Warning

生产实践: 高产出作者(大V长年高频发帖)会创建无界的发件箱分区。解决方案与收件箱相同:按时间窗口分桶——如分区键 (author_id, month)。信息流服务只读取最近的桶,因为最多需要最近 N 条帖子。

收件箱(Inbox) [R3, R4]

inbox (Cassandra):
  partition key:   user_id
  clustering key:  created_at DESC
  columns:         post_id, author_id

Clustering key 是 created_at不是排序分数。排序在读时动态计算。收件箱是一个候选缓冲区——它存储可用于排序的内容,而非排序本身。

Caution

常见错误: 在 Cassandra 收件箱行中存储排序分数。这看起来高效(“写时预排序!”),但排序信号持续变化——互动速度逐分钟变化,亲和度随用户与新内容互动而更新。更新 Cassandra clustering key 需要 delete + re-insert,在扇出规模下代价高昂。

Warning

生产实践: 单个 user_id 分区随时间无界增长(多年的 push 帖子)。生产系统按时间窗口分桶——如分区键 (user_id, month) — 来限制分区大小。信息流服务只读取当前和上一个桶。Staff 级面试官经常追问这个。

关注表(Following) [R2, R3]

following (Cassandra):
  partition key:   follower_id
  clustering key:  followee_id
  columns:         created_at

粉丝表(Followers) [R2, R3]

followers (Cassandra):
  partition key:   followee_id
  clustering key:  follower_id
  columns:         created_at

两张表,不是一张。扇出是最热路径(350 万写/秒)。它不能依赖二级索引——在 Cassandra 中那是 scatter-gather 查询。扇出 workers 需要 SELECT follower_id FROM followers WHERE followee_id = ? — 单分区读取。信息流服务需要反向:SELECT followee_id FROM following WHERE follower_id = ?。两张表,各自为其调用者优化。

用户资料(User Profile) [R2]

user_profile (Cassandra):
  partition key:  user_id
  columns:        display_name, followers_count,
                  is_celebrity (boolean — followers_count > threshold),
                  account_state (active | suspended | deactivated)

followers_count 异步更新(关注时递增,取关时递减)。is_celebrity 在跨越阈值时翻转——读取成本低,扇出 workers 在内存中缓存。

用户互动(User Engagement) [R4, R5]

user_engagement (Cassandra):
  partition key:   user_id
  clustering key:  author_id
  columns:         interaction_count, last_interaction_at

向排序器提供亲和度信号。从互动事件异步更新。不在关键读路径上——排序器读取缓存快照。

11. 深入探讨

信息流新鲜度与缓存失效

矛盾:缓存使读取廉价,但过时的缓存使信息流感觉停滞。

对于收件箱缓存,新鲜度来自写路径——新 push 的帖子立即写穿到 Redis。缓存总是与扇出管道一样新鲜。这里的延迟意味着扇出延迟(Kafka consumer lag),而非缓存过期。

对于pull 的大V帖子,新鲜度取决于信息流服务何时获取发件箱。我们接受约 30 秒的延迟——用户的信息流刷新触发 pull,发件箱始终是权威的。

对于帖子内容缓存,失效是个伪问题:帖子很少变更。编辑(如果支持)写穿;删除设置 tombstone 并通过 Kafka topic 上的失效事件传播。

关键洞察:“在信息流系统中,新鲜度主要是写路径问题(扇出完成得多快?),而非缓存失效问题。缓存是管道的下游,不是独立于管道的。“

热键与大V读放大

当大V发帖且百万粉丝在几秒内刷新信息流时,大V的发件箱分区成为热读键。这是读侧的大V问题——与混合策略解决的写侧问题不同。混合消除了写入风暴,但在发件箱分区上创造了集中的读取模式。

为什么发生: 在 300K 信息流读/秒的情况下,如果其中仅 1% 的用户关注了某个大V,那就是 3K 读/秒命中一个发件箱分区。热键不是 bug——它是混合设计选择的直接、可预测的后果。大V的数量少且已知,这意味着缓解措施是有界的。

Note

面试信号: 将读侧大V问题识别为混合设计的后果——而非一个独立的、不相关的问题——是 Staff+ 级信号。面试官可能会问:“混合的缺点是什么?“这就是答案。

从按时间到排序

按时间的信息流是排序合并。排序信息流在候选收集和组装之间引入打分函数。这是系统从阶段 3 跨越到阶段 4 的时刻,架构含义是重大的:

  • 新依赖: 排序器服务。必须低延迟(p99 < 50ms,约 1000 个候选)且高可用。
  • 新故障模式: 排序器不可用。系统必须在无人工干预的情况下降级到按时间排序。这就是为什么排序是独立服务而非嵌入信息流服务——这样它可以独立故障。
  • 新调优面: 排序模型就是产品。改变它改变用户看到的内容。这创造了反馈循环——模型优化互动,改变用户行为,改变训练数据。

从按时间到排序的过渡是信息流系统从纯基础设施问题变成产品-ML-基础设施混合体的时刻。在面试中,命名这个过渡及其含义比描述 ML 模型更有价值。

12. 故障模式

Important

核心要点: 信息流系统中的每个故障威胁三个属性之一:新鲜度(帖子是否及时出现?)、延迟(读取是否快速?)、或正确性(内容是否正确?)。知道一个故障威胁哪个属性,告诉你什么降级是可接受的。

六个场景,按威胁分组:

新鲜度故障

扇出 workers 落后(Kafka consumer lag)

威胁:新鲜度。

Kafka 吸收积压。粉丝看到延迟的帖子但无数据丢失。信息流是陈旧的,不是错误的。缓解:水平扩展 workers;Kafka 消费者组处理再平衡。监控 consumer lag 作为首要健康指标——它是信息流新鲜度的最佳代理。如果 lag 超过新鲜度 SLO(30 秒),触发告警。

为什么这是最可能的故障:扇出是最高吞吐量的组件(平均 350 万写/秒)。任何上游流量尖峰(病毒事件、大V密集发帖)首先冲击扇出 workers。

延迟故障

排序服务过载

威胁:延迟(降级下的信息流质量)。

流量高峰期间,排序器的候选量飙升。缓解措施,按降级顺序:

  1. 负载削减。 排序器在高负载下丢弃昂贵特征,仅使用快速信号(时效性、亲和度)。信息流质量略微降级;延迟保持在预算内。
  2. 超时 + 回退。 如果排序在 50ms 内没有响应,提供按时间排序。用户立即获得可用的信息流。
  3. 预排序缓存。 对于频繁访问的信息流(60 秒内的回访),提供之前排序过的结果。过时排序胜过无排序。

设计原则:排序是增强,不是前提条件。

Redis 收件箱集群丢失节点

威胁:延迟。

对 Cassandra 的冷读取雪崩。缓解措施:请求合并(一个用户的 miss 触发一次 DB 读取,而非 N 次)、熔断器——参见 Martin Fowler 的模式描述——以及从从副本集预热。信息流降级为较慢的读取,不是坏掉的读取。

大V流量尖峰(读路径)

威胁:延迟。

大V发帖,百万粉丝同时刷新——§11 描述的热键问题。缓解措施,按影响顺序:

  • 发件箱在 Redis 中复制。 大V的最近帖子复制到多个 Redis 分片。信息流服务在副本间轮询。
  • 读取合并。 对同一发件箱的多个同时请求解析为单次后端读取,结果扇出给所有等待者。
  • 预计算快照。 对粉丝数 top 约 1000 的账号,每隔几秒预生成其发件箱快照。从边缘缓存提供。

正确性故障

信息流缓存损坏或过时排序

威胁:正确性。

如果一条已删帖子出现在某人的信息流中,或回滚的排序模型提供过时分数:

  • 软删除传播。 删除事件使帖子在帖子缓存和引用它的任何收件箱条目中失效。
  • 版本戳。 缓存的排序结果携带 ranked_at 时间戳。信息流服务在下次读取时丢弃超过新鲜度阈值的结果。
  • 模型版本标记。 排序模型回滚触发活跃用户的后台重排序。非活跃用户在下次加载信息流时重排序。

关系 DB 分区不可用

威胁:正确性(以及受影响用户的新鲜度)。

受影响分区的扇出 workers 停滞。这是最难的故障,因为它接近正确性问题——如果我们无法获取粉丝列表,分区恢复时就无法正确扇出。缓解:关系数据应在 quorum 复制的存储中。恢复时,从 Kafka 重放排队事件以补上遗漏的扇出。

Note

面试信号: 区分影响新鲜度、延迟和正确性的故障——并解释每种情况下什么降级是可接受的——是区分 Senior 和 Staff 答案的关键。

13. 我会跳过的内容(并声明跳过)

时间检查——还剩五分钟。我会明确推迟的内容:

  • 排序模型本身。 我已经命名了信号、管道形状和回退行为。实现 ML(特征存储、模型服务、训练循环)是另一个系统。
  • 媒体管道。 图片和视频存储在 CDN 后的对象存储中,有针对不同分辨率的转码。说”CDN + S3”然后继续。
  • 通知。 信息流的并行系统,共享扇出基础设施。值得一句话;不值得一个小节。
  • 反垃圾和内容完整性。 内容审核、发帖频率限制、虚假账号。真实系统需要;面试不要求你解决。
  • 多阶段候选生成(阶段 5)。 当候选池超过单次排序 pass 能处理的量,你加一个召回/打分/组装漏斗。我在演进表中命名了它;展开构建是另一道设计题。

说*“我会跳过这个,原因是……”*是强信号。它表明你知道完整的问题表面并在做有意识的范围选择。

面试流程总结

架构一览

写路径                           读路径

发帖                              客户端请求
 ↓                                 ↓
帖子服务                          信息流服务
 ↓                                 ↓
Kafka                             收件箱(pushed) + 发件箱(pulled)
 ↓                                 ↓
扇出 Workers                     合并 + 去重
 ↓                                 ↓
收件箱(每粉丝)                 预过滤 → 排序 → 组装

                                  填充 → 响应

白板演练顺序

1. 需求和范围        — 确定规模、分布、新鲜度
2. 容量估算         — 找到 350 万写/秒的约束
3. 架构演进         — 命名阶段 1–5;目标阶段 4
4. 扇出策略         — 混合;从幂律论证
5. 信息流管道       — 收集 → 预过滤 → 排序 → 组装
6. 写路径           — 发帖 → Kafka → 扇出(跳过大V)
7. 读路径           — 收件箱 ∪ 发件箱 → 去重 → 排序 → 填充
8. API              — 三个端点,token 分页
9. 数据模型         — 访问模式矩阵 → schema
10. 深入探讨        — 新鲜度、热键、排序过渡
11. 故障模式        — 新鲜度/延迟/正确性

面试官如果在任何点追问更深:

深度层级他们在探测去哪里展开
层级 1Push vs pull 基础权衡矩阵(§5)
层级 2大V问题为什么混合,阈值策略
层级 3排序架构独立服务,回退到按时间
层级 4特征管道特征存储,延迟 SLO,流式任务
层级 5多阶段排序召回/打分/组装漏斗(命名,不构建)

14. 总结

面试官下个问题前的一句话总结:

这个设计通过 push 预计算大多数用户的收件箱来优化信息流读取,通过在读时 pull 来处理那 0.1% push 无法扩展的大V扇出问题,并在候选收集和信息流组装之间加入排序管道,使系统可以从按时间排序演进到相关性排序,而无需改变底层存储架构。

不同级别的区分

  • SDE II 通常落在 push 或 pull 之一,描述一种扇出策略,命名收件箱/发件箱结构,画出基本读路径。
  • SDE III 命名幂律粉丝分布作为混合的原因,选择有具体理由的阈值,将异步扇出管道视为对等系统(非实现细节),命名 4+ 故障模式及优雅降级路径,明确推迟排序和媒体为”第二系统”。
  • Staff/Principal 解释收件箱和发件箱为什么共存(不只是它们共存);命名架构演进阶段及设计所处位置;将排序分离为具有独立故障/降级契约的独立服务;识别读侧大V问题是混合设计的后果(不只是另一个问题);用成本不对称性而非功能清单来组织权衡。

差异不在知识。而在命名不对称性——成本分布是非均匀的,设计应该非均匀地匹配它,系统随产品需求增长经历可辨识的架构阶段。

延伸阅读