信息流系统设计:写扩散与读扩散

如何设计信息流:写扩散与读扩散、大型信息流普遍采用的混合方案、信息流缓存、排序、分页和架构图。

· 创始人兼开发者

发布于

信息流系统设计:写扩散与读扩散

首页信息流是任何社交产品中被读取最多的页面,也是构建成本最高的页面之一。每个用户关注数百个账号,每个账号按自己的节奏发帖,而每次有人打开应用,信息流都必须在几百毫秒内排好序返回。

核心的设计问题有两个经典答案:写扩散(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 或分数,并请求“这个游标之后的内容”。对于带排序的信息流,把本次会话的排序结果短暂保存一段时间,让后续页面与第一页保持一致。

架构图

帖子服务存储帖子并发布事件。扩散工作进程读取社交关系图,检查作者粉丝是否超过一百万。没超过就把帖子 ID 推入粉丝的信息流缓存;超过则在读取时拉取。信息流服务把两者合并后送去排序。

13 个组件 · 13 个连接

在 Archboard 中打开
图表内容
  • 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 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加 C4 模型节点包中的一个 人员,再为帖子服务、扩散工作进程、信息流服务和排序服务添加 容器 节点。
  3. 添加 C4 模型节点包中的 数据库 节点,分别表示帖子存储和社交关系图;添加数据节点包中的 Kafka 和 Redis,分别用于帖子事件和信息流缓存;再添加云节点包中的一个 CDN。
  4. 为关注者阈值添加流程图节点包中的一个 判断 和一个 开始 / 结束。
  5. 把写路径画在上方、读路径画在下方,再添加一个基础节点包中的 Markdown 块,写上你的经验法则。

这个示例共用 13 个图表节点,在 Free 套餐的限制之内。

常见问题

写扩散和读扩散哪个更好? 单独用哪个都不够。写扩散读取快,但在粉丝极多的账号上会失控;读扩散写入便宜,但读取慢。大型信息流采用混合方案。

信息流缓存应该存什么? 帖子 ID,按从新到旧排列,每个用户最多几百条。帖子内容单独获取,这样编辑和删除就不需要重写每个信息流。

名人发帖怎么处理? 对关注者数超过阈值的账号跳过扩散,在关注者打开信息流时拉取它们的近期帖子。

信息流分页应该怎么做? 用基于最后一条已返回内容的游标,而不是页码或偏移量,这样用户滚动时新帖子不会造成重复。

排序在哪里进行? 在读取时,对合并后的候选列表进行。把它放在扩散之外,就能在不重建缓存信息流的情况下修改排序。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。