题目背景
面试官让你设计一个信息流(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. 高层架构
在确定任何扇出策略之前,先画出组件拓扑。信息流系统有三个逻辑子系统:
- 帖子摄入 — 接收新帖子,权威存储,发出事件。
- 信息流生成 — 接收原始帖子事件,为每个用户生成个性化、有序的候选帖子列表。扇出发生在这里。
- 信息流服务 — 通过 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 的)。
权衡矩阵
| 维度 | Push | Pull | 混合 |
|---|---|---|---|
| 写放大 | 每帖 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 查询),要么全表扫描。两个反规范化的表——following 和 followers——各自使用正确的分区键,是唯一能为扇出 workers 和信息流服务都提供单分区读取的设计。
架构决策
| 决策 | 选择 | 否决 | 理由 |
|---|---|---|---|
| 扇出策略 | 混合 | Push / Pull | 幂律粉丝分布使纯 push 对大V灾难性,纯 pull 对普通用户太慢 |
| 排序时机 | 读时 | 写时 | 排序信号(互动、亲和度)是动态的;预计算的分数在几分钟内就过时 |
| 消息队列 | Kafka | SQS / 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
逐步说明:
- 作者通过 API 发帖。
- 帖子服务写入
posts(权威存储)并向 Kafka 发出帖子事件。按author_id逻辑排序确保每作者事件有序;物理分区策略是生产细节。 - 扇出 workers 消费事件。它们在关系 DB 中检查作者的粉丝数。
- 如果低于阈值(如 < 100 万粉丝):获取粉丝列表,批量将
post_id写入每个粉丝的inbox(Redis 和 Cassandra 都写)。 - 如果高于阈值:完全跳过扇出。帖子留在
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
信息流读取,逐步说明:
- 信息流服务从用户的 push 收件箱获取最近 N 个 post_id(Redis 有序集合,按
created_at排序)。 - 信息流服务从
following表查询用户的大V列表(已缓存),然后获取每个大V发件箱的最近帖子。 - 合并和去重: 组合 push 和 pull 的候选。合并步骤需要基于
post_id的去重pass(阈值跨越可能导致同一帖子同时出现在两个来源中)。 - 预过滤: 移除已看帖子并应用内容策略。这限制了排序器的输入。
- 排序: 排序器使用第 6 节的信号动态给候选集打分。排序发生在这里——不在收件箱中。
- 组装: 取 top K,强制多样性,附加分页令牌。
- 填充: 从帖子缓存获取完整帖子内容(Redis hash,按 post_id,miss 时回退 Cassandra)。
- 返回给客户端。
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 分区 |
存储技术选择
| 存储 | 技术 | 服务 | 为什么选这个 |
|---|---|---|---|
| 帖子 | Cassandra | R1 | 写优化,按 post_id 分区保证均匀分布,无需 join |
| 收件箱 | Cassandra + Redis 有序集合 | R3, R4 | Cassandra 做持久存储;Redis 有序集合做热工作集 |
| 发件箱 | Cassandra | R3 | 每作者时间排序日志;访问模式(按作者取最近 N 条)直接映射到 clustering key |
| 关注/粉丝 | Cassandra(两张表) | R2, R3 | 同一数据的两种访问模式,各需要单分区读取 |
| 用户资料 | Cassandra | R2 | 低写入、高读取;扇出 workers 在内存中缓存 is_celebrity |
| 消息队列 | Kafka | R1, R3 | 持久、可重放、按作者有序 |
访问模式矩阵
所有存储访问一览——这是驱动每个 schema 决策的总表:
| 需求 | 访问模式 | 表 | 使用的键 | 调用者 |
|---|---|---|---|---|
| R1 | 写入新帖子 | posts | post_id(分区键) | 帖子服务 |
| R3 | 按 ID 填充帖子 | posts | post_id(分区键) | 信息流服务 |
| R3 | 获取大V最近帖子 | outbox | author_id(分区键),created_at DESC(clustering key) | 信息流服务 |
| R3 | 获取信息流候选 | inbox | user_id(分区键),created_at DESC(clustering key) | 信息流服务 |
| R3 | Push 帖子到收件箱 | inbox | user_id(分区键) | 扇出 workers |
| R2 | 列出我关注的人 | following | follower_id(分区键) | 信息流服务 |
| R2 | 列出我的粉丝 | followers | followee_id(分区键) | 扇出 workers |
| R2 | 检查大V状态 | user_profile | user_id(分区键) | 扇出 workers |
| R4 | 获取用户-作者亲和度 | user_engagement | user_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。
延迟故障
排序服务过载
威胁:延迟(降级下的信息流质量)。
流量高峰期间,排序器的候选量飙升。缓解措施,按降级顺序:
- 负载削减。 排序器在高负载下丢弃昂贵特征,仅使用快速信号(时效性、亲和度)。信息流质量略微降级;延迟保持在预算内。
- 超时 + 回退。 如果排序在 50ms 内没有响应,提供按时间排序。用户立即获得可用的信息流。
- 预排序缓存。 对于频繁访问的信息流(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. 故障模式 — 新鲜度/延迟/正确性
面试官如果在任何点追问更深:
| 深度层级 | 他们在探测 | 去哪里展开 |
|---|---|---|
| 层级 1 | Push 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问题是混合设计的后果(不只是另一个问题);用成本不对称性而非功能清单来组织权衡。
差异不在知识。而在命名不对称性——成本分布是非均匀的,设计应该非均匀地匹配它,系统随产品需求增长经历可辨识的架构阶段。
延伸阅读
- Twitter’s Timeline at Scale (InfoQ) — 关于混合扇出、大V问题和大规模运营时间线的经典演讲。这道题最清晰的第一手资料。
- Designing Data-Intensive Applications — Kleppmann. 第 1、5、11 章覆盖了本设计所依赖的异步消息、复制和流处理模式。
- System Design Interview Vol. 1 — Alex Xu. 第 11 章是信息流的教科书式演练。
- Twitter Snowflake —
使
post_id分区干净的 ID 生成方案。 - 幂律分布 — 论证混合策略的统计形态。
- Cache-aside 模式 — 两个缓存层使用的模式。
- Martin Fowler: 熔断器 — 本设计多个故障模式中使用的降级模式。