我是如何构建一个分布式可横向扩展的 IM: Zhi

Updated at: 2025-04-28

前言

如果你还不了解 Zhi ,可以先看这一篇文章Zhi 作为一个 IM,至少必须要满足以下几点:

额外的 Zhi 提供零信任的端到端加密,这篇文章不会展开,请查看 这篇文章这篇文章

架构设计

如果是单机架构,那么一般是这样的:



但是随着用户的增加,单个 Node 终究会遇到瓶颈。那么就需要增加 Node,将用户平均分散在不同的 Node 上。客户端有重连机制,所以随时摘除一个也不影响功能。那么此时就会带来新的问题,用户 A 要发送消息给用户 B,但是如果他们两个不在同一个 Node 上,那么接收消息的 Node 就需要能够知道用户 B 在哪个 Node。这时我们引入一个 Memory DB 角色,用其存储每个用户的连接信息,当然 Memory DB 也应该是可横向扩展的高可用集群。



同时引入了一个消息路由角色 Route,这个在实践中是必要的,可以大幅降低 Node 的复杂度。即 Node 将消息转发给 Route,让 Route 再将消息转发给需要的 Node。当然 Route 也是可以横向扩展的高可用集群,当然也可以随时摘除一个并不影响功能



数据库作为最重要的一个角色,也是最容易出现瓶颈的地方,OLTP 和 OLAP 都有各自擅长的地方也有各自不擅长的之处。我们选择 OLTP。当然数据库也应该是可横向扩展的高可用集群。因为 OLTP 的吞吐量限制,所以我们要引入另外一个 Queue 角色,不仅仅是将数据库操作异步化,还可以进行很多其他的针对消息的异步处理。Queue 应该是支持高吞吐量的,可靠的,可横向扩展的,高可用集群。



整体架构

以下是 Zhi 的架构简要示意图,为了展示清晰,我简化了一些逻辑关系。

zhi.png

下面再说一下,Zhi 作为一个 IM 是如何做到一个 IM 必要的功能的。

消息的实时

所谓实施就是在一般网络环境下,消息的投递延迟应该控制在毫秒单位,所谓一般网络环境主要是消息在 Zhi 系统的路径。如果用户自己使用的是蜗牛般的网络,那么我们也无法帮助用户提升网速。所以我们能做的就是让消息在 Zhi 系统内部的路径里不能含有耗时的 IO 操作。

传输协议

用户与 Zhi 的网络传输协议,我们选择了 WebSocket,之所以不选择 TCP,是因为我们想将 Web 作为一等公民,并且 WebSocket 相对于 TCP 增加的 header 对延迟的影响也是基本可以忽略的。关于系统内部各个组件之间的通信,分享一点经验仅供参考,有时我们以为选用一些流行的 RPC 框架会让事情变得简单,实际上可能自己基于自己的具体场景直接在 TCP 或 UDP 上来搞可能更简单,也更容易掌控和保证可靠。

消息编码

用户与 Zhi 的消息,我们选择了 JSON,而不是二进制协议,之所以这么做,是因为我们想让用户可以轻松的审计 Zhi 的行为。具体可以查看 这篇文章这篇文章

消息的可靠

所谓消息的可靠就是

  1. 首先用户发出去一条消息,如果告知用户消息已发送,那么就是服务器确实已经收到,并且不会因为意外而丢失这条消息
  2. 其次服务器将消息投递给用户,如果用户没有明确告知服务器其已经收到消息了,那么服务器应该持续投递,直到认为用户已经为离线状态
  3. 针对离线的用户,其再次上线时,应该可以主动拉取到其离线期间未收到的消息

针对 1 和 2,我们应该有双向 ACK 机制。并且针对 2 我们要新增一个 Retry 角色,用来重试消息的的投递,直到认为用户已经离线。针对 3,我们应该在必要时根据一些指标和维度将消息分为冷数据和热数据,以保证用户拉取的性能。

消息的存储

首先用户发送和收到的消息都会存储到本地,以方便用户检索,因为服务端存储的只是加密后的消息。也有效的提升了消息列表展示的性能。仅当用户离线期间产生了消息,等用户再次上线后才需要从服务端拉取。

消息的有序

因为用户在发送消息时,消息在网络环境中传输的时间是未知的,也就是到达服务器的时间也是未知的。比如两个用户先后分别发送了一条消息,那么先发的不一定先到服务器。我们先说消息时间,等下再说消息顺序。

那么我们在给其他用户展示消息的发送时间时,应该展示用户实际发送消息的时间呢?还是应该展示消息达到服务器的时间呢?这个其实并没有绝对答案,有的 IM 会采用消息达到服务器的时间,但是我们选择用户实际发送消息的时间。因为 Zhi 作为零信任的 IM,我们认为消息的时间也是消息的一部分。那么如果一个 Chat 里,用户 A 伪造了时间,用户 B 看到的是个不实际的时间,怎么办?还是重复上面的观点,Zhi 作为零信任的 IM,用户的行为只与 Chat 内成员有关,Zhi 作为零信任的服务器不参与决策,当然如果 Chat 里某个成员有 Bot(Zhi 支持创建 Bot),那么可以删除伪造的信息,甚至可以将用户 A 踢出。

再就是消息顺序。这里涉及到一个消息 ID 生成的问题,首先消息 ID 必须是递增的,递增可以保证消息的有序性。还有就是消息 ID 是否是连续的,这决定了用户拉取消息时如何处理消息断层的问题。如果是连续的,那么客户端将很容易处理断层的问题。但是在分布式环境下,生成连续的 ID,就需要一个额外的 ID 生成器的角色,而且在接下来使用 ID 时应该有事务保证,在分布式环境下实现这个功能是相当复杂的,如下图:



如果在第 4 步失败了,问题将变得复杂,我们还需要通知 ID 生成器,让其恢复这种失败的情况。还有就是前面说了,消息时间我们采用的是用户端的时间,如果我们服务端采用了额外的一套与用户端时间无关的有序 ID,那么网络处理时间,以及各种 IO 操作,都不一定是串行的,在逻辑上就有可能和用户端期望的顺序产生不一致。

幸运的是 UUID 标准出了一个新标准 v7,UUID v7 可以作为分布式的带时间属性的有序 ID。但是这个 ID 不是连续的,那么在用户拉取消息时,如何处理消息断层的问题呢?尤其是我们将消息存储到了用户本地,那么什么时候应该从本地读取消息?以及什么时候应该从服务端拉取消息呢?虽然有一定的业务复杂度,但这个实际上客户端和服务端相结合是可以做到的。

这里有个原则,就是如果可以,尽量将复杂的工作放在前端来做,因为服务端的复杂度增速远比前端大。同时也可以将算力分散。

最后

如果你需要一个零信任的 IM,不妨试一下 Zhi 吧。当然你懂编程的话,还可以使用 Zhi 的 Bot,Bot 其实也是一个 Chat 成员,所以 Bot 发送接收消息也是加密的。