聊天应用架构图:WebSocket、扇出与消息存储

聊天应用架构图详解:WebSocket 网关、消息顺序、幂等发送、在线与离线用户的扇出、在线状态与存储。

· 创始人兼开发者

发布于

聊天应用架构图:WebSocket、扇出与消息存储

在所有系统设计问题中,聊天应用隐藏的状态最多。每个用户都与某一台特定服务器保持着实时连接,每条消息都必须恰好送达一次且保持顺序,而同一条消息可能只需送给一个人,也可能要送给一万人。一张只画了“客户端、服务器、数据库”的图,对这些问题一个也回答不了。

本文一次讲一个决策,逐步拆解聊天后端:客户端如何连接,一条消息如何在会话中获得自己的位置,如何送达所有应该看到它的人,以及如何存储。最后给出一张可以作为起点的架构图。

需求

  • 一对一和群组会话,支持文字和媒体。
  • 会话中的每个成员看到的消息顺序都相同。
  • 客户端重试发送时不会产生重复消息。
  • 在线用户在一秒内收到消息;离线用户收到推送通知,并在重新连接后补齐消息。
  • 送达回执、已读回执,以及在线状态指示。

连接:为什么用 WebSocket,为什么要有网关

轮询新消息既浪费请求,又增加延迟。WebSocket 连接会一直保持打开,服务器可以在消息产生的那一刻就推送出去。每台设备与一个 聊天网关 保持一条连接。

网关是系统中有状态的部分。一个网关只知道哪些用户连在它上面,别的什么都不知道。让网关保持轻量:只在建立连接时认证一次,接收消息并交给无状态的服务,再把要发出的消息推到正确的 socket 上。部署新版本网关时,连接会断开,客户端会重新连接,所以客户端必须带退避和抖动地重连,并从最后看到的消息处继续。

在前面放一个四层负载均衡器,或者能理解 WebSocket 升级的负载均衡器,并预料到长连接会让负载不均衡。新连接应该优先分配到负载最低的网关。

发送消息:幂等与顺序

客户端在发送之前生成一个 client_msg_id(一个 UUID)。如果在收到确认之前连接断开,客户端会用同一个 ID 重新发送,消息服务丢弃重复的那条。没有它,不稳定的移动网络就会产生重复消息。

顺序是按会话定义的,而不是全局的。消息服务为每条消息分配其所在会话的下一个序列号,然后存储。客户端按这个序列号排序,绝不按自己的时钟排序。分配序列号有两种实用的方法:

  • 每个会话一个计数器,在存储消息的同一次写入中递增。
  • 可按时间排序的 ID,例如由服务器生成的 Snowflake 式 ID,前提是你能接受“顺序”就是“服务器看到的顺序”。

序列号也让补齐消息变得很便宜。重新连接的客户端说“会话 42 我已经有到 1871 了”,就能拿到之后的所有消息。

扇出:在线、离线与大群组

消息存储之后,服务发布一个事件。接下来由 扇出工作进程 接手:

  1. 查出会话的成员。
  2. 在 Redis 这样的在线状态和会话注册表中,查出哪些成员在线,以及他们的连接在哪个网关上。
  3. 把消息发给这些网关,由网关推送到对应的 socket。
  4. 通过 APNs 和 FCM 向离线成员发送推送通知。

消息只写一次、读取时再扇出,存储量就只与消息数成正比,而不是与成员数成正比。大群组需要特别小心:一个有 100,000 名成员的频道,不应该每条消息都触发 100,000 条推送通知。只通知主动开启通知的成员,或者合并后发送摘要。

在线状态

在线状态是一个带 TTL 的心跳。网关大约每 30 秒刷新一次“用户 7 连在网关 3 上”;键过期时,用户即为离线。不要把每一次在线状态变化实时广播给每个联系人。用户几乎察觉不到它的延迟,它却是聊天应用里最昂贵的功能之一。在打开会话时获取在线状态,再定期刷新即可。会过期的键和缓存条目的工作方式相同;缓存策略一文 讲了 TTL,以及它们同时过期时会发生什么。

回执与端到端加密

送达回执和已读回执也是消息。设备收到第 1871 条消息时,发送一个小小的“已送达至 1871”事件;用户打开会话时,发送“已读至 1871”。按成员和会话存储最大的序列号,而不是每条消息存一条回执,这样在活跃的群组里成本也能保持平稳。

端到端加密会改变服务器能看到的内容。使用 Signal 这样的协议时,服务器只存储和转发密文,所以搜索、内容审核和链接预览都要移到客户端。尽早做决定,因为它会同时改变存储和扇出。

存储

消息只写一次,读取的大多是近期消息,而且几乎总是按会话读取。这意味着应该使用按会话分区、按序列号排序的存储。例如,Discord 按频道加固定时间桶对消息分区,避免一个繁忙的频道变成一个巨大的分区;它在介绍 如何存储数万亿条消息 的文章中,讲述了把这一模型从 Cassandra 迁移到 ScyllaDB 的过程。

不要把媒体放进消息存储。客户端用预签名 URL 把图片和视频直接上传到对象存储,消息里只携带一个引用。消息服务从不处理这些字节。

架构图

用户经负载均衡器,通过 WebSocket 连接到聊天网关。网关把消息交给消息服务,消息服务存储消息并发布事件。扇出工作进程在 Redis 中查询在线状态,经网关投递消息,并向离线成员发送推送。媒体文件上传到存储桶。

11 个组件 · 11 个连接

在 Archboard 中打开
图表内容
  • Users: mobile and web
  • Load balancer
  • Chat gateway
  • Message service
  • Message store
  • Message events
  • Fan-out workers
  • Presence and sessions
  • APNs and FCM
  • Media bucket
  • wss
  • send
  • append, next seq
  • event
  • who is online, which gateway
  • deliver
  • offline
  • heartbeat
  • pre-signed upload
一个由有状态的 WebSocket 网关和无状态的消息服务组成的聊天后端。每条消息获得一个按会话分配的序列号,只存储一次,再通过网关送达在线成员,通过推送送达离线成员。

这张图的读法如下:

  • 左侧: 用户经负载均衡器,通过 WebSocket(wss)连接到聊天网关。媒体上传直接进入存储桶。
  • 中间: 网关把每次发送交给消息服务,服务按 client_msg_id 丢弃重复消息,分配下一个序列号,并把消息追加到存储中。
  • 右侧: 服务发布一个事件。扇出工作进程检查在线状态和会话,经正确的网关投递,并向离线成员发送推送通知。
  • 从网关到在线状态的心跳 画成点线,因为它按定时器运行,而不是每条消息触发一次。
  • Markdown 块 展示了消息信封,让大家对字段达成一致。

需要标注什么

  • 哪些组件持有状态。 网关持有连接;消息服务什么都不持有。这决定了每个组件如何部署和扩展。
  • 顺序规则。 “按会话的序列号,由服务器分配”应该写在图上。
  • 去重键。 在发送路径上写明 client_msg_id。
  • 在线和离线两条路径。 两条都要画;大多数 bug 都藏在第二条里。

事件驱动架构一文 更深入地讲解了事件和消费者一侧,而 时序图示例 很适合一步步记录发送与确认的交互过程。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 添加 C4 模型节点包中的一个 人员 表示用户,以及云节点包中的一个 云负载均衡器。
  3. 添加 C4 模型节点包中的三个 容器 节点:聊天网关、消息服务和扇出工作进程。在每个节点上注明它是否有状态。
  4. 添加 C4 模型节点包中的一个 数据库 作为消息存储,数据节点包中的 Redis 和 Kafka 用于在线状态和事件,云节点包中的一个 对象存储 节点用于媒体,再添加一个 外部系统 表示 APNs 和 FCM。
  5. 把它们连起来,把心跳和媒体上传画成虚线或点线连线,再添加一个基础节点包中的 Markdown 块,写明消息信封。

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

常见问题

聊天应用一定要用 WebSocket 吗? 要实时投递的话,需要;或者使用类似的推送通道,比如用 server-sent events 接收、用 HTTP 发送。长轮询在小应用里可行,但会消耗更多请求,并增加延迟。

如何保证消息顺序? 让服务器在存储消息时按会话分配序列号,客户端按它排序。客户端的时钟各不相同,不能用来排序。

如何避免重复消息? 客户端在发送前生成消息 ID,重试时复用同一个 ID。服务器保存这个 ID,忽略带有相同 ID 的第二条消息。

聊天消息应该存在哪种数据库里? 能按会话快速读取、按序列号排序的数据库,例如按会话和时间桶分区的宽列存储。对于较小的应用,在会话和序列号上建索引的 Postgres 就很好用。

消息如何送达离线用户? 它们和其他消息一样被存储。扇出步骤会发送推送通知;应用重新连接后,会请求自己已有的最后一个序列号之后的所有消息。

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

相关文章

← 返回博客

一分钟打开画布

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