IM 消息的端到端传输过程

7 阅读13分钟

本篇主要介绍 IM 系统的消息存储模型,以及一条消息是如何实现端到端传输的。

1、业务背景

业内通用 IM 通信流程

首先通过下面一个流程图,来简要介绍下从登录到发送消息的一个过程。

IM系统为了保证消息必达以及发送可靠,消息数据是先落盘后推送的。

对于在线的接收方,会直接通过长链接推送。但推送并不是一个必达路径,只是为了保证“即时”,采用的一个更优的消息传递路径。

对于推送失败或者离线的接收方,会通过主动拉取的方式获得服务端所有未同步消息。

通过推拉结合的手段,保证消息发出后接收方能够看到。

业务定制的 IM 通信流程

客户端发送的消息其实不是直接到达IM系统的,首先需要建立长链接,再以RPC的形式将各种请求发送到 IM 服务,包括其中的鉴权逻辑(验证用户合token合法性)等也是在长链接服务中做的。

2、消息是如何存储的

首先举个例子,如:用户 A 与好友 B、C、D、E、群F之间互相发送消息,我们把一对一聊天和群聊都看成了一个聊天的信箱,不管谁给谁发送消息都往信箱内发送。

如右图,当用户 B、C、D、E 都给 A 发送消息时,那么就会往他们各自共同的信箱内发送消息,此时用户 A 如果想知道自己是否有新消息到达,必须去依次读取多个信箱,才能知道自己所有新消息的未读数。其实这就是读扩散的形式。

消息队列的设计

上述中提到的“信箱”的概念,在 IM 系统中的具体实现可对应为一个消息队列,系统通过消息队列,来管理消息的缓存与同步。

消息队列需要具备这样的特性:

  • 每条消息具备全局唯一的 seq_id 、单调递增  
  • 顺序性:队列中的消息按 seq_id 排序,新消息插入队尾,并且要保证新消息的 seq_id 比当前已存储的所有消息的 seq_id 都大,以此来保证消息的顺序性。
  • 定位能力:支持随机定位,可根据 seq_id 随机定位到消息队列中的某个位置,从这个位置开始正序或逆序的读取消息,也支持读取指定 seq_id 的某条消息。

这样的设计,能保证消息接收方在消息同步时,都从一个独立消息队列中同步消息,当同步完成时,在本地记录下最新同步到的消息的 seq_id,即最新的一个水位,作为下次消息同步的起始位置。 服务端不会保存各个端的同步状态,各个端均可以在任意时间点开始拉取消息。

读扩散

读扩散模式会在服务端以会话ID为索引为群的所有成员存储一份共同的消息列表,在会话中有人发送新的消息时,直接在该列表尾部增加一条记录,在拉取时,所有的成员均从该列表中拉取消息,即写一份,读多次。

对于接收端来说,需要读取所有会话的消息列表才能拿到所有消息,读被放大了,所以称为读扩散。

  • 优点
    • 节省存储空间,对于群消息友好。
    • 多设备登录支持较好
  • 缺点
    • 接收端需要读取多次来同步消息,增加了复杂性和系统负担,因为并不是每个会话都有新消息。

    • 消息可见性(千人千面)的处理较为复杂,增加额外的处理逻辑

写扩散

写扩散模式则会在服务端为每个用户维护一个信箱(消息列表),每当有新消息到来时,会在每个成员的信箱中写入一份数据,而拉取时,只需从自己的 信箱拉取即可。

在群聊场景下,写操作被更加的放大了,如果这个群拥有 N 个参与方,那每条消息都需要额外的写 N 次。即写多份,读一次。

  • 优点
    • 接收端消息同步逻辑简单,每个人有单独的信箱,方便实现消息差异化显示。
  • 缺点
    • 存储空间浪费较大

    • 对大群的支持力度差,如群规模较大时,消息处理性能较差

    • 多设备登录时,消息同步较复杂,可能导致同步耗时较长

IM 数据存储

  • 存储模型 (读+写)

IM 的产品特性:在产品功能上,如用户侧要支持消息永久存储、多端数据同步,并且群聊的使用场景非常多,流量扩散效应十分明显(尤其是大群)。

读写结合:IM系统优先采用的是读扩散的存储架构,一方面是为了节约存储资源,另一方面,当读扩散带来的性能损耗过大时,从读扩散的架构下再搭一层写扩散架构,用空间换取时间,读写扩散混合,来解决性能损耗问题。

  • 存储架构

当IM系统每日消息量到达几亿的级别(平均每条消息的平均存储空间200B左右),即便存储模型使用读扩散,存储资源的消耗还是比较快的,因此采用了多个存储介质来实现不同的存储策略,来节省存储资源。

三级存储:在消息存储方面分别使用了Redis、Mysql和HBase,三级存储逐级查询。

Redis和Mysql中存储经常被访问的热数据,并实现了一些缓存优化策略。

HBase中存储全量数据,一方面是作为Mysql的冷备,另一方面是作为归档数据源进行使用。

安全存储:为保证存储的安全性,落库的消息都需要进行加密。

3、消息端到端的具体流程

消息发送流程

消息发送的整体流程如下:

📢:由长链接服务负责客户端与IM 服务端的长连接通道,不处理 IM 业务逻辑,所以该图中未表示长链接服务实体。

下面介绍下设计消息发送过程的每个实体的处理流程:

  1. 发送端客户端 A
    1. 调用 IMSDK 的发送接口
    2. 注册消息回调,在消息状态变化时(如:草稿、发生成功、失败),重新获取消息列表并刷新
  2. 发送端 IMSDK
    1. 构造消息体,生成 client_seq_id(去重用),并存入本地消息列表(状态为未发送),然后回调给业务方通知消息创建成功
    2. 然后将消息发送给服务器,等到接收到服务器的响应后,通知客户端消息发送成功(或失败)
  3. IM Server
    1. 调用业务方服务器的策略接口,检查消息是否可发送
    2. 生成消息 seq_id,初始化消息时间,保存到三级存储中(Redis、MYSQL、HBASE)
    3. 将消息事件写入 kafka,以供会话服务、Push服务消费,然后通知客户端刷新
    4. 会话服务消费 kafka 事件,将会话信息保存到 Redis、MYSQL 中
    5. Push服务消费 kafka 事件,将消息实时下发给接收端,并将推送结果保存至 kafka 以供业务服务端消费
  4. 业务方 Server
    1. 实现消息策略接口,供 IM Server 在消息发送时检查各种发送条件
    2. 消费推送结果 kafka 数据,如果实时推送失败的话,进行第三方推送
  5. 接收端 IMSDK
    1. 接收到新消息时,首先会检查会话是否存在,若不存在,则尝试创建新会话,并通知客户端刷新会话列表
    2. 若消息为“可视消息”,则更新会话时间、lastMsg等,并触发会话排序,通知客户端刷新会话列表
    3. 检查消息 seq_id 的连续性,如不连续,则进行消息补洞逻辑
    4. 检查 client_seq_id 是否存在,若存在,则进行消息去重逻辑
    5. 检查当前消息是否已读,若未读,则对该会话的未读计数进行+1 操作,并通知客户端刷新会话列表
    6. 消息入库成功
  6. 接收端客户端 B
    1. 向 IMSDK 注册消息、会话刷新的回调,并刷新列表

群聊的特殊处理

发送群聊消息,在IM服务端处理时,会增加一些和群管理服务的交互:

  • 会先去群管理服务校验发消息的用户是否是有效的群成员、群是否有效等,若非有效的群成员,则返回消息发送失败。

  • 在处理会话更新和推送模块时,会去群管理服务获取所有有效的群成员,填充到消息的接受者列表里。

4、如何保证消息必达

在IM系统设计中,保证消息必达(不可丢失)是设计的核心目标,但用户的使用场景与网络环境复杂多样,因此需要设计一些机制来保证消息的完整无缺。

推拉结合

目前IM系统的实现使用了推拉结合的方式,对于新的消息,会实时推送给接收者,对接收者没有收到的消息,会在下次断线重连时进行拉取(如第一节的图,不再展开)。

  • 推送

用户A给用户B发送了一条消息msg,IM服务端在接收到该消息后,会将消息msg 实时下发给用户B。而此时用户B可能不在线,或者网络状况不佳,实时推送并不能保证目标用户可触达。

  • 拉取

对于没有推送成功的消息,在用户B下次联网时,会主动向服务器请求缺失的数据。

但无论是推送还是拉取,都无法保证消息一定能送达给客户端,因此需要一种检查确认机制来保障这一点。

消息 ID 生成器

IM服务端保证在绝大多数情况(非 100%)下所生成的消息ID是连续递增的,这样,IMSDK可通过收到的消息ID与本地缓存的上一条消息ID是否连续来判断本地消息列表是否是完整的。

例如,客户端新收到一条消息id=10,若本地保存的最新消息id=9,则可认为当前的消息列表是完整的,在收到新消息10前并没有消息丢失的情况。

反之,若本地保存的最新消息id<9,则可认为在收到新消息10之前,还有其他的消息存在,但并没有成功下发给客户端。

消息补洞

若IM客户端SDK发现本地消息数据不完整,则会触发补洞逻辑。

例如,IM客户端本地存在消息id=7,此时新收到一条消息id=10,根据消息ID连续性规则,可认为本地缺失[8,9]之间的消息。此时,会触发一个pullOld请求,尝试拉取该区间的消息列表。

消息占位

出于性能方面的考虑,IM服务端并不能保证所生成的消息ID100%是连续递增的,在某些异常情况下可能会导致消息ID发生跳变。

例如,在成功下发消息id=7给客户端后,此时,新的消息ID会发生跳变,假设会使用id=10来表示。

在这种情况下,如果简单的比较消息ID是否会连续,则会认为存在部分消息没有成功拉取到客户端,而触发消息补洞逻辑。但此时,由于服务端在 [8,9] 之间实际并无消息存在,则会返回空的消息列表。而IM客户端会继续认为本地消息不全而进行补洞,从而导致循环依赖。

对于上面的案例来说,客户端在消息补洞之后,IM服务端会下发一个占位消息PlaceHolder(8,9)表示8到9之间的消息ID并不存在,这样,IMSDK在收到该占位消息之后,可确认本地消息列表已经完整。

消息去重

client_seq_id

在弱网的场景下,存在同一条消息被多次发送的可能性,因此需避免接收端收到两条重复的消息。

在用户A发送消息时,IMSDK 为每一条消息分配了一个客户端消息ID,每条消息重复发送时(如网络断开重连,或者用户手动触发),该客户端消息ID不发生改变。

这样,在用户B接收到消息后,会对每个会话进行会话内的消息去重,若该会话内发送者ID与客户端消息ID均相同,则可认为是重复消息,对于这种情况,只保留最新一条即可,另外一条消息进行隐藏或使用占位消息替换。

5、断线重连机制

检测断线时机

  • 检测到系统网络断开事件,进入网络断开状态
  • 连接层发送心跳(KeepAlive,每60s一次),(10s内)没有收到服务端回包,进入网络断开(待确认)状态
  • 业务层调用发送接口,(10s内)没有收到服务端回包,进入网络断开(待确认)状态

断线的确认

若因接收服务器端回包超时进入网络断开(待确认)状态,则需进行断线的确认。

断线确认需向服务端发送Basic.Ping请求,若能成功接收到服务端回包(3s超时),则认为网络状态正常,否则则认为网络已经断开,进入重连逻辑

断线后的重连

  • 连接层检测到网络断开后,若此时操作系统网络状态正常,自动进入重连逻辑

(若当前无网,则重新联网后进入重连逻辑)

(若APP进入后台,则进入无网状态,不会尝试重连)

  • 若重连失败,则重新选取接入点IP重试一次(反复)

每个心跳周期内:重试间隔 = min(3*N,30),最大次数 = 20

当新心跳周期开始时,重置重试次数。 当网络连接状态发生变化时,重置重试次数

6、小结

  • 读扩散的消息存储模型,支持消息多端同步,以及消息的永久存储。
  • 通过 ID 生成器,来保证了消息 ID 自增且尽可能连续。
  • 通过消息补洞逻辑、消息占位逻辑、以及客户端 SDK 消息完整性校验,实现消息必达。