一个普通作者只有十个关注者,一个热点作者有一千个关注者。如果两人各发两条动态,全量写入扇出会产生 2020 份收件箱引用;如果只有五个人打开首页,其中大量预写永远不会被读到。时间线设计首先比较写时做多少工作与读时做多少工作,而不是先决定使用哪种缓存。

Twitter 在这里是公开发布和关注时间线的题型。基础时间线按发布时间倒序,不加入机器学习排序;排序信息流留给下一篇。本文使用固定关注图比较策略,不声称这些数字来自真实社交产品。

发布与时间线不是同一份事实

POST /posts 提交正文与客户端幂等键,写入 posts(post_id, author_id, created_seq, body, version, deleted);PUT /following/{author_id} 和 DELETE 修改关注关系;GET /timeline?cursor=...&limit=20 返回当前授权范围内的动态。帖子表是事实,用户收件箱只是 (reader_id, created_seq, post_id) 的派生索引。

发布成功的候选定义是帖子与扇出事件已经原子提交,不是所有关注者的时间线已经更新。接口可返回自己的帖子,读取自己的主页从事实表立即可见;关注者首页允许明确的新鲜度窗口。若产品要求发布者确认时所有人已收到,就会把慢读者和扇出积压引入写入延迟,应该先讨论是否真的需要这个承诺。

关注图可用两种索引支持访问:按读者查作者集合用于读时聚合,按作者查读者集合用于写时扇出。维护双向索引需要去重与一致更新。取消关注后,旧收件箱引用仍可能存在,读路径要应用当前关注关系或版本门禁,不能等待所有历史引用逐条删除才生效。

用同一组输入核算成本

教学假设每天一千万篇帖子,平均约 115.74 posts/s,峰值系数 6 为 694.44 posts/s。普通作者平均 200 个关注者,全部写入扇出时平均约 23148 份引用/秒、峰值约 138889 份引用/秒。每份引用按 32 B,日新增约 64 GB;保留七天、三份约 1.344 TB,未含排序结构开销和正文。

若某作者拥有一千万关注者,一条帖子就产生一千万份引用。即使扇出系统每秒能写十万份,仅这一条也要约一百秒;这个吞吐是假设,不是本地测量。队列隔离能防止它拖慢普通作者,却不能让一百秒工作凭空消失。

假设每日两千万次首页请求、每次关注 200 个作者,全拉取方案要考虑最多四十亿个作者流读取,不能只报首页平均 231.48 requests/s。批量查询、共享缓存和只取每个作者近期片段可以减少 RPC 数,但仍需要比较候选条目数量与聚合成本。用户关注数从 200 增至 2000 时,拉取成本可能先变成瓶颈。

推送是否合算取决于关注者活跃程度。对某作者,一次发布的推成本近似 关注者数 × 单引用写成本,拉成本近似 实际首页读取次数 × 作者流读取成本,后者还受缓存复用影响。不能仅按粉丝数设永久阈值;热点作者中有些粉丝非常活跃,普通作者也可能拥有大量沉睡账户。

三种策略的读写路径

flowchart LR
    P[帖子提交] --> D[(帖子事实表)]
    D --> C{作者策略}
    C -->|普通作者| W[写时扇出任务]
    W --> I[(读者收件箱)]
    C -->|热点作者| A[(作者近期流)]
    R[首页读取] --> I
    R --> A
    I --> M[合并 去重 当前权限过滤]
    A --> M
    M --> R

全推在发布时写读者收件箱,读时查询便宜,适合活跃读者多、关注关系相对稳定的场景。全拉只维护作者流,读时取关注作者的近期内容再归并,适合读取少或关注者多数不活跃的场景。混合将热点作者保留为读时拉取,其他作者写入收件箱,并在返回前统一去重、授权与排序。

混合策略不是一个二元标签就结束了。作者由推切到拉期间,旧帖子已进入收件箱,新帖子尚未进入,读时必须知道切换水位。短期双读并以帖子 ID 去重可以减少遗漏;完成对账后再清理冗余。反向由拉切到推也要处理历史窗口回填,不应突然让读者首页缺少切换之前的内容。

sequenceDiagram
    participant P as 发布服务
    participant W as 扇出工作者
    participant I as 收件箱
    participant R as 首页读取
    P->>W: 普通作者帖子 事件可能重投
    W->>I: 按读者与帖子唯一键插入
    W->>I: 重试相同引用
    I-->>W: 已存在 不重复
    R->>I: 读取游标之后的普通帖子
    R->>P: 拉取热点作者近期帖子
    R->>R: 按时间和ID归并并去重

分页游标包含稳定的 (created_seq, post_id),并绑定过滤条件。客户端翻页期间新发的更晚帖子留给刷新操作,不插入已经翻过的历史区间。时间戳并列用唯一 ID 决胜;单独依赖时间戳会漏掉同毫秒帖子。普通游标不是快照,删除和新关注会改变候选集合,若产品要求固定浏览会话,需要额外快照或会话缓存。

可复跑的操作数对照

examples/system-design/labs/18/timeline.py 固定一千个读者:全部关注热点作者,前十人另外关注普通作者;两作者各发两帖,五个指定读者各读一次。三个策略都返回相同帖子顺序,写引用数分别为全推 2020、全拉 0、混合 20。读候选总数在这份微型输入里均为 14,因为所有读取都枚举同一组相关帖子;实验没有测量远程 RPC、缓存或 CPU 时间。

程序还检查读者 0 的两页游标结果没有交集。负例给单批扇出设置 100 份写预算,全推的 2020 份超预算,退出 2;正常对照和结果等价断言通过,退出 0。

1
2
python3 examples/system-design/labs/18/timeline.py
python3 examples/system-design/labs/18/timeline.py --unsafe

完整候选列表、计数和环境位于 examples/system-design/evidence/18/。它是成本与语义模型,不是真实 Redis 或消息队列压测。小样本证明“相同业务结果可以对应不同写入量”,不能证明混合在所有流量下最快。尤其把读取频率提高很多后,全拉要重复聚合,而预写收件箱的收益会增加。

删除和恢复要绕过扇出积压

帖子撤销与权限收紧先改权威状态,再发送派生索引清理事件。首页合并之后再过滤,阻止旧收件箱引用继续暴露内容。正文缓存可以留存字节,但当前权限门禁不能被缓存命中绕过。若过滤后不足一页,需要继续取候选并设扫描上限;否则大量删除可能让单次请求扫描无界。

扇出工作者按批次保存进度,重领时使用读者与帖子唯一键避免重复。热点事件与普通事件分队列或按租户公平调度,防止单个作者占满工作者。恢复时观察最老未扇出事件年龄而非只看队列长度,小队列也可能包含一个无法处理的永久毒事件。

关注索引、帖子事实和收件箱版本需要可对账。派生收件箱可以重建,事实帖子丢失却不能从不完整的收件箱恢复全量。初版如果单库查询足够快,可以先用关注关联查询实现拉取,用测量证明聚合成本过高后再添加扇出,避免一开始承担双份索引和重放系统。

面试先声明时间排序与新鲜度,再算普通与热点两组负载,画三种策略,深入切换水位、删除及分页,最后核对可恢复性。追问“百万粉作者一律拉是否正确”,应该回到发布频率、活跃粉丝和缓存命中;追问“推送完成等于已读吗”,应与第 17 篇的设备送达和用户已读区别开。

参考资料

  • Redis sorted sets:有序集合可作为时间线索引的一种实现,本文实验没有运行 Redis。
  • SQLite CREATE TABLE:唯一引用可用数据库约束表达;三策略和负载分布均为独立教学模型。