分布式IM聊天系统学习

搜集到了关于现代即时通讯(IM)系统中一些设计方案,作为分布式集群应用的集大成者,这里进行总结学习。这些资料基本来自52im.net。在此感谢站长和作者们。

零基础入门

IM其实并不局限于聊天、社交这类“典型”应用中,实际上它已经广泛运用于我们身边形形色色的软件中。

聊天、直播、在线客服、物联网等所有需要实时互动、高实时性的场景等等,都需要应用到 IM 技术。

  • 1)微信、qq、钉钉等主流IM应用:这是IM技术的典型应用场景;
  • 2)微博、知乎等社区应用:它们利用IM技术实现了用户私信等点对点聊天;
  • 3)抖音、快手等直播/短视频应用:它们利用IM技术实现了与主播的实时互动;
  • 4)米家等智能家居物联网应用:利用IM技术实现实时控制、远程监控等;
  • 5)滴滴、Uber等共享家通类应用:利用IM技术实现位置共享;
  • 6)在线教育类应用:利用IM技术实现在线白板。

image-20260217005547159

即时消息技术剖析与实战

IM技术特点

1)实时性:

对于IM系统,“实时”二字是精髓,也是这项技术存在关键意义所在。它保证的是消息的实时触达。

举个例子:如果跟你的好友微信或qq聊天,我发的消息他不能即时收到,或者他发的信息你也不知道什么时候能收到,这基本上也就没法聊下去了

2)可靠性:

保证消息的不丢失和不重复,是IM系统的另一个关键技术特点。试想,当你在用qq或微信跟女朋友聊天,好不容易鼓起勇气向“她”表白,结果这消息要是丢包了,那肯定得卸载应用了,搞不好砸手机都有可能。当然,好话不说二遍,消息重复也同样恼人。

3)一致性:

对于单聊消息而言,保证同一个设备的时间顺序、不同设备的漫游同步,也是相当重要的一环。

IM系统中的消息交互,就到底就是人跟人在“说话”,前言不搭理后言、或者胡言乱语式的消息展现,那不是人疯了就是程序疯了,总之就是没法再聊下去了。

4)安全性:

保证数据传输安全、数据存储安全、消息内容安全,也是IM系统必不可少的特性。尤其在私聊场景下,如果不能做到安全性,聊天的体验跟被人偷窥的感觉是没有区别的。

IM功能组成

一个典型的IM功能组成,无非就是以下5样:

  • 1)联系人列表;
  • 2)聊天界面;
  • 3)消息发送通道;
  • 4)消息接收通道;
  • 5)消息存储;
  • 6)消息未读数。

联系人列表看似简单,实际上它是一系列IM系统的社交关系确立动作的结果体现。

要想建立联系人列表,你可能需要实现以下逻辑:

  • 1)怎么能找到想要聊天的人?(需要实现随机查找?精确查找?)
  • 2)怎么决定要不要跟这个人聊?(需要实现对方的个人信息查看)
  • 3)开始发出好友请求;
  • 4)被请求的一方,还可以决定是“同意”还是“拒绝”(“同意”该怎么处理?“拒绝”又该怎么处理?)。

聊天界面看似很平常,实际它就是IM系统客户端的核心功能所在,所有主要的IM功能都是通过它展现。

它应该具备的能力有:

  • 1)各种聊天功能按钮:语音留言、图片、文字、表情、文件、实时电话、实时视频等;
  • 2)各种聊天消息显示:各种消息都有不同的UI显示元素和处理逻辑;
  • 3)流畅的使用体验:大量不同类型的消息显示时,不能卡顿;
  • 4)即时显示聊天消息:网络线程收到的消息,要马上在UI上显示出来;
  • 5)历史消息的加载:上次聊过的内容也得显示出来吧。

消息发送通道

  • 1)如何保证这条socket长连接时一直处于可用的状态?
  • 2)当socket长连接不可用时,用户此时发送的消息该怎么处理?
  • 3)怎么保证发送的消息不丢?
  • 4)怎么保证发送的消息不重复?
  • 5)怎么保证发送的消息乱序?
  • 6)当对方不在线时,发送的消息去哪了?
  • 7)发送的消息,能保证实时送到?

消息接收通道:

要实现一个可靠的消息接收通道,也并非易事:

  • 1)如何保证socket长连接通道能随时处于良好的连接状态(随时接收对方write的消息);
  • 2)当socket长连接断开时,对方发送消息该怎么实现?
  • 3)当socket恢复连接时,怎么恢复之前的聊天现场?
  • 4)当我收到对方的消息时,对方怎么知道我已经收到了?
  • 5)当重复收到对方的消息时,该怎么处理?
  • 6)当收到的消息时序有错乱,该怎么处理?

    5)消息存储:\

消息存储这个功能好理解,聊天的消息如果存储,下次再聊的时候就不知道之前聊过什么,做不到这一点,这个IM系统的聊天体验好不起来。

那么,哪些情况下需要进行消息存储呢:

  • 1)对方不在线时:聊天消息应该存储(这叫离线消息存储);
  • 2)对方在线时:聊天消息也要存到本地存储(这叫消息缓存);
  • 3)对方在线或不在线时:聊天消息都要存到服务端(用于实现多设备的消息漫游和同步)。

但技术落到实处,要做的事情同样少不了:

  • 1)离线消息该怎么多久?
  • 2)图片、短视频、大文件这类的离线消息,多媒体文件该怎么存(有可能量会很大)?
  • 3)当本地的消息积累太多时,怎么能保证本地存储的性能?
  • 4)当应用更新、升级或异常时,怎么能保证本地存储的完整性(不被破坏)?
  • 5)怎么能保证多设备消息能不丢、不重、不乱?

消息未读数

1)未读数是客户端实现还是服务端实现?

2)会话未读和总未读怎么保持一致?

3)多终端情况下,怎么保证未读数的一致性(我在这台设备上读没读,那台设备怎么知道的?

IM聊天的实时性

短轮询技术

在“短轮询”模式下,IM客户端定时轮询服务端,以便让用户知道是否有新的聊天消息存在。

这种模式下,服务端收到请求后,即刻查询是否存在新消息,有就返回给客户端,没有则返回空并立即关闭连接。相较于前面用户需要“手动”去刷新页面的方式,这种模式只是将用户的“手动”变为“自动”而已,技术本质并没有发生任何实质性改变。

短轮询这种模式,就好比旧时代一个等待重要邮件的人,他需要每天自已跑到邮局,主动去问是否有自己的信件,有就拿回家,如果没有,则第二天继续去问。一来一去,非常低效。

长轮询技术

技术上来说,长轮询实现的IM相较于短轮询最大的改进在于:短轮询情况下,服务端不管有没有新消息,请求结束就会立即断开连接。而长轮询时,如果本次请求没有新消息发生,不会马上断开连接并返回,而是会将本次连接“挂起”一段时间,如果在这段“挂起”时间内有新的聊天消息出现,就能马上读取并立即返回给客户端,接着结束本次连接。一段时间后又会再次发起请求,如此周而复始。

长轮询这种模式,拿上节等待邮件的这个例子来说,就好比收信的人每天到邮局去问是否有信件,如果没有,他不马上回家,而是在邮局待上一段时间,如果这段时间过去了,还是没有,就先回家,接着第二天再来。

长轮询的优点是:

  • 1)相较于短连询,一定程度降低了服务端请求负载;
  • 2)相较于短连询,实时性有提升,因为它是主动“等”消息。

长轮询的缺点是:

  • 1)长论询模式下,连接“挂起”的这段时间内,服务端需要配合开启单独的消息查询线程,仍然存在无用功;
  • 2)相较于短连询模式,在一次长轮询结束、下次轮询发起前的窗口期内,仍然存在“实时性”盲区。

实际上,在Web端即时通讯技术里,长轮询有个专业的术语叫“Comet”,

在每次轮询结束和下次轮询开始的间隔期会形成“实时性盲区”。要理解纠结轮询技术的实时性缺陷,就得了解它们背后的技术——HTTP协议了。HTTP协议设计的目的,就是为了实现“请求—响应”这种模式的数据交互,也就是众所周之的“短连接”设计。而无论是短轮询还是长轮询,都跳不出HTTP的先天技术逻辑。

所以,归根到底,想要基于HTTP协议来实现IM,要达到真正的“实时性”,是相当勉强的。因为HTTP设计的目的,就是用“短连接”来简化传统TCP长连接通信带来的复杂性,而IM的实时性恰好要用到的又是TCP的长连接特性,所以这就是个悖论。

WebSocket是真正的全双式双向通信技术。

  • 1)轮询技术一问一答,在下一个请求发起之前,存在“实时性”盲区;
  • 2)WebSocket一旦建立连接后,数据可以随时双向通信(即客户端可以随时向服务端发消息,服务端也可以随时通知客户端有新消息)。

  • 1)真正的实时性:支持客户端与服务端真正的双向实时通信;

  • 2)大幅降低负载:少了轮询技术中高频率无用的请求,可大大降低服务端QPS压力;
  • 3)网络开销降低:一次连接,随时使用,再也不用轮询技术中每次发起HTTP请求(随之而来的是每次HTTP的大量冗余协议头信息等)。

IM聊天系统的可靠性

M系统的消息“可靠性”,通常就是指聊天消息投递的可靠性(准确的说,这个“消息”是广义的,因为还存用户看不见的各种指令,为了通俗,统称“消息”)。

从用户行为来讲,消息“可靠性”应该分为两种类型:

  • 1)在线消息的可靠性:即发送消息时,接收方当前处于“在线”状态;
  • 2)离线消息的可靠性:即发送消息时,接收方当前处于“离线”状态。

从具体的技术表现来讲,消息“可靠性”包含两层含义:

  • 1)消息不丢:这很直白,发出去的消息不能像进了黑洞一样;
  • 2)消息不重:这是丢消息的反面,消息重复了也不能容忍。

对于“消息不丢”这个特征来说,细化下来,它又包含两重含义:

  • 1)已明确被对方收到;
  • 2)已明确未被对方收到。

典型的IM消息收发流程是 一条消息从客户端A发出后,需要先经过 IM 服务器来进行中转,然后再由 IM 服务器推送给客户端B,这种模式也是目前最常见的 IM 系统的消息分发架构。

image-20260217153718305

P2P模式在IM系统中用的很少。

原因是以下两个很明显的弊端:

  • 1)P2P模式下,IM运营者很容易被用户架空
  • 2)P2P模式下,群聊这种业务形态,很难实现

TCP并不能保证在线消息的“可靠性”

这次我们从客户端角度来理解,为什么使用了可靠传输协议TCP的情况下IM聊天消息仍然不可靠的问题。

数据可靠抵达网络层之后,还需要一层层往上移交处理,可能的处理有:

  • 1)安全性校验;
  • 2)binary 解析;
  • 3)model 创建;
  • 4)写 db;
  • 5)存入 cache;
  • 6)UI 展示;
  • 7)以及一些边界问题:比如断网、用户突然退出登陆、磁盘已满、内存溢出、app奔溃、突然关机等等。

例如,消息可靠抵达网络层之后,写 db 之前 IM APP 崩溃(不稀奇,是 App 都有崩溃的可能),虽然数据在网络层可靠抵达了,但没存进 db,下次用户打开 App 消息自然就丢失了,如果不在业务层再增加可靠性保障(比如:后面要提到的网络层面的消息重发保障),那么意味着这条消息对于接收端来说就永远丢失了,也就自然不存在“可靠性”了。

为在线消息增加可靠性保障

那么怎样在应用层增加可靠性保障呢?有一个现成的机制可供我们借鉴:TCP协议的超时、重传、确认机制。

具体来说就是:

  • 1)在应用层构造一种ACK消息,当接收方正确处理完消息后,向发送方发送ACK;
  • 2)假如发送方在超时时间内没有收到ACK,则认为消息发送失败,需要进行重传或其他处理。

可以把整个过程分为两个阶段。

阶段1:clientA -> server\

  • 1-1:clientA向server发送消息(msg-Req);
  • 1-2:server收取消息,回复ACK(msg-Ack)给clientA;
  • 1-3:一旦clientA收到ACK即可认为消息已成功投递,第一阶段结束。

无论msg-A或ack-A丢失,clientA均无法在超时时间内收到ACK,此时可以提示用户发送失败,手动进行重发

阶段2:server -> clientB\

  • 2-1:server向clientB发送消息(Notify-Req);
  • 2-2:clientB收取消息,回复ACK(Notify-ACk)给server;
  • 2-3:server收到ACK之后将该消息标记为已发送,第二阶段结束。

无论msg-B或ack-B丢失,server均无法在超时时间内收到ACK,此时需要重发msg-B,直到clientB返回ACK为止

离线消息收发流程

离线消息收发流程也可划分为两个阶段:

阶段1:clientA -> server\

  • 1-1:clientA向server发送消息(msg-Req) ;
  • 1-2:server发现clientB离线,将消息存入offline-DB。

阶段2:server -> clientB\

  • 2-1:clientB上线后向server拉取离线消息(pull-Req) ;
  • 2-2:server从offline-DB检索相应的离线消息推送给clientB(pull-res),并从offline-DB中删除。

显然:离线消息收发过程同样存在消息丢失的可能性。

举例来说:假设pull-res没有成功送达clientB,而offline-DB中已删除,这部分离线消息就彻底丢失了。

image-20260217160754364

与初始的离线消息收发流程相比,上图增加了1-3、2-4、2-5步骤:

  • 1-3:server将消息存入offline-DB后,回复ACK(msg-Ack)给clientA,clientA收到ACK即可认为消息投递成功;
  • 2-4:clientB收到推送的离线消息,回复ACK(res-Ack)给server;
  • 2-5:server收到res-ACk后确定离线消息已被clientB成功收取,此时才能从offline-DB中删除。

当离线消息的量较大时:如果对每条消息都回复ACK,无疑会大大增加客户端与服务器的通信次数。这种情况我们通常使用批量ACK的方式,对多条消息仅回复一个ACK。还可以将所有的离线消息按会话进行分组,每组回复一个ACK,假如某个ACK丢失,则只需要重传该会话的所有离线消息。

通过在应用层加入重传、确认机制后,我们确实是杜绝了消息丢失的可能性。

但由于重试机制的存在,我们会遇到一个新的问题:那就是同一条消息可能被重复发送。

举一个最简单的例子:假设client成功收到了server推送的消息,但其后续发送的ACK丢失了,那么server将会在超时后再次推送该消息,如果业务层不对重复消息进行处理,那么用户就会看到两条完全一样的消息。

消息去重的方式其实非常简单,一般是根据消息的唯一标志(id)进行过滤。

具体过程在服务端和客户端可能有所不同:

  • 1)**客户端 :可以通过构造一个map来维护已接收消息的id,当收到id重复的消息时直接丢弃;
  • 2)**服务端 :收到消息时根据id去数据库查询,若库中已存在则不进行处理,但仍然需要向客户端回复Ack(因为这条消息很可能来自用户的手动重发)。

IM聊天系统的消息时序一致性

所谓的一致性,在IM中通常指的是消息时序的一致性,那就是:

  • 1)聊天消息的上下文连续性;
  • 2)聊天消息的绝对时间序。

再具体一点,IM消息的一致性体现在:

  • 1)单聊时:要保证发送方发出聊天消息的顺序与接收方看到的顺序一致;
  • 2)群聊时:要保证所有群员看到的聊天消息,与发送者发出消息时的绝对时间序是一致的。

技术难点:没有全局时钟

一个真正堪用的生产系统,显示不可能所有服务都跑在一台服务器上,分布式环境是肯定的。

那么:在分布式环境下,客户端+服务端后台的各种后台服务,都各自分布在不同的机器上,机器之间都是使用的本地时钟,没有一个所谓的“全局时钟”(也没办法做到真正的全局时钟),那么所谓的消息时序也就没有真正意义上的时序基准点。所以消息时序问题显然不是“本地时间”可以完全决定的。

多发送方问题:服务端分布式的情况下,不能用“本地时间”来保证时序性,那么能否用接收方本地时间表示时序呢?遗憾的是,由于多个客户端的存在(比如群聊时),即使是一台服务器的本地时间,也无法表示“绝对时序”。

绝对时序上,APP1先发出msg1,APP2后发出msg2,都发往服务器web1,网络传输是不能保证msg1一定先于msg2到达的,所以即使以一台服务器web1的时间为准,也不能精准描述msg1与msg2的绝对时序。

多接收方问题,多发送方不能保证时序,假设只有一个发送方,能否用发送方的本地时间表示时序呢?遗憾的是,由于多个接收方的存在,无法用发送方的本地时间,表示“绝对时序”。绝对时序上,web1先发出msg1,后发出msg2,由于网络传输及多接收方的存在,无法保证msg1先被接收到先被处理,故也无法保证msg1与msg2的处理时序。

网络传输与多线程问题,既然多发送方与多接收方都难以保证绝对时序,那么假设只有单一的发送方与单一的接收方,能否保证消息的绝对时序一致性呢?

结论是悲观的,由于网络传输与多线程的存在,这仍然不行。

如上图所示,web1先发出msg1、后发出msg2,即使msg1先到达(网络传输其实还不能保证msg1先到达),由于多线程的存在,也不能保证msg1先被处理完。

优化思路

假设两人一对一聊天,发送方A依次发出了msg1、msg2、msg3三条消息给接收方B,这三条消息该怎么保证显示时序的一致性(发送与显示的顺序一致)?发送方A依次发出的msg1、msg2、msg3三条消息,到底服务端后,再由服务端中转发出时,这个顺序由于多线程的网络的问题,是有可能乱序的。

不过,实际上一对一聊天的两个人,并不需要全局消息时序的一致(因为聊天只在两人的同一会话在发生),只需要对于同一个发送方A,发给B的消息时序一致就行了。

常见优化方案,在A往B发出的消息中,加上发送方A本地的一个绝对时序(比如本机时间戳),来表示接收方B的展现时序。那么当接收方B收到消息后,即使极端情况下消息可能存在乱序到达,但因为这个乱序的时间差对于普通用户来说体感是很短的,在UI展现层按照消息中自带的绝对时序排个序后再显示,用户其实是没有太多感知的。

多对多群聊的消息一致性保证思路假设N个群友在一个IM群里聊天,应该怎样保证所有群员收到消息的显示时序一致性呢?不能像一对一聊天那样利用发送方的绝对时序来保证消息顺序,因为群聊发送方不单点,时间也不一致。可以利用服务器的单点做序列化。

  • 1)sender1发出msg1,sender2发出msg2;
  • 2)msg1和msg2经过接入集群,服务集群;
  • 3)service层到底层拿一个唯一seq,来确定接收方展示时序;
  • 4)service拿到msg2的seq是20,msg1的seq是30;
  • 5)通过投递服务将消息发给多个群友,群友即使接收到msg1和msg2的时间不同,但可以统一按照seq来展现。

这个方法:

  • 1)优点是:能实现所有群友的消息展示时序相同;
  • 2)缺点是:这个生成全局递增序列号的服务很容易成为系统瓶颈

群消息其实也不用保证全局消息序列有序,而只要保证一个群内的消息有序即可,这样的话,“消息id序列化”就成了一个很好的思路。

image-20260217172530135

群消息其实不用保证全局消息序列有序,而只要保证一个群内的消息有序即可,这样的话,“消息id序列化”就成了一个很好的思路。service层不再需要去一个统一的后端拿全局seq(序列号),而是在service连接池层面做细小的改造,保证一个群的消息落在同一个service上,这个service就可以用本地seq来序列化同一个群的所有消息,保证所有群友看到消息的时序是相同的。这样就需要全局消息ID生成方案,对于IM系统来说,绝对意义上的时序很难保证,但通过服务端生成的单调递增消息ID的方式,利用递增ID来保证时序性,也是一个很可性的方案。

IM聊天系统的端到端加密

一般的数据加密可以在通信的3个层次来实现:链路加密、节点加密和端到端加密。

链路加密

对于在两个网络节点间的某一次通信链路,链路加密能为网上传输的数据提供安全保证。对于链路加密(又称在线加密),所有消息在被传输之前进行加密,在每一个节点对接收到的消息进行解密,然后先使用下一个链路的密钥对消息进行加密,再进行传输。

在到达目的地之前,一条消息可能要经过许多通信链路的传输。由于在每一个中间传输节点消息均被解密后重新进行加密,因此,包括路由信息在内的链路上的所有数据均以密文形式出现,这样,链路加密就掩盖了被传输消息的源点与终点。

节点加密

尽管节点加密能给网络数据提供较高的安全性,但它在操作方式上与链路加密是类似的:两者均在通信链路上为传输的消息提供安全性,都在中间节点先对消息进行解密,然后进行加密。因为要对所有传输的数据进行加密,所以加密过程对用户是透明的。然而,与链路加密不同,节点加密不允许消息在网络节点以明文形式存在,它先把收到的消息进行解密,然后采用另一个不同的密钥进行加密,这一过程是在节点上的一个安全模块中进行

节点加密要求报头和路由信息以明文形式传输,以便中间节点能得到如何处理消息的信息,因此这种方法对于防止攻击者分析通信业务是脆弱的。

端到端加密

端到端加密允许数据在从源点到终点的传输过程中始终以密文形式存在。采用端到端加密(又称脱线加密或包加密),消息在被传输时到达终点之前不进行解密,因为消息在整个传輸过程中均受到保护,所以即使有节点被损坏也不会使消息泄露。端到端加密系统的价格便宜些,并且与链路加密和节点加密相比更可靠,更容易设计、实现和维护。端到端加密还避免了其它加密系统所固有的同步问题,因为每个报文包均是独立被加密的,所以一个报文包所发生的传输错误不会影响后续的报文包。端到端加密系统通常不允许对消息的目的地址进行加密,这是因为每一个消息所经过的节点都要用此地址来确定如何传输消息。由于这种加密方法不能掩盖被传输消息的源点与终点,因此它对于防止攻击者分析通信业务是脆弱的。

在IM系统中,当用户A发送消息给用户B时,IM系统会生成一对公钥和私钥,并将公钥发送给用户B。用户A使用用户B的公钥对消息进行加密,然后将加密后的消息发送给用户B。在用户B接收到消息后,使用自己的私钥对消息进行解密,从而获取明文内容。由于私钥只有用户B拥有,因此除了用户B之外,任何人都无法解密消息。

通讯效率低:由于端对端加密需要对通讯数据进行加密和解密,因此可能会导致通信效率较低。

需双向支持:端对端加密需要发送方和接收方都需要支持该技术,否则就将无法实现端对端加密通信。

安全性问题:虽然端对端加密可以防止中间人攻击,但如果黑客能够获得了私钥或公钥,那么他们也能够轻易地获取到通信数据。

简易架构设计

IM系统设计设计方方面面,这里不作细究。从功能来说包括账号,关系链,在线状态显示,消息交互等等。

一种简单架构如下

浅谈IM系统的架构设计_QQ20160514-4.png

对于上线服务由于建立的是TCP长连接,对于单台服务器往往由于硬件资源、系统资源、网络资源的限制无法做到海量用户的同时 在线,所以设计为根据服务器负载支持多服务器上线,同时由于多服务器上线造成了对整个系统交互(不同的客户端的交互,协作部门应用服务和客户的交互)的分 割,引入消息转发服务器作为粘合点。另外对于多服务器上线造成的统一账户信息(在线状态,消息)数据的分割,引入统一的数据层(内存存储 层:session、状态信息存储、消息队列存储;数据库:账号信息存储)做到业务和数据的分离,也就做到了支持分布式部署。

一种现代架构如下图

跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)_1.png

高并发上又可以从水平扩展,线程模型,多层缓存,长连接等优化。

高可用上包括心跳,稳定性以及Redis宕机高可用等。

从流程上来看主要有单聊和群聊流程。

单聊流程

假设是用户A发消息给用户B ,以下是完整的业务流程。

1)A打包数据发送给服务端,服务端接收消息后,根据接收消息的sequence_id来进行客户端发送消息的去重,并且生成递增的消息ID,将发送的信息和ID打包一块入库,入库成功后返回ACK,ACK包带上服务端生成的消息ID。

2)服务端检测接收用户B是否在线,在线直接推送给用户B。

3)如果没有本地消息ID则存入,并且返回接入层ACK信息;如果有则拿本地sequence_id和推送过来的sequence_id大小对比,并且去重,进行展现时序进行排序展示,并且记录最新一条消息ID。最后返回接入层ack。

4)服务端接收ACK后,将消息标为已送达。

5)如果用户B不在线,首先将消息存入库中,然后直接通过手机通知来告知客户新消息到来。

6)用户B上线后,拿本地最新的消息ID,去服务端拉取所有好友发送给B的消息,考虑到一次拉取所有消息数据量大,通过channel通道来进行分页拉取,将上一次拉取消息的最大的ID,作为请求参数,来请求最新一页的比ID大的数据。

群聊流程

假设是用户A发消息给群G ,以下是完整的业务流程。

1)登录,TCP连接,token校验,名词检查,sequence_id去重,生成递增的消息ID,群消息入库成功返回发送方ACK。

2)查询群G所有的成员,然后去redis中央存储中找在线状态。离线和在线成员分不同的方式处理。

3)在线成员:并行发送拉取通知,等待在线成员过来拉取,发送拉取通知包如丢失会有兜底机制。

4)在线成员过来拉取,会带上这个群标识和上一次拉取群的最小消息ID,服务端会找比这个消息ID大的所有的数据返回给客户端,等待客户端ACK。一段时间没ack继续推送。如果重试几次后没有回ack,那么关闭连接和清除ack等待队列消息。

5)客户端会更新本地的最新的消息ID,然后进行ack回包。服务端收到ack后会更新群成员的最新的消息ID。

6)离线成员:发送手机通知栏通知。离线成员上线后,拿本地最新的消息ID,去服务端拉取群G发送给A的消息,通过channel通道来进行分页拉取,每一次请求,会将上一次拉取消息的最大的ID,作为请求参数来拉取消息,这里相当于第二次拉取请求包是作为第一次拉取的ack包。

7)分页的情况下,客户端在收到上一页请求的的数据后更新本地的最新的消息ID后,再请求下一页并且带上消息ID。上一页请求的的数据可以当作为ack来返回服务端,避免网络多次交互。服务端收到ack后会更新群成员的最新的消息ID。

客户端设计

客户端的设计主要从以下几点出发:

  • 1)client每个设备会在本地存每一个会话,保留有最新一条消息的顺序 ID;
  • 2)为了避免client宕机,也就是退出应用,保存在内存的消息ID丢失,会存到本地的文件中;
  • 3)client需要在本地维护一个等待ack队列,并配合timer超时机制,来记录哪些消息没有收到ack:N,以定时重发;
  • 4)客户端本地生成一个递增序列号发送给服务器,用作保证发送顺序性。该序列号还用作ack队列收消息时候的移除

1)方案一:

跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)_2.png

设计思路:

  • 1)数据传输中的大小尽量小用int,不用bigint,节省传输大小;
  • 2)只保证递增即可,在用户重新登录或者重连后可以进行日期重置,只保证单次;
  • 3)客户端发号器不需要像类似服务器端发号器那样集群部署,不需要考虑集群同步问题。

注:上述生成器可以用18年[(2^29-1)/3600/24/365]左右,一秒内最多产生4个消息。

优点:可以在断线重连和重装APP的情况下,18年之内是有序的。
缺点:每秒只能发4个消息,限制太大,对于群发场景不合适。
改进:使用long进行传输,年限扩展很久并且有序。

2)方案二:

设计思路:

  • 1)每次重新建立链接后进行重置,将sequence_id(int表示)从0开始进行严格递增;
  • 2)客户端发送消息会带上唯一的递增sequence_id,同一条消息重复投递的sequence_id是一样的;
  • 3)后端存储每个用户的sequence_id,当sequence_id归0,用户的epoch年代加1存储入库,单聊场景下转发给接收者时候,接收者按照sequence_id和epoch来进行排序。

优点:可以在断线重连和重装APP的情况下,接收者可以按照发送者发送时序来显示,并且对发送消息的速率没限制。

转发接入层

IM接入层的高可用、负载均衡、扩展性全部在这里面做。客户端通过LSB,来获取gate IP地址,通过IP直连。

这样做的目的是:

  • 1)灵活的负载均衡策略 可根据最少连接数来分配IP;
  • 2)做灰度策略来分配IP;
  • 3)AppId业务隔离策略 不同业务连接不同的gate,防止相互影响;
  • 4)单聊和群聊的im接入层通道分开。

上述设计存在一个问题:就是当某个实例重启后,该实例的连接断开后,客户端会发起重连,重连就大概率转移其他实例上,导致最近启动的实例连接数较少,最早启动的实例连接数较多。

解决方法:

  • 1)客户端会发起重连,跟服务器申请重连的新的服务器IP,系统提供合适的算法来平摊gate层的压力,防止雪崩效应
  • 2)gate层定时上报本机的元数据信息以及连接数信息,提供给LSB中心,LSB根据最少连接数负载均衡实现,来计算一个节点供连接。

消息投递

一个正常的消息流转需要如下图所示的流程:

跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)_5.png

如上图所示:

  • 1)客户端A发送请求包R;
  • 2)server将消息存储到DB;
  • 3)存储成功后返回确认ack;
  • 4)server push消息给客户端B;
  • 5)客户端B收到消息后返回确认ack;
  • 6)server收到ack后更新消息的状态或者删除消息。
  1. 接入层预处理(Gateway/Connector)

消息首先到达网关节点,执行以下校验:

  • 安全鉴权:校验 A 的 Token 是否合法、连接是否有效。
  • 幂等去重:利用消息包携带的 ClientMsgId,在 Redis 中检查是否为重发请求,防止因网络抖动导致重复入库。
  • 协议解析:将前端的协议(如 WebSocket/Protobuf)转换为服务端内部的通用模型。
  1. 消息原子性处理与保序(Logic Server)

这是流程中最关键的“逻辑中枢”:

  • 分配序列号(SeqID):服务端根据 SessionID 去 Redis 中请求一个单调递增的序号。这个 SeqID 是保证 B 侧消息不乱序的唯一标准。
  • 写入持久化库(存储库):将消息内容、发送者、接收者、SeqID 等完整写入分布式数据库(如 HBase 或 MySQL)。
  • 写入同步库(Sync Store):同时写入 Redis 的 ZSet 结构(Timeline 模型),为 B 上线后的增量同步做准备。
  1. 分布式路由寻址

消息存好后,需要找到 B 到底在哪台机器上:

  • 查询 Redis 路由表:根据接收方 B 的 UserId,查询其当前的在线状态及对应的 NodeID(网关节点 ID)。
  • 判定投递路径
    • 在线:获取 B 所在的网关 IP,进入实时转发逻辑。
    • 离线:直接结束实时流程,记录未读数,等待 B 上线后触发离线拉取,或者触发第三方推送(如厂商推送 APNs/华为推送)。
  1. 跨机转发与实时推送

通过消息队列(MQ)进行服务器间的“转运”:

  • MQ 转发:服务器 A 将消息投递到特定的 MQ Topic(通常以 B 所在的 NodeID 命名)。
  • 服务器 B 消费:服务器 B 监听到消息后,根据内部的 ChannelID(长连接通道)找到 B 的物理连接。
  • 下发推送:通过 WebSocket/TCP 将带有 SeqID 的消息包推给客户端 B。
  1. 确认与反馈(ACK 闭环)

为了确保“不丢失”,必须完成闭环:

  • 服务端响应 A:一旦消息入库成功,服务端立即返回一个 SERVER_ACK 给 A,告知消息已妥投(此时 A 端的“发送中”菊花图标消失)。
  • 等待 B 的回执:服务端将该消息放入“待确认队列”。直到收到客户端 B 发回的 CLIENT_ACK,才将该消息从重传列表中删除。

聊天消息的同步和存储方案

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

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

一种传统架构设计是消息先同步后存储

image-20260213165208138

对于在线的用户,消息会直接实时同步到在线的接收方,消息同步成功后,并不会进行持久化。而对于离线的用户或者消息无法实时同步成功时,消息会持久化到离线库,当接收方重新连接后,会从离线库拉取所有未读消息。当离线库中的消息成功同步到接收方后,消息会从离线库中删除。传统的消息系统,服务端的主要工作是维护发送方和接收方的连接状态,并提供在线消息同步和离线消息缓存的能力,保证消息一定能够从发送方传递到接收方。服务端不会对消息进行持久化,所以也无法支持消息漫游。

image-20260213210649588

现代架构中最核心的就是两个消息库『消息同步库』和『消息存储库』,是消息同步和存储最核心的基础。

Timeline逻辑模型

Timeline可以简单理解为是一个消息队列,但这个消息队列有如下特性:

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

基于Timeline逻辑模型,可以分析消息存储模型和消息同步模型。

消息的同步可以拿Timeline来很简单的实现。图中的例子中,消息发送方是A,消息接收方是B,同时B存在多个接收端,分别是B1、B2和B3。A向B发送消息,消息需要同步到B的多个端,待同步的消息通过一个Timeline来进行交换。A向B发送的所有消息,都会保存在这个Timeline中,B的每个接收端都是独立的从这个Timeline中拉取消息。每个接收端同步完毕后,都会在本地记录下最新同步到的消息的SeqId,即最新的一个位点,作为下次消息同步的起始位点服务端不会保存各个端的同步状态,各个端均可以在任意时间从任意点开始拉取消息。

消息存储/漫游也是基于Timeline,和消息同步唯一的区别是,消息存储要求服务端能够对Timeline内的所有数据进行持久化。

消息存储模型

image-20260214100630240

消息存储要求每个会话都对应一个独立的Timeline。如图例子所示,A与B/C/D/E/F均发生了会话,每个会话对应一个独立的Timeline,每个Timeline内存有这个会话中的所有消息,服务端会对每个Timeline进行持久化。服务端能够对所有会话Timeline中的全量消息进行持久化,也就拥有了消息漫游的能力。

消息同步模型

消息的同步一般有读扩散和写扩散两种不同的方式,分别对应不同的Timeline物理模型。

读扩散

也叫“发件箱模型”。A 发一条消息,系统只存一份到群的 Timeline 里。B、C、D 上线时,去群的表里拉取属于自己的消息。

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

优点

  • 写极快:不管群里有多少人,只写一次数据库。

缺点

  • 读很重:如果用户加了 500 个群,每次刷新要拉取 500 个群的数据再做聚合。
  • 未读数复杂:需要记录每个人在每个群里读到了哪一条(LastReadMsgId)。

写扩散

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

优点

  • 读极快:用户看消息时,只需要读取自己的收件箱,不需要去别人的表里翻找。
  • 逻辑简单:未读数、排序都在自己表里。

缺点

  • 写放大:一个 2000 人的群,发一条消息要写 2000 次数据库。如果是万人数的大群,瞬间的写入压力会击垮数据库。

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

IM场景下,一条消息只会产生一次,但是会被读取多次,是典型的读多写少的场景,消息的读写比例大概是10:1。若使用读扩散同步模式,整个系统的读写比例会被放大到100:1。

一个优化的好的系统,必须从设计上去平衡这种读写压力,避免读或写任意一维触碰到天花板。所以IM系统这类场景下,通常会应用写扩散这种同步模式,来平衡读和写,将100:1的读写比例平衡到30:30。

当然写扩散这种同步模式,还需要处理一些极端场景,例如万人大群。针对这种极端写扩散的场景,会退化到使用读扩散。一个简单的IM系统,通常会在产品层面限制这种大群的存在,而对于一个高级的IM系统,会采用读写扩散混合的同步模式,来满足这类产品的需求.

现代 IM(如微信、钉钉)不会只用一种,而是动态切换

A. 单聊与常规小群(写扩散为主)

  • 逻辑:绝大多数人的联系人和群组规模都在 500 人以内。
  • 做法:采用写扩散。直接把消息同步到每个人的同步库(Sync Store)。
  • 结果:保证了普通用户查看聊天列表时的极致流畅度。

B. 超大群/直播间(读扩散为主)

  • 逻辑:比如一个 1 万人的企业全员群,或者几万人的直播间。
  • 做法:采用读扩散。消息只存一份在“群 Timeline”。
  • 同步策略:当用户点进这个特定的大群时,客户端才去请求该群的增量数据。

消息库设计

消息同步库

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

消息存储库

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

数据库选型

消息同步库消息存储库
高并发写,十万级TPS高并发写,少量读,万级TPS
高并发范围读,十万级TPS少范围读,千级TPS
保存一段时间的同步消息,TB级别。保留千万级的Timeline规模保存全量信息,百TB级别。保留亿级Timeline规模

1)表结构设计能够满足Timeline模型的功能要求:不要求关系模型,能够实现队列模型,并能够支持生成自增的SeqId;
2)能够支持高并发写和范围读,规模在十万级TPS;
3)能够保存海量数据,百TB级;
4)能够为数据定义生命周期。

消息送达保证机制实现

IM的客户端与服务器通过发送报文(也就是请求包)来完成消息的传递。

报文分为三种:

  • 请求报文(request,后简称为为R); 客户端主动发送给服务器的报文
  • 应答报文(acknowledge,后简称为A);务器被动应答客户端的报文,一个A一定对应一个R
  • 通知报文(notify,后简称为N)服务器主动发送给客户端的报文

用户A给用户B发送一个“你好”,流程如下:

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

  • client-A向im-server发送一个消息请求包,即msg:R
  • im-server在成功处理后,回复client-A一个消息响应包,即msg:A
  • 如果此时client-B在线,则im-server主动向client-B发送一个消息通知包,即msg:N(当然,如果client-B不在线,则消息会存储离线)

发送方client-A收到msg:A后,只能说明im-server成功接收到了消息,并不能说明client-B接收到了消息。在若干场景下,可能出现msg:N包丢失,且发送方client-A完全不知道,例如:

  • 服务器崩溃,msg:N包未发出
  • 网络抖动,msg:N包被网络设备丢弃
  • client-B崩溃,msg:N包未接收

接收方client-B是否有收到msg:N,发送方client-A完全不可控

应用层确认+IM消息可靠投递

TCP 协议本身就是可靠的(有确认、重传、有序),那 IM 系统的业务层为什么还要大费周章地搞应用层 ACK 和 SeqID 呢?TCP 的可靠性只存在于“传输层”,而 IM 的可靠性必须建立在“业务层”

TCP 只能保证“发到网卡”,不能保证“存入数据库”,“消息有序”的维度不同,网络链路复杂:TCP 连接不断不代表业务可用

UDP是一种不可靠的传输层协议,TCP是一种可靠的传输层协议,TCP是如何做到可靠的?答案是:超时、重传、确认。(实际上IM中,数据通讯层无论用的是UDP还是TCP协议,都同样需要消息送达保证(即QoS)机制,原因在于IM的通信是A端-Server-B端的3方通信,而非传统C/S或B/S这种2方通信)。

要想实现应用层的消息可靠投递,必须加入应用层的确认机制,即:要想让发送方client-A确保接收方client-B收到了消息,必须让接收方client-B给一个消息的确认,这个应用层的确认的流程,与消息的发送流程类似:

  • client-B向im-server发送一个ack请求包,即ack:R
  • im-server在成功处理后,回复client-B一个ack响应包,即ack:A
  • 则im-server主动向client-A发送一个ack通知包,即ack:N

至此,发送“你好”的client-A,在收到了ack:N报文后,才能确认client-B真正接收到了“你好”。

一条消息的发送,分别包含(上)(下)两个半场,即msg的R/A/N三个报文,ack的R/A/N三个报文。一个应用层即时通讯消息的可靠投递,共涉及6个报文,这就是im系统中消息投递的最核心技术(如果某个im系统不包含这6个报文,不要谈什么消息的可靠性)。

期望六个报文完成消息的可靠投递,但实际情况下:

  • msg:R,msg:A 报文可能丢失:
    此时直接提示“发送失败”即可,问题不大;
  • msg:N,ack:R,ack:A,ack:N这四个报文都可能丢失:
    此时client-A都收不到期待的ack:N报文,即client-A不能确认client-B是否收到“你好”。

消息超时重传

客户端A发送msg:R,收到msg:A后,在期待时间内如果没收到ack:N,client-A会尝试将msg:R重发。可能client-A同时发出了很多消息,故client-A需要在本地维护一个等待ack队列,并配合timer超时机制,来记录哪些消息没有收到ack:N,以定时重发。一旦收到了ack:N,说明client-B收到了“你好”消息,对应的消息将从“等待ack队列”中移除。

image-20260214153634044

消息重传导致的问题

核心问题就是重复收到

例如,msg:N报文,ack:N报文都有可能丢失:

  • msg:N 报文丢失:说明client-B之前压根没有收到“你好”报文,超时与重传机制十分有效
  • ack:N 报文丢失:说明client-B之前已经收到了“你好”报文(只是client-A不知道而已),超时与重传机制将导致client-B收到重复的消息。

解决方法很简单,由发送方client-A生成一个消息去重的msgid,保存在“等待ack队列”里,同一条消息使用相同的msgid来重传,供client-B去重,而不影响用户体验。

1)上述设计理念,由客户端重传,可以保证服务端无状态性(架构设计基本准则);
2)如果client-B不在线,im-server保存了离线消息后,要伪造ack:N发送给client-A;
3)离线消息的拉取,为了保证消息的可靠性,也需要有ack机制,但由于拉取离线消息不存在N报文,故实际情况要简单的多,即先发送offline:R报文拉取消息,收到offline:A后,再发送offlineack:R删除离线消息。

核心总结:可以通过应用层的确认(六个消息)、发送方的超时重传、接收方的去重等手段来保证业务层面消息的不丢不重。

1)im系统是通过超时、重传、确认、去重的机制来保证消息的可靠投递,不丢不重;
2)切记,一个消息的发送,包含上半场msg:R/A/N与下半场ack:R/A/N的6个报文。

离线消息的可靠投递

消息接收方不在线时的典型消息发送流程

  • Step 1:用户A发送一条消息给用户B;
  • Step 2:服务器查看用户B的状态,发现B的状态为“offline”(即B当前不在线);
  • Step 3:服务器将此条消息以离线消息的形式持久化存储到DB中;
  • Step 4:服务器返回用户A“发送成功”ACK确认包(注:对于消息发送方而言,消息一旦落地存储至DB就认为是发送成功了)

消息发送出去后,无论是对方实时在线收到还是对方不在线而被服务端离线存储了,对于发送方而言只要消息没有因为网络等原因莫名消失,就应该认为是“被收到了”。

离线表设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- 消息接收者ID
receiver_uid varchar(50),

-- 消息的唯一指纹码(即消息ID),用于去重等场景,单机情况下此id可能是个自增值、分布式场景下可能是类似于UUID这样的东西
msg_id varchar(70),

-- 消息发出时的时间戳(如果是个跨国IM,则此时间戳可能是GMT-0标准时间)
send_time time,

-- 消息发送者ID
sender_uid varchar(50),

-- 消息类型(标识此条消息是:文本、图片还是语音留言等)
msg_type int,

-- 消息内容(如果是图片或语音留言等类型,由此字段存放的可能是对应文件的存储地址或CDN的访问URL)
msg_content varchar(1024),

接收方B要拉取发送方A给ta发送的离线消息,只需在receiver_uid(即接收方B的用户ID), sender_uid(即发送方A的用户ID)上查询,然后把离线消息删除,再把消息返回B即可。

1
2
3
SELECT` `msg_id, send_time, msg_type, msg_content
FROM` `offline_msgs
WHERE` `receiver_uid = ? ``and` `sender_uid = ?

整体流程

  • Stelp 1:用户B开始拉取用户A发送给ta的离线消息;

  • Stelp 2:服务器从DB(或对应的持久化容器)中拉取离线消息;

  • Stelp 3:服务器从DB(或对应的持久化容器)中把离线消息删除;
  • Stelp 4:服务器返回给用户B想要的离线消息。

优化方案

如果用户B有很多好友,登陆时客户端需要对所有好友进行离线消息拉取,客户端与服务器交互次数就会比较多。

例如,对于每个好友,进行一次查询。

1
2
3
4
5
// 登陆时所有好友都要拉取
for``(all uid in B’s friend-list){
``// 与服务器交互
``get_offline_msg(B,uid);
}

1.客户端先拉离线消息数量,当用户真正查看时才向服务器发送拉取请求

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

image-20260214161309225

用户B一次性拉取所有好友发给ta的离线消息,消息量很大时,一个请求包很大、速度慢,容易卡顿怎么办?

可以分页拉取:根据业务需求,先拉取最新(或者最旧)的一页消息,再按需一页页拉取,这样便能很好地解决用户体验问题。

image-20260214161456393

如何保证可达性,上述步骤第三步执行完毕之后,第四个步骤离线消息返回给客户端过程中,服务器挂点,路由器丢消息,或者客户端crash了,那离线消息岂不是丢了么

同在线消息的应用层ACK机制一样,离线消息拉时,不能够直接删除数据库中的离线消息,而必须等应用层的离线消息ACK,才能删除数据库中的离线消息。这个应用层的ACK可以通过实时消息通道告之服务端,也可以通过服务端提供的REST接口,以更通用、简单的方式通知服务端。

如果用户B拉取了一页离线消息,却在ACK之前crash了,下次登录时会拉取到重复的离线消息么?

image-20260214163239393

拉取了离线消息却没有ACK,服务器不会删除之前的离线消息,故下次登录时系统层面还会拉取到。但在业务层面,可以根据msg_id去重。SMC理论:系统层面无法做到消息不丢不重,业务层面可以做到,对用户无感知。

假设有N页离线消息,现在每个离线消息需要一个ACK,那么岂不是客户端与服务器的交互次数又加倍了?有没有优化空间?

image-20260214163220532

不用每一页消息都ACK,在拉取第二页消息时相当于第一页消息的ACK,此时服务器再删除第一页的离线消息即可,最后一页消息再ACK一次(实际上:最后一页拉取的肯定是空返回,这样可以极大地简化这个分页过程,否则客户端得知道当前离线消息的总页数,而由于消息读取延迟的存在,这个总页数理论上并非绝对不变,从而加大了数据读取不一致的可能性)。这样的效果是,不管拉取多少页离线消息,只会多一个ACK请求,与服务器多一次交互。

常见优化总结如下:

  • 1)对于同一个用户B,一次性拉取所有用户发给ta的离线消息,再在客户端本地进行发送方分析,相比按照发送方一个个进行消息拉取,能大大减少服务器交互次数;
  • 2)分页拉取,先拉取计数再按需拉取,是无线端的常见优化;
  • 3)应用层的ACK,应用层的去重,才能保证离线消息的不丢不重;
  • 4)下一页的拉取,同时作为上一页的ACK,能够极大减少与服务器的交互次数。

Timeline逻辑模型下消息的有序

分布式IM聊天系统中,IM消息怎么做到不丢、不重、还按顺序到达?

这个问题,涉及到IM系统的两个核心:

1)消息不能丢(可靠性):比如用户点了发送,不能因为服务宕机或网络抖动,消息石沉大海。比如地铁隧道、电梯间,网络断了又连,消息不能卡住不动(要确保弱网也能用)。

2)顺序不能乱(有序性):比如“在吗?” 回成 “吗在?”,群聊时间线错乱,体验直接崩盘。

image-20260214172807014

为什么消息会乱

不同消息如果走不同服务节点,走不同网络路径,到达时间完全不可控,最终顺序错乱。

一个要“串行等”,一个想“并发冲”,天然冲突。

这时候有人会说:那我加个全局排序服务不就行了?

可以,但代价太大——一个中心节点最多撑几万 QPS,面对百万群聊、亿级用户,还没上线就已过载。

所以,全局有序不是解,而是枷锁。我们要的不是“天下大同”,而是“各聊各的别乱就行”。

所以根本不需要全局有序,只需要“会话内有序”。

分而治之+局部有序

具体怎么做?两步走稳:

第一步 - 业务分区:哈希分片,锁定归属

用 sessionId 做一致性哈希,确保同一个会话的所有消息始终路由到同一个处理节点。按“会话ID”做哈希,算出该消息该由哪个节点处理。同一会话 → 哈希值一样 → 路由到同一台机器 → 所有消息串行处理,天然避免跨节点乱序。这样一来,单个会话内的消息在服务端就是串行处理的,天然不会乱。

第二步 - 局部序号:独立发号,局部递增

每个会话独立维护一个计数器,每来一条消息就+1,作为它的“官方序号”。每个会话,可以配一个独立计数器(比如 Redis 的 INCR),每来一条消息就+1,生成唯一 SEQ。客户端不管什么时候收到消息,只认这个序号,按序号从小到大排列展示。这个 SEQ 就是这条消息的“官方身份证号”,客户端只认这个,不看接收时间。

1)服务端分片路由

1
2
3
4
5
6
String sessionId = msg.getSessionId();
//这里是伪代码,实际代码以mq 的负载均衡机制为准
int nodeIndex = Math.abs(sessionId.hashCode()) % clusterNodeCount;
//这里写个伪代码,代表mq 主从复制
ClusterNode targetNode = clusterNodes.get(nodeIndex);
targetNode.sendMsg(msg);

核心就一句:基于会话 ID 哈希取模,固定路由

2)服务端序号分配

1
2
3
long msgSeq = redis.incr("msg_seq_" + sessionId);
msg.setSeq(msgSeq);
msg.setUniqueKey(sessionId + "_" + msgSeq);

利用Redis 的 INCR,保证同一个会话下的 SEQ 绝对递增,且线程安全。同时用 sessionId_seq 作为唯一键,既能幂等去重,也能防止重试导致消息重复入库。

3)客户端排序逻辑:

最后一步,客户端收尾:别急着渲染,先排好队。

1
2
3
4
5
6
//这里是伪代码, 先排序
List<Msg> sortedMsgs = msgList.stream()
.sorted(Comparator.comparingLong(Msg::getSeq))
.collect(Collectors.toList());
//这里是伪代码, 再渲染
renderMsgList(sortedMsgs);

无论消息以什么顺序到达,统统按 seq 升序排列后再上屏。

如何保障分布式IM聊天系统的消息有序性(即消息不乱)_2.png

对于客户端来说,客户端串行发送(最稳健的方案)

大多数 IM SDK(如微信、钉钉)在发送消息时,并不是并行的,而是维护了一个本地发送队列

  • 逻辑:消息 1 发出后,客户端会进入等待状态。只有收到服务器返回的 SERVER_ACK,或者该消息彻底超时失败,才会从队列取出消息 2 进行发送。
  • 优点:从物理上保证了同一时刻只有一个请求在路上,绝对不会出现“后发先至”的情况。
  • 缺点:在极差网络下,如果前一条消息卡住,后面的消息会排队。但对于聊天语义来说,这种牺牲是值得的。

IM聊天系统的消息可靠性保障

三层兜底,像保险一样层层防

客户端兜底 —— 消息先存本地,解决网络不稳定问题

只要没收到 ACK,就当没发成功。

所以第一步不是联网,而是先把消息塞进手机本地数据库(比如 SQLite)。

1
2
3
4
5
db.saveLocalMsg(msg); // 先落库,保命
boolean sendOk = network.send(msg);
if (!sendOk) {
scheduleRetry(msg, 1000); // 发失败?排队重试
}

如何保障分布式IM聊天系统的消息有序性(即消息不乱)_3.png

再加上客户端scheduleRetry 采用阶梯式重试策略:

  • 1)第1次失败 → 1秒后重试
  • 2)第2次失败 → 3秒后重试
  • 3)第3次失败 → 5秒后重试

避免雪崩式刷屏,既保障可靠性,又不压垮服务。只有等到服务端明确说“我收到了”,才把这条消息从本地删掉。

服务端兜底 实现 服务端持久化的高可靠

客户端发来了,服务端能不能直接处理完就返回?绝对不行!如果此时机器宕机,消息还在内存里没来得及持久化,那就真的丢了。

正确做法是两步走:

  • 1)收到消息立刻写入 RocketMQ/数据库(支持刷盘、集群同步);
  • 2)同步复制到至少3个副本节点,确保单点故障不丢数据。

伪代码如下:

1
2
3
rocketMQ.send(msg); // 必须落盘,断电也不怕
replicaService.syncTo3Replicas(msg); // 多副本容灾
response.sendAck(msg.getUniqueKey()); // 此时才能回 ACK

这一步的关键是:ACK 必须在落盘之后发

幂等性设计

网络可能超时、包可能丢失、ACK 可能没传回来。

用唯一键 + 幂等控制。

每个消息生成全局唯一的 key(如 sessionID:msgID),服务端通过 Redis 的原子操作判断是否已处理。

1
2
3
4
5
6
String uniqueKey = msg.getUniqueKey();
if (redis.setNx(uniqueKey, "processed", 86400)) {
processMsg(msg); // 第一次来,正常处理
} else {
log.info("重复消息,忽略:{}", uniqueKey);
}

setNx 是关键:只有 key 不存在时才设置成功,保证多实例并发下也不会重复消费。

如何保障分布式IM聊天系统的消息可靠性(即消息不丢)_2-1.png

聊天序列号生成方案

美团Leaf-Segment和Leaf-Snowflake方案

生成全局唯一ID的系统ID要求

  • 1)全局唯一性:不能出现重复的ID号,既然是唯一标识,这是最基本的要求;
  • 2)趋势递增:在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能;
  • 3)单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM聊天中的增量消息、排序等特殊需求;
  • 4)信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则、不规则。

UUID标准型式包含32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的36个字符

优点:
性能非常高:本地生成,没有网络消耗。

缺点:

  • 1)不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用;
  • 2)信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露

D作为主键时在特定的环境会存在一些问题,比如做DB主键的场景下,UUID就非常不适用:

  • ① MySQL官方有明确的建议主键要尽量越短越好,36个字符长度的UUID不符合要求:
  • ② 对MySQL索引不利:如果作为数据库主键,在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能。

Snowflake算法使用一个 64 bit 的 long 型的数字作为全局唯一 ID。其中 1 个 bit 是不用的,然后用其中的 41 bit 作为毫秒数,用 10 bit 作为工作机器 ID,12 bit 作为序列号。41 bit 可以表示的数字多达 2^41 - 1,也就是可以标识 2 ^ 41 - 1 个毫秒值,换算成年就是表示 69 年的时间。

这种方式的优缺点如下。

优点:

  • 1)毫秒数在高位,自增序列在低位,整个ID都是趋势递增的;
  • 2)不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的;
  • 3)可以根据自身业务特性分配bit位,非常灵活。

缺点:
强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。

数据库自增ID,这种方案的优缺点如下。

优点:

  • 1)非常简单,利用现有数据库系统的功能实现,成本小,有DBA专业维护;
  • 2)ID号单调自增,可以实现一些对ID有特殊要求的业务。

缺点:

  • 1)强依赖DB,当DB异常时整个系统不可用,属于致命问题。配置主从复制可以尽可能的增加可用性,但是数据一致性在特殊情况下难以保证。主从切换时的不一致可能会导致重复发号;
  • 2)ID发号性能瓶颈限制在单台MySQL的读写性能。

对于MySQL性能问题,可用如下方案解决:在分布式系统中可以多部署几台机器,每台机器设置不同的初始值,且步长和机器数相等。比如有两台机器。设置步长step为2,TicketServer1的初始值为1(1,3,5,7,9,11…)、TicketServer2的初始值为2(2,4,6,8,10…)。

Leaf-segment算法

美团的Leaf-segment方案,实际上是在上面介绍的数据库自增ID方案上的一种改进方案,可生成全局唯一、全局有序的ID,可以用于:事务版本号、IM聊天中的增量消息、全局排序等业务中。

美团的Leaf-segment对数据库自增ID方案做了如下改变:

  • 1)原方案每次获取ID都得读写一次数据库,造成数据库压力大。改为利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力;
  • 2)各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。
1
2
3
4
5
6
7
8
9
+-------------+--------------+------+-----+-------------------+-----------------------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+--------------+------+-----+-------------------+-----------------------------+
| biz_tag | varchar(128) | NO | PRI | | |
| max_id | bigint(20) | NO | | 1 | |
| step | int(11) | NO | | NULL | |
| desc | varchar(256) | YES | | NULL | |
| update_time | timestamp | NO | | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
+-------------+--------------+------+-----+-------------------+-----------------------------+

biz_tag:用来区分业务;
max_id:表示该biz_tag目前所被分配的ID号段的最大值;
step:表示每次分配的号段长度。

原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写频率变为1/seq

优点:

  • 1)Leaf服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景;
  • 2)ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求;
  • 3)容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务;
  • 4)可以自定义max_id的大小,非常方便业务从原有的ID方式上迁移过来。

缺点:

  • 1)ID号码不够随机,能够泄露发号数量的信息,不太安全;
  • 2)TP999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,tg999数据会出现偶尔的尖刺;
  • 3)DB宕机会造成整个系统不可用。

Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。为此,我们希望DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。

Leaf-snowflake

Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,但不适用于订单ID生成场景。比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量。面对这一问题,美团技术团队实现了 Leaf-snowflake这个方案。

Leaf-snowflake方案是Twittersnowflake改进版,它完全沿用snowflake方案的bit位设计(如上图所示),即是“1+41+10+12”的方式组装ID号。

对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。

  • 1)启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点);
  • 2)如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务;
  • 3)如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。

除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。这样做到了对三方组件的弱依赖。一定程度上提高了SLA。

服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:

  • 1)若写过,则用自身系统时间与leaf_forever/${self}节点记录时间做比较,若小于leaf_forever/${self}时间则认为机器时间发生了大步长回拨,服务启动失败并报警;
  • 2)若未写过,证明是新服务节点,直接创建持久节点leaf_forever/${self}并写入自身系统时间,接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确,具体做法是取leaf_temporary下的所有临时节点(所有运行中的Leaf-snowflake节点)的服务IP:Port,然后通过RPC请求得到所有节点的系统时间,计算sum(time)/nodeSize;
  • 3)若abs( 系统时间-sum(time)/nodeSize ) < 阈值,认为当前系统时间准确,正常启动服务,同时写临时节点leaf_temporary/${self} 维持租约;
  • 4)否则认为本机系统时间发生大步长偏移,启动失败并报警;
  • 5)每隔一段时间(3s)上报自身系统时间写入leaf_forever/${self}。

多点登录和消息漫游

以微信为例:可以PC端、phone端同时登录、同时收发消息。在任何一个终端的任何一个实例登录qq,都能够拉取到所有历史聊天消息,这个就是消息漫游。

image-20260214195714405

  • 1)用户A登录在gate1上,发出消息;
  • 2)gate1将消息给logic/router;
  • 3)logic/router查询接收方的在线状态(B在线,C不在线);
  • 4)例如接收方C不在线,存储离线;
  • 4)例如接收方B在线,且登录在gate2上,消息投递给gate2;
  • 5)gate2将消息投递给B。

接收方多点登录:pc登录、phone也登录,后一端登录不会将前一端踢出,cache中存储状态与登录点时,不再以user_id为key,改为以user_id+终端类型为key即可

当用户A(phone端)给用户B发送消息时,除了要投递给B的所有多点登录端,还需要投递给A自已多点登陆的其他端(pc端),如上图中步骤4与步骤5。只有这样,才能在所有用户的所有端,恢复与还原双方聊天的上下文。

如果业务不需要支持“消息漫游”的功能,对于在线消息,如果用户实时接收到则是不需要存储到数据库的。但如果要支持“换一台机器(指的是用户的客户端)也能看到历史的聊天消息”,就需要对所有消息进行存储了。

浅谈移动端IM的多点登陆和消息漫游原理_4.jpg
消息投递如上图:用户A发送消息给用户B,虽然B在线,仍然要增加一个步骤2.5,在投递之前进行存储,以备B的其他端登陆时,可以拉取到历史消息。

浅谈移动端IM的多点登陆和消息漫游原理_5.jpg
消息拉取如上图:原本不在线的B(phone端),又重新登录了,他怎么拉取历史消息?只需要在客户端本地存储一个上一次拉取到的msg_id(time),到服务端重新拉取即可。这里还有个问题:由于服务端存储所有消息成本是非常高的,所以一般“消息漫游”是有时间(或者消息数)限制,不能拉取所有所有几年前的历史消息,比如只能拉取3个月内的云端消息等。

“多点登录”是指多个端同时登录一个帐号,同时收发消息,关键点是:

  • 1)需要在服务端存储同一个用户多个端的状态与登陆点;
  • 2)发出消息时,要对发送方的多端与接收端的多端,都进行消息投递。

“消息漫游”是指一个用户在任何端,都可以拉取到历史消息,关键点是:

  • 1)所有消息存储在云端;
  • 2)每个端本地存储last_msg_id,在登录时可以到云端同步历史消息
  • 3)云端存储所有消息成本较高,一般会对历史消息时间(或者条数)进行限制。

消息撤回的实现

  1. 撤回指令的发送与校验

当用户 A 点击“撤回”时,客户端并不是发送“删除请求”,而是发送一条类型为 MSG_REVOKE 的消息包。

  • 数据包内容:包含 operatorId(操作者)、sessionId(会话ID)以及最重要的 targetMsgId(要撤回的那条消息的唯一ID)
  • 服务端校验
    • 权限校验:只有发送者本人(或群管理员)可以撤回。
    • 时效校验:检查当前时间与原消息发送时间的差值(如超过 2 分钟则拦截并返回错误码)。
  1. 服务端的“逻辑覆盖”

服务端接收到撤回指令后,不会执行物理删除(DELETE),而是执行逻辑更新

  1. 修改数据库:在消息表中找到该 msgId,将其 msgType 修改为 REVOKED,并清空原有的文字/图片内容,改为“对方撤回了一条消息”。
  2. 更新快照(Session_Snapshot):如果这条消息是该会话的最后一条,需要同步更新 Redis 中的会话快照,将首页显示的摘要改为“对方撤回了一条消息”。
  3. 生成撤回信令:服务端会生成一条带有新 SeqId 的撤回通知,分发给会话中的所有参与者。

  4. 客户端的实时更新(UI 层)

当客户端(包括发送方和接收方)收到这条 REVOKED 类型的指令时:

  • 内存替换:根据指令中的 targetMsgId,在当前聊天列表的内存缓存(List/Array)中找到那条旧消息。
  • UI 刷新:将旧的消息气泡替换为一个灰色的提示条(“对方撤回了一条消息”)。
  • 本地持久化:同步更新本地 SQLite 数据库,确保下次打开 App 时,看到的也是撤回后的状态。

群聊消息的有序不丢失

两个群业务的核心数据结构:

1
2
3
4
群成员表:用来描述一个群里有多少成员
t_group_users(group_id, user_id)
群离线消息表:用来描述一个群成员的离线消息
t_offine_msgs(user_id, group_id, sender_id,time, msg_id, msg_detail)

群消息投递

image-20260214204421041

  • 步骤1:群消息发送者x向server发出群消息;
  • 步骤2:server去db中查询群中有多少用户(x,A,B,C,D);
  • 步骤3:server去cache中查询这些用户的在线状态;
  • 步骤4:对于群中在线的用户A与B,群消息server进行实时推送;
  • 步骤5:对于群中离线的用户C与D,群消息server进行离线存储

群离线消息拉取

image-20260214204502274

  • 步骤1:离线消息拉取者C向server拉取群离线消息;
  • 步骤2:server从db中拉取离线消息并返回群用户C;
  • 步骤3:server从db中删除群用户C的群离线消息。

缺点:对于同一份群消息的内容,多个离线用户存储了很多份。假设群中有200个用户离线,离线消息则冗余了200份,这极大的增加了数据库的存储压力

优化1 减少存储量

为了减少离线消息的冗余度,增加一个群消息表,用来存储所有群消息的内容,离线消息表只存储用户的群离线消息msg_id,就能大大的降低数据库的冗余存储量

1
2
3
4
群消息表:用来存储一个群中所有的消息内容
t_group_msgs(group_id, sender_id, time,msg_id, msg_detail)
群离线消息表:优化后只存储msg_id
t_offine_msgs(user_id, group_id, msg_id)

这样优化后,群在线消息发送就做了一些修改:

  • 步骤3:每次发送在线群消息之前,要先存储群消息的内容;
  • 步骤6:每次存储离线消息时,只存储msg_id,而不用为每个用户存储msg_detail。

拉取离线消息时也做了响应的修改:

  • 步骤1:先拉取所有的离线消息msg_id;
  • 步骤3:再根据msg_id拉取msg_detail;
  • 步骤5:删除离线msg_id。

存在的问题(如同单对单消息的发送一样):

  • 1)在线消息的投递可能出现消息丢失,例如服务器重启,路由器丢包,客户端crash;
  • 2)离线消息的拉取也可能出现消息丢失,原因同上。

优化2 应用层ACK

image-20260214205637816

  • 步骤3:在消息msg_detail存储到群消息表后,不管用户是否在线,都先将msg_id存储到离线消息表里;
  • 步骤6:在线的用户A和B收到群消息后,需要增加一个应用层ACK,来标识消息到达;
  • 步骤7:在线的用户A和B在应用层ACK后,将他们的离线消息msg_id删除掉。

对应到群离线消息的拉取也一样:

  • 步骤1:先拉取msg_id;
  • 步骤3:再拉取msg_detail;
  • 步骤5:最后应用层ACK;
  • 步骤6:server收到应用层ACK才能删除离线消息表里的msg_id。

对于离线的每一条消息,虽然只存储了msg_id,但是每个用户的每一条离线消息都将在数据库中保存一条记录

优化3 离线消息表 只需要存储最近一条拉取到的离线消息的time(或者msg_id),下次登录时拉取在那之后的所有群消息即可,而完全没有必要存储每个人未拉取到的离线消息msg_id。

1
2
3
4
5
群成员表:用来描述一个群里有多少成员,以及每个成员最后一条ack的群消息的msg_id(或者time
t_group_users(group_id, user_id, last_ack_msg_id(last_ack_msg_time))
群消息表:用来存储一个群中所有的消息内容,不变
t_group_msgs(group_id, sender_id, time,msg_id, msg_detail)
群离线消息表:不再需要了

离线消息表优化后,群在线消息的投递流程:

  • 步骤3:在消息msg_detail存储到群消息表后,不再需要操作离线消息表(优化前需要将msg_id插入离线消息表);
  • 步骤7:在线的用户A和B在应用层ACK后,将last_ack_msg_id更新即可(优化前需要将msg_id从离线消息表删除)

群离线消息的拉取流程也类似:

  • 步骤1:拉取离线消息;
  • 步骤3:ACK离线消息;
  • 步骤4:更新last_ack_msg_id。

由于“消息风暴扩散系数”的存在,假设1个群有500个用户,“每条”群消息都会变为500个应用层ACK,将对服务器造成巨大的冲击,有没有办法减少ACK请求量呢?

优化4 批量ACK

批量ACK的方式又有两种:

  • 1)每收到N条群消息ACK一次,这样请求量就降低为原来的1/N了;
  • 2)每隔时间间隔T进行一次群消息ACK,也能达到类似的效果。

新的问题:批量ACK有可能导致:还没有来得及ACK群消息,用户就退出了,这样下次登录会拉取到重复的离线消息。
解决方案:msg_id去重,不对用户展现,保证良好的用户体验。

还可能存在的问题:群离线消息过多:拉取过慢。 解决方案:分页拉取(按需拉取),

时序性和一致性

即时消息的时序性(Message Ordering),简单来说就是保证消息的“发生顺序”“呈现顺序”完全一致。

为什么分布式环境下即时消息时序性难以保证

  1. 时钟不一致:分布式环境下,有多个客户端、有web集群、service集群、db集群,他们都分布在不同的机器上,机器之间都是使用的本地时钟,而没有一个所谓的“全局时钟”,所以不能用“本地时间”来完全决定消息的时序。
  2. 多客户端:绝对时序上,APP1先发出msg1,APP2后发出msg2,都发往服务器web1,网络传输是不能保证msg1一定先于msg2到达的,所以即使以一台服务器web1的时间为准,也不能精准描述msg1与msg2的绝对时序。
  3. 服务集群:绝对时序上,web1先发出msg1,后发出msg2,由于网络传输及多接收方的存在,无法保证msg1先被接收到先被处理,故也无法保证msg1与msg2的处理时序。
  4. 网络传输与多线程:多发送方与多接收方都难以保证绝对时序,假设只有单一的发送方与单一的接收方,能否保证消息的绝对时序呢?结论是悲观的,由于网络传输与多线程的存在,仍然不行。

假设只有一个发送方,一个接收方,上下游连接只有一条连接池,通过阻塞的方式通讯,难道不能保证先发出的消息msg1先处理么?

可以,但吞吐量会非常低,而且单发送方单接收方单连接池的假设不太成立,高并发高可用的架构不会允许这样的设计出现

生产环境下的优化方案:

  1. 以客户端或者服务端的时序为准:多客户端、多服务端导致“时序”的标准难以界定,需要一个标尺来衡量时序的先后顺序。
  2. 服务端能够生成单调递增的id:利用单点写db的seq/auto_inc_id肯定能生成单调递增的id,只是说性能及扩展性会成为潜在瓶颈。对于严格时序的业务场景,可以利用服务器的单调递增id来保证时序。
  3. 大部分业务能接受误差不大的趋势递增id
  4. 单聊中业务上不需要全局消息一致,只需要对于同一个发送方A,ta发给B的消息时序一致就行,常见优化方案,在A往B发出的消息中,加上发送方A本地的一个绝对时序,来表示接收方B的展现时序。
  5. 群聊中保证各接收方收到顺序一致:service层不再需要去一个统一的后端拿全局seq,而是在service连接池层面做细小的改造,保证一个群的消息落在同一个service上,这个service就可以用本地seq来序列化同一个群的所有消息,保证所有群友看到消息的时序是相同的。

保证发送端的物理序(即用户点击发送的顺序 $M1 \rightarrow M2$ 能够原样到达服务器)是 IM 系统的基本功。

在生产级框架(如微信 SDK、网易云信、钉钉等)中,这并不是通过单一手段实现的,而是通过“客户端串行队列”“逻辑序号标记”双重机制来保证的。

  1. 核心实现方案:客户端发送队列 (Serial Sending Queue)

这是最主流的生产级实现方式。客户端 SDK 内部并不是直接调用网络接口发送消息,而是维护了一个“待发送队列”

  • 执行逻辑
    1. 用户点击发送 $M1$,SDK 将其放入队列,状态设为“发送中”。
    2. SDK 发出 $M1$ 的网络请求,此时不发送 $M2$
    3. 只有当收到服务器对 $M1$ 的 SERVER_ACK,或者 $M1$ 彻底重试失败后,SDK 才会从队列取出 $M2$ 进行下发。
  • 保证了什么:它在物理链路上保证了同一时刻只有一个消息在“飞行”。这样在服务器看来,接收到的顺序一定等于用户发送的顺序。
  1. 逻辑序号标记:ClientSeq

为了应对极端情况(比如网络切换导致旧连接没断、新连接又开了),生产框架会给每个消息包打上一个本地递增的 ClientSeq

  • 实现方式:在客户端本地数据库或内存里维护一个自增变量。
  • 作用:即使因为并发或网络重传导致 $M2$ 比 $M1$ 先到,服务端可以根据 ClientSeq 发现:“咦,我收到了序号 2,但序号 1 还没到”。
  • 服务端处理:服务端可以选择等待一会(针对极高要求的业务),或者在最终给消息分配全局 SeqID 时参考这个 ClientSeq

聊消息的已读回执功能该怎么实现

群消息设计,群消息存一份,为每个成员设置一个群消息队列,会有大量数据冗余,并不合适

如果群消息只存一份,怎么知道每个成员读了哪些消息?可以利用群消息的偏序关系,记录每个成员的last_ack_msgid(last_ack_time),这条消息之前的消息已读,这条消息之后的消息未读。该方案意味着,对于群内的每一个用户,只需要记录一个值即可。

群消息表:记录群消息

group_msgs(msgid, gid, sender_uid, time, content);

各字段的含义为:消息ID,群ID,发送方UID,发送时间,发送内容。

群成员表:记录群里的成员,以及每个成员收到的最后一条群消息

group_users(gid, uid, last_ack_msgid);

各字段的含义为:群ID,群成员UID,群成员最后收到的一条群消息ID

其整个消息发送的流程如上图:

  • 1)A发出群消息;
  • 2)server收到消息后,一来要将群消息落地,二来要查询群里有哪些群成员,以便实施推送;
  • 3)对于群成员,查询在线状态;
  • 4)对于在线的群成员,实施推送。

这个流程里,只要第二步消息落地完成,就能保证群消息不会丢失。

核心问题:如何保证接收方一定收到群消息?
**各个收到消息后,要修改各群成员的last_ack_msgid,以告诉系统,这一条消息确认收到了。

对于在线的群友,收到群消息后,第一时间会ack、修改last_ack_msgid。对于离线的群友,会在下一次登录时,拉取未读的所有群离线消息,并将last_ack_msgid修改为最新的一条消息。

如果ack丢失,群友会不会拉取重复的群消息?
会,可以根据msgid在客户端本地做去重,即使系统层面收到了重复的消息,仍然可以保证良好的用户体验。

上述流程,只能确保接收方收到消息,发送方仍然不知道哪些人在线阅读了消息,哪些人离线未阅读消息,并没有实现已读回执,那已读回执会对系统设计产生什么样的影响呢?

对于发送方发送的任何一条群消息,都需要知道,这条消息有多少人已读多少人未读,就需要一个基础表来记录这个关系。

消息回执表:用来记录消息的已读回执

msg_acks(sender_uid, msgid, recv_uid, gid,if_ack);

增加了已读回执逻辑后,群消息的流程会有细微的改变,接着,server收到消息后,除了要:

  • 1)将群消息落地;
  • 2)查询群里有哪些群成员,以便实施推送;

之外,还需要:

  • 3)插入每条消息的初始回执状态。

接收方修改last_ack_msgid的流程,会变为:

  • 1)发送ack请求;
  • 2)修改last_ack_msgid,并且,修改已读回执if_ack状态;
  • 3)查询发送方在线状态
  • 4)向发送方实时推送已读回执(如果发送方在线);

如果发送方不在线,ta会在下次登录的时候:

  • 5)从关联表里拉取每条消息的已读回执。

这里的初步结论是:

  • 如果发送方在线:会实时被推送已读回执;
  • 如果发送方不在线:会在下次在线时拉取已读回执。

群消息已读回执的“消息风暴扩散系数”,假设每个群有200个用户,其中20%的用户在线,即40各用户在线。

那么,群用户每发送一条群消息,会有:

  • 40个消息,通知给群友;
  • 40个ack修改last_ack_msgid,发给服务端;
  • 40个已读回执,通知给发送方。

群数量,群友数量,群消息数量越来越多之后,存储也会成为问题。

群消息的推送,能否改为接收方轮询拉取?
答:不能,消息接收,实时性是核心指标。

对于last_ack_msgid的修改,真的需要每个群消息都进行ack么?
答:其实不需要,可以批量ack,累计收到N条群消息(例如10条),再向服务器发送一次last_ack_msgid的修改请求,同时修改这个请求之前所有请求的已读回执,这样就能将40个发送给服务端的ack请求量,降为原来的1/10。

会带来什么副作用?
答:last_ack_msgid的作用是,记录接收方最近新取的一条群消息,如果不实时更新,可能导致,异常退出时,有一些群消息没来得及更新last_ack_msgid,使得下次登陆时,会拉取到重复的群消息。但这不是问题,客户端可以根据msgid去重,用户体验不会受影响。

发送方在线时,对于已读回执的发送,真的需要实时推送么?
答:其实不需要,发送方每发一条消息,会收到40个已读回执,采用轮询拉取(例如1分钟一次,一个小时也就60个请求),可以大大降低请求量

会带来什么副作用?
答:已读回执更新不实时,最坏的情况下,1分钟才更新回执。当然,可以根据性能与产品体验来折衷配置这个轮询时间。

如何降低数据量?
答:回执数据不是核心数据

  • 已读的消息,可以进行物理删除,而不是标记删除;
  • 超过N长时间的回执,归档或者删除掉。

对于群消息已读回执,一般来说:

  • 如果发送方在线,会实时被推送已读回执;
  • 如果发送方不在线,会在下次在线时拉取已读回执。

如果要对进行优化,可以:

  • 接收方累计收到N条群消息再批量ack;
  • 发送方轮询拉取已读回执。

在线状态同步的推与拉

保证单聊好友状态的一致性

用户uid-A登录时,如何获取自己全部好友的在线状态?

1)服务器要存储所有用户的在线状态(往往存储在保证高可用的缓存集群里)

2)用户状态实时变更,任何用户登录时,需要将服务端自己的在线状态置为online;任何用户登出时,需要将服务端自己的状态置为offline -> 保证服务端状态存储的一致性与实时性

3)uid-A登录时,先去数据库拉取自己的好友列表,再去缓存获取所有好友的在线状态 -> 保证登录时好友状态获取的一致性与实时性

用户uid-A的好友uid-B状态改变时(由登录、登出、隐身等动作触发),uid-A如何知道这一事件

方案一的逻辑:
uid-A向服务器轮询拉取uid-B(其实是自己的全部好友)的状态,例如每1分钟一次。

方案一的缺点:

  • 如果uid-B的状态改变,uid-A获取不实时,可能有1分钟时延;
  • 如果uid-B的状态不改变,uid-A会有大量无效的轮询请求,占用服务器资源。

方案二的逻辑:
uid-B状态改变时(由登录、登出、隐身等动作触发),服务器不仅在缓存中修改uid-B的状态,还要将这个状体改变的通知推送给uid-B的在线反向好友

方案二的优点:实时。
方案二的缺点:当在线好友量很大时,任何一个用户状态的改变,会扩散成N个实时通知,这个N叫做“消息风暴扩散系数”。
设一个im系统平均每个用户有200个反向好友,平均有20%的反向好友在线,那么消息风暴扩散系数N=40,这意味着,任何一个状态的变化会变成40个推送请求。

1场景一:群友状态一致性有什么不同,和好友状态一致性相比复杂在哪里?为什么不能采用实时推送?

理论上群友状态也可以通过实时推送的方式实现,以保证实时性。但实际上,群友状态一般都是采用拉取的方式获得,因为群友状态“消息风暴扩散系数”N实在太大,全部实时获取系统往往承受不了。

假设平均每个用户加了20个群,平均每个群有200个用户,依然假设20%的用户在线,那么为了保证群友状态的实时性,每个用户登录,就要将自己的状态改变通知发送给2020020%=800个群友,N=800,意味着,任何一个状态的变化会变成800个推送请求。

XXX系统使用的是群友状态推送,不存在的这样的问题?那很可能是,XXX系统的用户量和活跃度还不够高吧。

2场景二:轮询拉取群友状态也会给服务器带来过大的压力,还有什么优化方式?

群友的数据量太大,虽然每个用户平均加入了20个群,但实际上并不会每次登录都进入每一个群。不采用轮询拉取,而采用按需拉取,延时拉取的方式,在真正进入一个群时才实时拉取群友的在线状态,是既能满足用户需求(用户感觉是状态是实时、一致的,但其实是进入群才拉取的),又能降低服务器压力。这是一种常见方法。

心跳机制与断线重连机制

在使用 TCP 长连接的 IM 服务设计中,往往都会涉及到心跳。心跳一般是指某端(绝大多数情况下是客户端)每隔一定时间向对端发送自定义指令,以判断双方是否存活,因其按照一定间隔发送,类似于心跳,故被称为心跳指令。

为什么说基于TCP的移动端IM仍然需要心跳保活?使用 TCP 长连接来实现业务的最大驱动力在于:在当前连接可用的情况下,每一次请求都只是简单的数据发送和接受,免去了 DNS 解析,连接建立等时间,大大加快了请求的速度,同时也有利于接受服务器的实时消息。但前提是连接可用。

如果连接无法很好地保持,每次请求就会变成撞大运:运气好,通过长连接发送请求并收到反馈。运气差,当前连接已失效,请求迟迟没有收到反馈直到超时,又需要一次连接建立的过程,其效率甚至还不如 HTTP。而连接保持的前提必然是检测连接的可用性,并在连接不可用时主动放弃当前连接并建立新的连接。基于这个前提,必须要有一种机制用于检测连接可用性。同时移动网络的特殊性也要求客户端需要在空余时间发送一定的信令,避免连接被回收。

而对于服务器而言,能够及时获悉连接可用性也非常重要:一方面服务器需要及时清理无效连接以减轻负载,另一方面也是业务的需求,如游戏副本中服务器需要及时处理玩家掉线带来的问题。

TCP KeepAlive 的机制其实并不适用于此。Keep Alive 机制开启后,TCP 层将在定时时间到后发送相应的 KeepAlive 探针以确定连接可用性。一般时间为 7200 s,失败后重试 10 次,每次超时时间 75 s。显然默认值无法满足我们的需求。因为 TCP KeepAlive 是用于检测连接的死活,而心跳机制则附带一个额外的功能:检测通讯双方的存活状态。两者听起来似乎是一个意思,但实际上却大相径庭。

考虑一种情况,某台服务器因为某些原因导致负载超高,CPU 100%,无法响应任何业务请求,但是使用 TCP 探针则仍旧能够确定连接状态,这就是典型的连接活着但业务提供方已死的状态,对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态,一直向当前服务器发送些必然会失败的请求。

从上面我们可以知道,KeepAlive 并不适用于检测双方存活的场景,这种场景还得依赖于应用层的心跳。应用层心跳有着更大的灵活性,可以控制检测时机,间隔和处理流程,甚至可以在心跳包上附带额外信息。从这个角度而言,应用层的心跳的确是最佳实践。

应用层心跳的确是检测连接有效性,双方是否存活的最佳实践,那么剩下的问题就是怎么实现。

最简单粗暴做法当然是定时心跳,如每隔 30 秒心跳一次,15 秒内没有收到心跳回包则认为当前连接已失效,断开连接并进行重连。这种做法最直接,实现也简单。唯一的问题是比较耗电和耗流量。以一个协议包 5 个字节计算,一天收发 2880 个心跳包,一个月就是 5 2 2880 * 30 = 0.8 M 的流量,如果手机上多装几个 IM 软件,每个月光心跳就好几兆流量没了,更不用说频繁的心跳带来的电量损耗。

既然频繁心跳会带来耗电和耗流量的弊端,改进的方向自然是减少心跳频率,但也不能过于影响连接检测的实时性。基于这个需求,一般可以将心跳间隔根据程序状态进行调整,当程序在后台时(这里主要考虑安卓),尽量拉长心跳间隔,5 分钟、甚至 10 分钟都可以。

而当 App 在前台时则按照原来规则操作。连接可靠性的判断也可以放宽,避免一次心跳超时就认为连接无效的情况,使用错误积累,只在心跳超时 n 次后才判定当前连接不可用。当然还有一些小 trick 比如从收到的最后一个指令包进行心跳包周期计时而不是固定时间,这样也能够一定程度减少心跳次数

使用Netty的IdleStateHandler实现心跳机制

所谓心跳, 即在 TCP 长连接中, 客户端和服务器之间定期发送的一种特殊的数据包, 通知对方自己还在线, 以确保 TCP 连接的有效性。**心跳包还有另一个作用,经常被忽略,即:一个连接如果长时间不用,防火墙或者路由器就会断开该连接

1
2
3
public IdleStateHandler(int readerIdleTimeSeconds, int writerIdleTimeSeconds, int allIdleTimeSeconds) {
this((long)readerIdleTimeSeconds, (long)writerIdleTimeSeconds, (long)allIdleTimeSeconds, TimeUnit.SECONDS);
}
  • readerIdleTimeSeconds: 读超时. 即当在指定的时间间隔内没有从 Channel 读取到数据时, 会触发一个 READER_IDLE 的 IdleStateEvent 事件.
  • writerIdleTimeSeconds: 写超时. 即当在指定的时间间隔内没有数据写入到 Channel 时, 会触发一个 WRITER_IDLE 的 IdleStateEvent 事件.
  • allIdleTimeSeconds: 读/写超时. 即当在指定的时间间隔内没有读或写操作时, 会触发一个 ALL_IDLE 的 IdleStateEvent 事件.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* <p>在规定时间内未收到客户端的任何数据包, 将主动断开该连接</p>
*/
public class ServerIdleStateTrigger extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleState state = ((IdleStateEvent) evt).state();
if (state == IdleState.READER_IDLE) {
// 在规定时间内没有收到客户端的上行数据, 主动断开连接
ctx.disconnect();
}
} else {
super.userEventTriggered(ctx, evt);
}
}
}

IM系统消息未读数实现

实现思路大致如下:

1)每发一个消息,消息接收者的会话未读数+1,并且接收者所有未读数+1;

2)消息接收者返回消息接收确认ack后,消息未读数会-1;

3)消息接收者的未读数+1,服务端就会推算有多少条未读数的通知。

分布式锁保证总未读数和会话未读数一致:

1)原因:当总未读数增加,这个时候客户端来了请求将未知数置0,然后再增加会话未读数,那么会导致不一致;

2)保证:为了保证总未读数和会话未读数原子性,需要用分布式锁来保证。

群聊消息未读数的难点和优化思路

对于群聊来说,消息未读数的技术难点主要是:一个群聊每秒几百的并发聊天,比如消息未读数,相当于每秒W级别的写入redis,即便redis做了集群数据分片+主从,但是写入还是单节点,会有写入瓶颈。

按群ID分组或者用户ID分组,批量写入,写入的两种方式:定时flush和满多少消息进行flush。

即时通讯 : 未读消息计数_获取未读消息数量-CSDN博客

即时通讯的消息传输安全原理

对称加密:DES算法

DES即数据加密标准,这种加密算法是由IBM研究提出来的, 是一种分组密码,它用于对64比特的数据进行加密和解密。DES算法所用的密钥也是64比特,但由于其中包含了8个比特的奇偶校验位,因而实际的密钥长度是56比特。DES算法多次组合替代算法和换位算法,利用分散和错乱的相互作用,把明文编制成密码强度很高的密文。DES算法的加密和解密的流程是完全相同的,区别仅仅是加密与解密使用子密钥序列的顺序正好相反n1。DES算法属于对称加密算法,即加密和解密共享同一个密钥,主要用于解决数据机密性问题。

公开密钥算法:RSA算法

RSA算法作为惟一被广泛接受并实现的通用公共密钥加密方法,是众多阐述非对称密码体制的算法中最具代表性的,几乎成了公开密钥密码学的同义词。它是麻省理工大学的Rivest,Shamir和Adleman(RSA算法即为三人名字的缩写)于1977年研制并于1978年首次发表的一种算法。该算法的数学基础是数论的欧拉定理,它的安全性依赖于大数的因子分解的困难性,该算法至今仍没有发现严重的安全漏洞。RSA使用两个密钥,一个是公钥(PubHc Key),另一个是私钥(Private Key)加密时把明文分戍块,块的大小可变,但不超过密钥的长度。RSA把明文块转化为与密钥长度相同的密文。RSA算法还可以用于“数字签名”,即用私钥进行加密,公钥来解密。

Hash算法:MD5算法

MD5算法并不是加密算法,但却能形成信息的数字“指纹”,主要用途是确保数据没有被篡改或变化过,以保证数据的完整性。MD5算法有三个特性:

  • a)能处理任意大小的信息,并生成固定长度128位的信息摘要;
  • b)具有不可预见性,信息摘要的大小与原始信息的大小没有任何联系,原信息的每一个微小变化都会对信息摘要产生很大的影响;
  • c)具有不可逆性,没有办法通过信息摘要直接恢复原信息。

中级通信安全

image-20260217143701726

  • 客户端和服务端提前约定好加密算法,在传递消息前,先协商密钥;
  • 客户端,请求密钥;
  • 服务端,返回密钥;
  • 然后用协商密钥加密消息,传输密文。

  • 安全评估:首先,网上传输的内容是不安全的,于是乎,黑客能得到加密key=X。其次,客户端和服务端提前约定的加密算法是不安全的,于是乎,黑客能得到加密算法。于是乎,黑客截取后续传递的密文,可以用对应的算法和密钥解密;

  • 改进方案:协商的密钥不能在网络上传递。

高级通信安全

image-20260217145154678

  • 协商的密钥无需在网络传输;
  • 使用“具备用户特性的东西”作为加密密钥,例如:用户密码的散列值;
  • 一人一密,每个人的密钥不同;
  • 然后密钥加密消息,传输密文;
  • 服务端从db里获取这个“具备用户特性的东西”,解密。

  • 安全评估:用户客户端内存是安全的,属于黑客远端范畴,不能被破解。当然,用户中了木马,用户的机器被控制的情况不在此列,如果机器真被控制,监控用户屏幕就好了,就不用搞得这么麻烦了;

  • 导致后果:使用“具备用户特性的东西”作为加密密钥,一人一密,是安全的。只是,当“具备用户特性的东西”泄漏,就有潜在风险;

一次一密 密钥协商

每次通信前,进行密钥协商,一次一密。密钥协商过程,如上图所述,需要随机生成三次密钥,两次非对称加密密钥(公钥,私钥),一次对称加密密钥,简称安全信道建立的“三次握手”,在客户端发起安全信道建立请求后:

  • 服务端随机生成公私钥对(公钥pk1,私钥pk2),并将公钥pk1传给客户端:
    (注意:此时黑客能截获pk1);
  • 客户端随机生成公私钥对(公钥pk11,私钥pk22),并将公钥pk11,通过pk1加密,传给服务端:
    (注意:此时黑客能截获密文,也知道是通过pk1加密的,但由于黑客不知道私钥pk2,是无法解密的);
  • 服务端收到密文,用私钥pk2解密,得到pk11;
  • 服务端随机生成对称加密密钥key=X,用pk11加密,传给客户端:
    (注意:同理,黑客由密文无法解密出key);
  • 客户端收到密文,用私钥pk22解密,可到key=X。

至此,安全信道建立完毕,后续通讯用key=X加密,以保证信息的安全性。

参考资料

  1. 现代IM系统中聊天消息的同步和存储方案探讨 - 知乎
  2. 跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)-IM开发/专项技术区 - 即时通讯开发者社区!
  3. 【IM】如何保证消息有序性? | VankyHub
  4. 高性能网络编程(一):单台服务器并发TCP连接数到底可以有多少-网络编程/专项技术区 - 即时通讯开发者社区!
  5. 即时消息技术剖析与实战 - 极客时间文档
  6. 移动端IM中大规模群消息的推送如何保证效率、实时性?-IM开发/专项技术区 - 即时通讯开发者社区!
  7. 现代IM系统中聊天消息的同步和存储方案探讨-IM开发/专项技术区 - 即时通讯开发者社区!
  8. 基于Timeline模型构建IM消息系统的同步与存储架构-表格存储-阿里云
  9. 新手入门一篇就够:从零开发移动端IM-IM开发/专项技术区 - 即时通讯开发者社区!
  10. 《即时消息技术剖析与实战》学习笔记7——IM系统的消息未读 - 鹿呦呦 - 博客园
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道