在所有系统设计问题中,聊天应用隐藏的状态最多。每个用户都与某一台特定服务器保持着实时连接,每条消息都必须恰好送达一次且保持顺序,而同一条消息可能只需送给一个人,也可能要送给一万人。一张只画了“客户端、服务器、数据库”的图,对这些问题一个也回答不了。
本文一次讲一个决策,逐步拆解聊天后端:客户端如何连接,一条消息如何在会话中获得自己的位置,如何送达所有应该看到它的人,以及如何存储。最后给出一张可以作为起点的架构图。
需求
- 一对一和群组会话,支持文字和媒体。
- 会话中的每个成员看到的消息顺序都相同。
- 客户端重试发送时不会产生重复消息。
- 在线用户在一秒内收到消息;离线用户收到推送通知,并在重新连接后补齐消息。
- 送达回执、已读回执,以及在线状态指示。
连接:为什么用 WebSocket,为什么要有网关
轮询新消息既浪费请求,又增加延迟。WebSocket 连接会一直保持打开,服务器可以在消息产生的那一刻就推送出去。每台设备与一个 聊天网关 保持一条连接。
网关是系统中有状态的部分。一个网关只知道哪些用户连在它上面,别的什么都不知道。让网关保持轻量:只在建立连接时认证一次,接收消息并交给无状态的服务,再把要发出的消息推到正确的 socket 上。部署新版本网关时,连接会断开,客户端会重新连接,所以客户端必须带退避和抖动地重连,并从最后看到的消息处继续。
在前面放一个四层负载均衡器,或者能理解 WebSocket 升级的负载均衡器,并预料到长连接会让负载不均衡。新连接应该优先分配到负载最低的网关。
发送消息:幂等与顺序
客户端在发送之前生成一个 client_msg_id(一个 UUID)。如果在收到确认之前连接断开,客户端会用同一个 ID 重新发送,消息服务丢弃重复的那条。没有它,不稳定的移动网络就会产生重复消息。
顺序是按会话定义的,而不是全局的。消息服务为每条消息分配其所在会话的下一个序列号,然后存储。客户端按这个序列号排序,绝不按自己的时钟排序。分配序列号有两种实用的方法:
- 每个会话一个计数器,在存储消息的同一次写入中递增。
- 可按时间排序的 ID,例如由服务器生成的 Snowflake 式 ID,前提是你能接受“顺序”就是“服务器看到的顺序”。
序列号也让补齐消息变得很便宜。重新连接的客户端说“会话 42 我已经有到 1871 了”,就能拿到之后的所有消息。
扇出:在线、离线与大群组
消息存储之后,服务发布一个事件。接下来由 扇出工作进程 接手:
- 查出会话的成员。
- 在 Redis 这样的在线状态和会话注册表中,查出哪些成员在线,以及他们的连接在哪个网关上。
- 把消息发给这些网关,由网关推送到对应的 socket。
- 通过 APNs 和 FCM 向离线成员发送推送通知。
消息只写一次、读取时再扇出,存储量就只与消息数成正比,而不是与成员数成正比。大群组需要特别小心:一个有 100,000 名成员的频道,不应该每条消息都触发 100,000 条推送通知。只通知主动开启通知的成员,或者合并后发送摘要。
在线状态
在线状态是一个带 TTL 的心跳。网关大约每 30 秒刷新一次“用户 7 连在网关 3 上”;键过期时,用户即为离线。不要把每一次在线状态变化实时广播给每个联系人。用户几乎察觉不到它的延迟,它却是聊天应用里最昂贵的功能之一。在打开会话时获取在线状态,再定期刷新即可。会过期的键和缓存条目的工作方式相同;缓存策略一文 讲了 TTL,以及它们同时过期时会发生什么。
回执与端到端加密
送达回执和已读回执也是消息。设备收到第 1871 条消息时,发送一个小小的“已送达至 1871”事件;用户打开会话时,发送“已读至 1871”。按成员和会话存储最大的序列号,而不是每条消息存一条回执,这样在活跃的群组里成本也能保持平稳。
端到端加密会改变服务器能看到的内容。使用 Signal 这样的协议时,服务器只存储和转发密文,所以搜索、内容审核和链接预览都要移到客户端。尽早做决定,因为它会同时改变存储和扇出。
存储
消息只写一次,读取的大多是近期消息,而且几乎总是按会话读取。这意味着应该使用按会话分区、按序列号排序的存储。例如,Discord 按频道加固定时间桶对消息分区,避免一个繁忙的频道变成一个巨大的分区;它在介绍 如何存储数万亿条消息 的文章中,讲述了把这一模型从 Cassandra 迁移到 ScyllaDB 的过程。
不要把媒体放进消息存储。客户端用预签名 URL 把图片和视频直接上传到对象存储,消息里只携带一个引用。消息服务从不处理这些字节。
架构图
图表内容
- 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(wss)连接到聊天网关。媒体上传直接进入存储桶。
- 中间: 网关把每次发送交给消息服务,服务按
client_msg_id丢弃重复消息,分配下一个序列号,并把消息追加到存储中。 - 右侧: 服务发布一个事件。扇出工作进程检查在线状态和会话,经正确的网关投递,并向离线成员发送推送通知。
- 从网关到在线状态的心跳 画成点线,因为它按定时器运行,而不是每条消息触发一次。
- Markdown 块 展示了消息信封,让大家对字段达成一致。
需要标注什么
- 哪些组件持有状态。 网关持有连接;消息服务什么都不持有。这决定了每个组件如何部署和扩展。
- 顺序规则。 “按会话的序列号,由服务器分配”应该写在图上。
- 去重键。 在发送路径上写明
client_msg_id。 - 在线和离线两条路径。 两条都要画;大多数 bug 都藏在第二条里。
事件驱动架构一文 更深入地讲解了事件和消费者一侧,而 时序图示例 很适合一步步记录发送与确认的交互过程。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 添加 C4 模型节点包中的一个 人员 表示用户,以及云节点包中的一个 云负载均衡器。
- 添加 C4 模型节点包中的三个 容器 节点:聊天网关、消息服务和扇出工作进程。在每个节点上注明它是否有状态。
- 添加 C4 模型节点包中的一个 数据库 作为消息存储,数据节点包中的 Redis 和 Kafka 用于在线状态和事件,云节点包中的一个 对象存储 节点用于媒体,再添加一个 外部系统 表示 APNs 和 FCM。
- 把它们连起来,把心跳和媒体上传画成虚线或点线连线,再添加一个基础节点包中的 Markdown 块,写明消息信封。
这个示例共用 11 个图表节点,在 Free 套餐的限制之内。
常见问题
聊天应用一定要用 WebSocket 吗? 要实时投递的话,需要;或者使用类似的推送通道,比如用 server-sent events 接收、用 HTTP 发送。长轮询在小应用里可行,但会消耗更多请求,并增加延迟。
如何保证消息顺序? 让服务器在存储消息时按会话分配序列号,客户端按它排序。客户端的时钟各不相同,不能用来排序。
如何避免重复消息? 客户端在发送前生成消息 ID,重试时复用同一个 ID。服务器保存这个 ID,忽略带有相同 ID 的第二条消息。
聊天消息应该存在哪种数据库里? 能按会话快速读取、按序列号排序的数据库,例如按会话和时间桶分区的宽列存储。对于较小的应用,在会话和序列号上建索引的 Postgres 就很好用。
消息如何送达离线用户? 它们和其他消息一样被存储。扇出步骤会发送推送通知;应用重新连接后,会请求自己已有的最后一个序列号之后的所有消息。