文章目录

 

1、IM消息送达保证机制

1.1 IM消息送达保证机制实现(一):保证在线实时消息的可靠投递

  • 消息的可靠性,即消息的不丢失不重复
  • IM 的报文:
    1、请求报文(request,后简称为为R)
    2、应答报文(acknowledge,后简称为A)
    3、通知报文(notify,后简称为N)
  • 应用层确认+im消息可靠投递的六个报文(一条消息的发送,分别包含(上)(下)两个半场,即msg的R/A/N三个报文,ack的R/A/N三个报文)

1.2 IM消息送达保证机制实现(二):保证离线消息的可靠投递

  • 消息接收方不在线时的典型消息发送流程
    整理一下最近看的IM开发资料_消息存储
    注:对于消息发送方而言,消息一旦落地存储至DB就认为是发送成功了)
    整理一下最近看的IM开发资料_消息存储_02
    Step 1:用户B开始拉取用户A发送给ta的离线消息;
    Step 2:服务器从DB(或对应的持久化容器)中拉取离线消息;
    Step 3:服务器从DB(或对应的持久化容器)中把离线消息删除;
    Step 4:服务器返回给用户B想要的离线消息。

  • 一次性拉取所有好友发送给用户B的离线消息,到客户端本地再根据sender_uid进行计算,这样的话,离校消息表的访问模式就变为->只需要按照receiver_uid来查询了。登录时与服务器的交互次数降低为了1次。
    整理一下最近看的IM开发资料_消息存储_03

  • 可以分页拉取:根据业务需求,先拉取最新(或者最旧)的一页消息,再按需一页页拉取,这样便能很好地解决用户体验问题。整理一下最近看的IM开发资料_消息存储_04

  • 进一步优化,解决重复拉取离线消息的问题。在业务层面,可以根据msg_id去重。SMC理论:系统层面无法做到消息不丢不重,业务层面可以做到,对用户无感知。
    整理一下最近看的IM开发资料_服务器_05

  • 进一步优化,降低离线拉取ACK带来的额外与服务器的交互次数。
    在拉取第二页消息时相当于第一页消息的ACK,此时服务器再删除第一页的离线消息即可整理一下最近看的IM开发资料_服务器_06
    总结:

  • 1)对于同一个用户B,一次性拉取所有用户发给ta的离线消息,再在客户端本地进行发送方分析,相比按照发送方一个个进行消息拉取,能大大减少服务器交互次数;

  • 2)分页拉取,先拉取计数再按需拉取,是无线端的常见优化;

  • 3)应用层的ACK,应用层的去重,才能保证离线消息的不丢不重;

  • 4)下一页的拉取,同时作为上一页的ACK,能够极大减少与服务器的交互次数。

1.3 IM群聊消息如此复杂,如何保证不丢不重?

在线的群友能第一时间收到消息;
离线的群友能在登陆后收到消息。

  • 典型群消息投递流程
    整理一下最近看的IM开发资料_消息存储_07
    步骤1:群消息发送者x向server发出群消息;
    步骤2:server去db中查询群中有多少用户(x,A,B,C,D);
    步骤3:server去cache中查询这些用户的在线状态;
    步骤4:对于群中在线的用户A与B,群消息server进行实时推送;
    步骤5:对于群中离线的用户C与D,群消息server进行离线存储。
    整理一下最近看的IM开发资料_群消息_08
    步骤1:离线消息拉取者C向server拉取群离线消息;
    步骤2:server从db中拉取离线消息并返回群用户C;
    步骤3:server从db中删除群用户C的群离线消息。
    对于同一份群消息的内容,多个离线用户存储了很多份。
  • 群消息优化1:减少存储量
    整理一下最近看的IM开发资料_离线_09
    离线消息表只存储用户的群离线消息msg_id
    整理一下最近看的IM开发资料_离线_10
    存在的问题(如同单对单消息的发送一样):
    1)在线消息的投递可能出现消息丢失,例如服务器重启,路由器丢包,客户端crash;
    2)离线消息的拉取也可能出现消息丢失,原因同上。
  • 群消息优化2:应用层ACK
    整理一下最近看的IM开发资料_im_11
    整理一下最近看的IM开发资料_服务器_12
    存在的问题:
    1)如果拉取了消息,却没来得及应用层ACK,会收到重复的消息么?
    答案是肯定的,不过可以在客户端去重,对于重复的msg_id,对用户不展现,从而不影响用户体验
    2)对于离线的每一条消息,虽然只存储了msg_id,但是每个用户的每一条离线消息都将在数据库中保存一条记录,有没有办法减少离线消息的记录数呢?
  • 群消息优化3:离线消息表
    只需要存储最近一条拉取到的离线消息的time(或者msg_id),下次登录时拉取在那之后的所有群消息即可,而完全没有必要存储每个人未拉取到的离线消息msg_id。
    整理一下最近看的IM开发资料_消息存储_13
    整理一下最近看的IM开发资料_离线_14
    存在的问题:
    由于“消息风暴扩散系数”的存在,假设1个群有500个用户,“每条”群消息都会变为500个应用层ACK,将对服务器造成巨大的冲击,有没有办法减少ACK请求量呢?
  • 群消息优化4:批量ACK
    为了减少ACK请求量,很容易想到的方法是批量ACK。
    批量ACK的方式又有两种:
    1)每收到N条群消息ACK一次,这样请求量就降低为原来的1/N了;
    2)每隔时间间隔T进行一次群消息ACK,也能达到类似的效果。
    新的问题:批量ACK有可能导致:还没有来得及ACK群消息,用户就退出了,这样下次登录会拉取到重复的离线消息。
    解决方案:msg_id去重,不对用户展现,保证良好的用户体验。
    还可能存在的问题:群离线消息过多:拉取过慢。
    解决方案:分页拉取(按需拉取),分页拉取的细节在《IM消息送达保证机制实现(下篇):保证离线消息的可靠投递》一章中有详细叙述,此处不再展开。
  • 总结:
    1)不管是群在线消息,还是群离线消息,应用层的ACK是可达性的保障;
    2)群消息只存一份,不用为每个用户存储离线群msg_id,只需存储一个最近ack的群消息id/time;
    3)为了减少消息风暴,可以批量ACK;
    4)如果收到重复消息,需要msg_id去重,让用户无感知;
    5)离线消息过多,可以分页拉取(按需拉取)优化。

2、IM群聊消息存储方案演化过程

IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)?

2.1 最基本的方案:“在线的群友不存储消息,离线的群友才存储”

整理一下最近看的IM开发资料_im_15
“在线的群友不存储,离线的群友才存储”会带来的问题是,如果第四步发生异常,群友会丢失消息。

2.2 优化的方案:“不管群员是否在线,都要先存储消息”

整理一下最近看的IM开发资料_离线_16
整理一下最近看的IM开发资料_群消息_17
逻辑删除,还是物理删除,根据业务是否有消息漫游决定。

整理一下最近看的IM开发资料_群消息_18

2.3 “不管群员是否在线,都冗余一份群消息”带来的问题

  • 同一条消息存储了很多次,对磁盘和带宽造成了很大的浪费。
  • 群消息实体存储一份,用户只冗余消息ID。

    整理一下最近看的IM开发资料_服务器_19

2.4 终级方案:利用群消息的“偏序”特性优雅地实现“只存1份”

  • 群消息具备“偏序”特性
  • 每个用户只需要记录“最近一次收到的消息ID”,而不用记录“所有未收到的消息ID集合”,每当收在线消息ack,以及拉离线消息ack时,只需要更新这个“最近一次收到的消息ID”即可。

整理一下最近看的IM开发资料_离线_20
整理一下最近看的IM开发资料_im_21
整理一下最近看的IM开发资料_服务器_22

2.5 总结

方案1:群聊消息存多份,只存在线,消息容易丢;
方案2:群聊消息存多份,所有群友都存储,消息冗余多;
方案3:群聊消息存多份,只存ID,未利用偏序;
终极方案:群聊消息存一份,只存last_ack_msgid。

3、IM的同步存储

现代IM系统中聊天消息的同步和存储方案探讨

3.1 Timeline 模型

IM系统中最核心的部分是消息系统,消息系统中最核心的功能是消息的同步和存储:

  • 1)消息的同步:将消息完整的、快速的从发送方传递到接收方,就是消息的同步。消息同步系统最重要的衡量指标就是消息传递的实时性、完整性以及能支撑的消息规模。从功能上来说,一般至少要支持在线和离线推送,高级的IM系统还支持『多端同步』;
  • 2)消息的存储:消息存储即消息的持久化保存,这里不是指消息在客户端本地的保存,而是指云端的保存,功能上对应的就是『消息漫游』。『消息漫游』的好处是可以实现账号在任意端登陆查看所有历史消息,这也是高级IM系统特有的功能之一。

整理一下最近看的IM开发资料_群消息_23
Timeline可以简单理解为是一个消息队列,但这个消息队列有如下特性:

  • 每个消息拥有一个顺序ID(SeqId),在队列后面的消息的SeqId一定比前面的消息的SeqId大,也就是保证SeqId一定是增长的,但是不要求严格递增;
  • 新的消息永远在尾部添加,保证新的消息的SeqId永远比已经存在队列中的消息都大;
  • 可根据SeqId随机定位到具体的某条消息进行读取,也可以任意读取某个给定范围内的所有消息。

3.2 消息存储模型

整理一下最近看的IM开发资料_群消息_24
A与B/C/D/E/F均发生了会话,每个会话对应一个独立的Timeline,每个Timeline内存有这个会话中的所有消息,服务端会对每个Timeline进行持久化。服务端能够对所有会话Timeline中的全量消息进行持久化,也就拥有了消息漫游的能力。

3.3 消息同步模型

整理一下最近看的IM开发资料_离线_25
按图中的示例,A作为消息接收者,其与B/C/D/E/F发生了会话,每个会话中的新的消息都需要同步到A的某个端,看下读扩散和写扩散两种模式下消息如何做同步。

读扩散

  • 消息存储模型中,每个会话的Timeline中保存了这个会话的全量消息。读扩散的消息同步模式下,每个会话中产生的新的消息,只需要写一次到其用于存储的Timeline中,接收端从这个Timeline中拉取新的消息。

  • 优点是消息只需要写一次,相比写扩散的模式,能够大大降低消息写入次数,特别是在群消息这种场景下。但其缺点也比较明显,接收端去同步消息的逻辑会相对复杂和低效。接收端需要对每个会话都拉取一次才能获取全部消息,读被大大的放大,并且会产生很多无效的读,因为并不是每个会话都会有新消息产生。

写扩散

  • 写扩散的消息同步模式,需要有一个额外的Timeline来专门用于消息同步,通常是每个接收端都会拥有一个独立的同步Timeline,用于存放需要向这个接收端同步的所有消息。
  • 每个会话中的消息,会产生多次写,除了写入用于消息存储的会话Timeline,还需要写入需要同步到的接收端的同步Timeline。在个人与个人的会话中,消息会被额外写两次,除了写入这个会话的存储Timeline,还需要写入参与这个会话的两个接收者的同步Timeline。而在群这个场景下,写入会被更加的放大,如果这个群拥有N个参与者,那每条消息都需要额外的写N次。
  • 写扩散同步模式的优点是,在接收端消息同步逻辑会非常简单,只需要从其同步Timeline中读取一次即可,大大降低了消息同步所需的读的压力。其缺点就是消息写入会被放大,特别是针对群这种场景。

在IM这种应用场景下,通常会选择写扩散这种消息同步模式。

3.4 典型设计

整理一下最近看的IM开发资料_服务器_26
该典型的消息系统架构中包含几个重要组件:

  • 1)端:作为消息的发送和接收端,通过连接消息服务器来发送和接收消息。
  • 2)消息服务器:一组无状态的服务器,可水平扩展,处理消息的发送和接收请求,连接后端消息系统。
  • 3)消息队列:新写入消息的缓冲队列,消息系统的前置消息存储,用于削峰填谷以及异步消费。
  • 4)消息处理:一组无状态的消费处理服务器,用于异步消费消息队列中的消息数据,处理消息的持久化和写扩散同步。
  • 5)消息存储和索引库:持久化存储消息,每个会话对应一个 Timeline 进行消息存储,存储的消息建立索引来实现消息检索。
  • 6)消息同步库:
  • 写扩散形式同步消息,每个用户的收件箱对应一个 Timeline,同步库内消息不需要永久保存,通常对消息设定一个生命周期。
  • 新消息会由端发出,通常消息体中会携带消息 ID(用于去重)、逻辑时间戳(用于排序)、消息类型(控制消息、图片消息或者文本消息等)、消息体等内容。
  • 消息会先写入消息队列,作为底层存储的一个临时缓冲区。消息队列中的消息会由消息处理服务器消费,可以允许乱序消费。消息处理服务器对消息先存储后同步,先写入发件箱 Timeline(存储库),后写扩散至各个接收端的收件箱(同步库)。
  • 消息数据写入存储库后,会被近实时的构建索引,索引包括文本消息的全文索引以及多字段索引(发送方、消息类型等)。

对于在线的设备,可以由消息服务器主动推送至在线设备端。对于离线设备,登录后会主动向服务端同步消息。每个设备会在本地保留有最新一条消息的顺序 ID,向服务端同步该顺序 ID 后的所有消息。

3.5 消息库设计

整理一下最近看的IM开发资料_服务器_27
消息同步库:

  • 消息同步库用于存储所有用于消息同步的Timeline,每个Timeline对应一个接收端,主要用作写扩散模式的消息同步
  • 这个库不需要永久保留所有需要同步的消息,因为消息在同步到所有端后其生命周期就可以结束,就可以被回收。但是如前面所介绍的,一个实现简单的多端同步消息系统,在服务端不会保存有所有端的同步状态,而是依赖端自己主动来做同步。
  • 所以服务端不知道消息何时可以回收,通常的做法是为这个库里的消息设定一个固定的生命周期,例如一周或者一个月,生命周期结束可被淘汰。

消息存储库:

  • 消息存储库用于存储所有会话的Timeline,每个Timeline包含了一个会话中的所有消息。这个库主要用于消息漫游时拉取某个会话的所有历史消息,也用于读扩散模式的消息同步