首页信息流是任何社交产品中被读取最多的页面,也是构建成本最高的页面之一。每个用户关注数百个账号,每个账号按自己的节奏发帖,而每次有人打开应用,信息流都必须在几百毫秒内排好序返回。
核心的设计问题有两个经典答案:写扩散(fan-out on write)和读扩散(fan-out on read),而所有大型信息流最终都会把两者混合使用。本文用数字比较两者,介绍混合方案,最后给出一张包含完整读写路径的架构图。
需求
- 用户能看到所关注账号的近期帖子,按排序或时间顺序展示。
- 打开信息流的 P99 耗时远低于一秒。
- 新帖子在几秒内出现在关注者的信息流中。
- 无限滚动,没有重复,也没有遗漏。
- 即使某个账号有数千万关注者,系统也能正常工作。
读请求占绝对多数。根据 这篇介绍 Twitter 架构的文章,Twitter 在 2013 年每秒处理约 30 万次时间线读取,而推文写入只有每秒约 6,000 次。先为读取做设计。
粗略估算
| 假设 | 数值 |
|---|---|
| 日活用户 | 1,000 万 |
| 每个用户每天打开信息流次数 | 10 次,平均约 1,160 次读取/秒 |
| 每天新帖子数 | 100 万,约 12 篇/秒 |
| 每个作者的平均关注者数 | 200,约 2,300 次扩散写入/秒 |
| 缓存信息流大小 | 800 个 8 字节的帖子 ID,所有用户合计约 64 GB |
平均值看起来人畜无害,离群值却不是:一个拥有 2,000 万关注者的作者,发一篇帖子就会产生 2,000 万次扩散写入,相当于其他所有人一整天扩散工作量的十分之一。下面的设计要解决的正是这个离群值。
写扩散(推模式)
有人发帖时,后台任务查出他的关注者,把帖子 ID 插入每个关注者缓存信息流的顶部。读取信息流就只需一次查找:从自己的列表里取出最新的 ID,再获取帖子内容。
- 优点: 读取快速且可预测。
- 代价: 一篇帖子产生的写入次数等于作者的关注者数。一个有 3,000 万关注者的账号,发一篇帖子就是 3,000 万次缓存写入,而且这些关注者要很晚才能看到。同一篇文章提到,最大账号的帖子要花几分钟才能送达所有关注者。
- 浪费: 你还会写入那些本周根本不会打开应用的用户的信息流。
让每个缓存信息流保持简短。Twitter 当时把首页时间线在 Redis 中限制为 800 条;更早的帖子在有人滚动到那里时再从数据库读取。
读扩散(拉模式)
有人发帖时什么都不做。用户打开信息流时,获取其关注的每个账号的近期帖子,合并后排序。
- 优点: 写入很便宜,大号也不会带来额外成本。
- 代价: 每次读取信息流都要涉及数百个作者。在每秒 30 万次读取的规模下,这意味着为大量重复的工作发出海量查询。
混合方案
大多数大型信息流把两者结合起来:
- 普通账号的帖子推送到关注者的缓存信息流中。
- 关注者数超过阈值的账号跳过扩散,在读取时拉取它们的近期帖子。
- 读取时,把缓存信息流和拉取到的帖子合并,再进行排序。
阈值是一个可调的旋钮,而不是定律。把它设在扩散写入的成本开始超过额外读取成本的位置,并随着关注者分布的变化重新评估。没有关注任何大号的用户,永远不需要为拉取路径付出成本。
排序
按时间排列的信息流按时间排序。带排序的信息流分三步:收集候选帖子(上面合并后的列表,如果有推荐也一并加入),用新鲜度、用户与作者的互动频率、帖子的互动量等信号给每个候选打分,然后过滤(已屏蔽的账号、已经看过的帖子、近似重复的内容)。
把排序放在扩散路径之外。扩散只存储候选;排序在读取时用最新的信号完成。这样修改排序模型时,就不需要回填缓存信息流。
删除、屏蔽与私密账号
信息流里存的是 ID,所以删除帖子只需对帖子存储做一次写入;信息流服务会丢弃那些已经解析不到的 ID。屏蔽以及切换为私密的账号,也需要在读取时做同样的过滤,因为要从数百万个缓存信息流中删除一个 ID,既慢又容易出错。
分页
永远不要用偏移量给信息流分页。用户滚动时,新帖子不断出现在顶部,“第 2 页”就会错位并出现重复。返回一个编码了位置的游标,例如最后一条已返回内容的 ID 或分数,并请求“这个游标之后的内容”。对于带排序的信息流,把本次会话的排序结果短暂保存一段时间,让后续页面与第一页保持一致。
架构图
图表内容
- User
- Post service
- Posts store
- Post events
- Fan-out workers
- Social graph
- Author over 1M followers?
- Feed cache
- Pulled at read time
- Feed service
- Ranking
- Media CDN
- POST /posts
- post.created
- followers
- no: push post ID
- yes
- GET /feed
- cached timeline
- big accounts
- images and video
这张图的读法如下:
- 写路径(上方): 帖子服务存储帖子并发布事件。扩散工作进程读取社交关系图,检查作者的关注者数量。
- 判断: 对大多数作者,工作进程把帖子 ID 推入每个关注者的信息流缓存。对于超过阈值的账号,不做任何推送;它们的帖子在读取时拉取。
- 读路径(下方): 信息流服务读取用户的缓存信息流,以及帖子存储中大号的近期帖子,合并后发送给排序服务。
- 媒体文件 走 CDN,从不经过信息流服务。
- Markdown 块 把经验法则放在设计旁边。
图上应该画什么
- 两条路径,清楚地分开。 写入和读取面临不同的扩展问题。
- 扩散阈值。 即使以后会变,也把数字写在判断节点上。
- 什么存在哪里。 信息流缓存存的是帖子 ID,而不是帖子本身;内容存在帖子存储中。
- 排序在哪里发生。 读者应该能看出它是在读取时运行的。
这个设计中间的缓存值得单独画一张图;请看 缓存策略一文。事件驱动架构一文 介绍了事件和消费者一侧,系统设计图示例 则在另一个系统上展示了同样的约定。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的一个 人员,再为帖子服务、扩散工作进程、信息流服务和排序服务添加 容器 节点。
- 添加 C4 模型节点包中的 数据库 节点,分别表示帖子存储和社交关系图;添加数据节点包中的 Kafka 和 Redis,分别用于帖子事件和信息流缓存;再添加云节点包中的一个 CDN。
- 为关注者阈值添加流程图节点包中的一个 判断 和一个 开始 / 结束。
- 把写路径画在上方、读路径画在下方,再添加一个基础节点包中的 Markdown 块,写上你的经验法则。
这个示例共用 13 个图表节点,在 Free 套餐的限制之内。
常见问题
写扩散和读扩散哪个更好? 单独用哪个都不够。写扩散读取快,但在粉丝极多的账号上会失控;读扩散写入便宜,但读取慢。大型信息流采用混合方案。
信息流缓存应该存什么? 帖子 ID,按从新到旧排列,每个用户最多几百条。帖子内容单独获取,这样编辑和删除就不需要重写每个信息流。
名人发帖怎么处理? 对关注者数超过阈值的账号跳过扩散,在关注者打开信息流时拉取它们的近期帖子。
信息流分页应该怎么做? 用基于最后一条已返回内容的游标,而不是页码或偏移量,这样用户滚动时新帖子不会造成重复。
排序在哪里进行? 在读取时,对合并后的候选列表进行。把它放在扩散之外,就能在不重建缓存信息流的情况下修改排序。