























搜集到了关于现代即时通讯(IM)系统中一些设计方案,作为分布式集群应用的集大成者,这里进行总结学习。这些资料基本来自52im.net。在此感谢站长和作者们。
IM其实并不局限于聊天、社交这类“典型”应用中,实际上它已经广泛运用于我们身边形形色色的软件中。
聊天、直播、在线客服、物联网等所有需要实时互动、高实时性的场景等等,都需要应用到 IM 技术。

1)实时性:
对于IM系统,“实时”二字是精髓,也是这项技术存在关键意义所在。它保证的是消息的实时触达。
举个例子:如果跟你的好友微信或qq聊天,我发的消息他不能即时收到,或者他发的信息你也不知道什么时候能收到,这基本上也就没法聊下去了
2)可靠性:
保证消息的不丢失和不重复,是IM系统的另一个关键技术特点。试想,当你在用qq或微信跟女朋友聊天,好不容易鼓起勇气向“她”表白,结果这消息要是丢包了,那肯定得卸载应用了,搞不好砸手机都有可能。当然,好话不说二遍,消息重复也同样恼人。
3)一致性:
对于单聊消息而言,保证同一个设备的时间顺序、不同设备的漫游同步,也是相当重要的一环。
IM系统中的消息交互,就到底就是人跟人在“说话”,前言不搭理后言、或者胡言乱语式的消息展现,那不是人疯了就是程序疯了,总之就是没法再聊下去了。
4)安全性:
保证数据传输安全、数据存储安全、消息内容安全,也是IM系统必不可少的特性。尤其在私聊场景下,如果不能做到安全性,聊天的体验跟被人偷窥的感觉是没有区别的。
一个典型的IM功能组成,无非就是以下5样:
联系人列表看似简单,实际上它是一系列IM系统的社交关系确立动作的结果体现。
要想建立联系人列表,你可能需要实现以下逻辑:
聊天界面看似很平常,实际它就是IM系统客户端的核心功能所在,所有主要的IM功能都是通过它展现。
它应该具备的能力有:
消息发送通道
消息接收通道:
要实现一个可靠的消息接收通道,也并非易事:
6)当收到的消息时序有错乱,该怎么处理?
5)消息存储:\
消息存储这个功能好理解,聊天的消息如果存储,下次再聊的时候就不知道之前聊过什么,做不到这一点,这个IM系统的聊天体验好不起来。
那么,哪些情况下需要进行消息存储呢:
但技术落到实处,要做的事情同样少不了:
消息未读数
1)未读数是客户端实现还是服务端实现?
2)会话未读和总未读怎么保持一致?
3)多终端情况下,怎么保证未读数的一致性(我在这台设备上读没读,那台设备怎么知道的?
在“短轮询”模式下,IM客户端定时轮询服务端,以便让用户知道是否有新的聊天消息存在。
这种模式下,服务端收到请求后,即刻查询是否存在新消息,有就返回给客户端,没有则返回空并立即关闭连接。相较于前面用户需要“手动”去刷新页面的方式,这种模式只是将用户的“手动”变为“自动”而已,技术本质并没有发生任何实质性改变。
短轮询这种模式,就好比旧时代一个等待重要邮件的人,他需要每天自已跑到邮局,主动去问是否有自己的信件,有就拿回家,如果没有,则第二天继续去问。一来一去,非常低效。
技术上来说,长轮询实现的IM相较于短轮询最大的改进在于:短轮询情况下,服务端不管有没有新消息,请求结束就会立即断开连接。而长轮询时,如果本次请求没有新消息发生,不会马上断开连接并返回,而是会将本次连接“挂起”一段时间,如果在这段“挂起”时间内有新的聊天消息出现,就能马上读取并立即返回给客户端,接着结束本次连接。一段时间后又会再次发起请求,如此周而复始。
长轮询这种模式,拿上节等待邮件的这个例子来说,就好比收信的人每天到邮局去问是否有信件,如果没有,他不马上回家,而是在邮局待上一段时间,如果这段时间过去了,还是没有,就先回家,接着第二天再来。
长轮询的优点是:
长轮询的缺点是:
实际上,在Web端即时通讯技术里,长轮询有个专业的术语叫“Comet”,
在每次轮询结束和下次轮询开始的间隔期会形成“实时性盲区”。要理解纠结轮询技术的实时性缺陷,就得了解它们背后的技术——HTTP协议了。HTTP协议设计的目的,就是为了实现“请求—响应”这种模式的数据交互,也就是众所周之的“短连接”设计。而无论是短轮询还是长轮询,都跳不出HTTP的先天技术逻辑。
所以,归根到底,想要基于HTTP协议来实现IM,要达到真正的“实时性”,是相当勉强的。因为HTTP设计的目的,就是用“短连接”来简化传统TCP长连接通信带来的复杂性,而IM的实时性恰好要用到的又是TCP的长连接特性,所以这就是个悖论。
WebSocket是真正的全双式双向通信技术。
2)WebSocket一旦建立连接后,数据可以随时双向通信(即客户端可以随时向服务端发消息,服务端也可以随时通知客户端有新消息)。
1)真正的实时性:支持客户端与服务端真正的双向实时通信;
M系统的消息“可靠性”,通常就是指聊天消息投递的可靠性(准确的说,这个“消息”是广义的,因为还存用户看不见的各种指令,为了通俗,统称“消息”)。
从用户行为来讲,消息“可靠性”应该分为两种类型:
从具体的技术表现来讲,消息“可靠性”包含两层含义:
对于“消息不丢”这个特征来说,细化下来,它又包含两重含义:
典型的IM消息收发流程是 一条消息从客户端A发出后,需要先经过 IM 服务器来进行中转,然后再由 IM 服务器推送给客户端B,这种模式也是目前最常见的 IM 系统的消息分发架构。

P2P模式在IM系统中用的很少。
原因是以下两个很明显的弊端:
TCP并不能保证在线消息的“可靠性”
这次我们从客户端角度来理解,为什么使用了可靠传输协议TCP的情况下IM聊天消息仍然不可靠的问题。
数据可靠抵达网络层之后,还需要一层层往上移交处理,可能的处理有:
例如,消息可靠抵达网络层之后,写 db 之前 IM APP 崩溃(不稀奇,是 App 都有崩溃的可能),虽然数据在网络层可靠抵达了,但没存进 db,下次用户打开 App 消息自然就丢失了,如果不在业务层再增加可靠性保障(比如:后面要提到的网络层面的消息重发保障),那么意味着这条消息对于接收端来说就永远丢失了,也就自然不存在“可靠性”了。
为在线消息增加可靠性保障
那么怎样在应用层增加可靠性保障呢?有一个现成的机制可供我们借鉴:TCP协议的超时、重传、确认机制。
具体来说就是:
可以把整个过程分为两个阶段。
阶段1:clientA -> server\
无论msg-A或ack-A丢失,clientA均无法在超时时间内收到ACK,此时可以提示用户发送失败,手动进行重发。
阶段2:server -> clientB\
无论msg-B或ack-B丢失,server均无法在超时时间内收到ACK,此时需要重发msg-B,直到clientB返回ACK为止
离线消息收发流程也可划分为两个阶段:
阶段1:clientA -> server\
阶段2:server -> clientB\
显然:离线消息收发过程同样存在消息丢失的可能性。
举例来说:假设pull-res没有成功送达clientB,而offline-DB中已删除,这部分离线消息就彻底丢失了。

与初始的离线消息收发流程相比,上图增加了1-3、2-4、2-5步骤:
当离线消息的量较大时:如果对每条消息都回复ACK,无疑会大大增加客户端与服务器的通信次数。这种情况我们通常使用批量ACK的方式,对多条消息仅回复一个ACK。还可以将所有的离线消息按会话进行分组,每组回复一个ACK,假如某个ACK丢失,则只需要重传该会话的所有离线消息。
通过在应用层加入重传、确认机制后,我们确实是杜绝了消息丢失的可能性。
但由于重试机制的存在,我们会遇到一个新的问题:那就是同一条消息可能被重复发送。
举一个最简单的例子:假设client成功收到了server推送的消息,但其后续发送的ACK丢失了,那么server将会在超时后再次推送该消息,如果业务层不对重复消息进行处理,那么用户就会看到两条完全一样的消息。
消息去重的方式其实非常简单,一般是根据消息的唯一标志(id)进行过滤。
具体过程在服务端和客户端可能有所不同:
所谓的一致性,在IM中通常指的是消息时序的一致性,那就是:
再具体一点,IM消息的一致性体现在:
技术难点:没有全局时钟
一个真正堪用的生产系统,显示不可能所有服务都跑在一台服务器上,分布式环境是肯定的。
那么:在分布式环境下,客户端+服务端后台的各种后台服务,都各自分布在不同的机器上,机器之间都是使用的本地时钟,没有一个所谓的“全局时钟”(也没办法做到真正的全局时钟),那么所谓的消息时序也就没有真正意义上的时序基准点。所以消息时序问题显然不是“本地时间”可以完全决定的。
多发送方问题:服务端分布式的情况下,不能用“本地时间”来保证时序性,那么能否用接收方本地时间表示时序呢?遗憾的是,由于多个客户端的存在(比如群聊时),即使是一台服务器的本地时间,也无法表示“绝对时序”。
绝对时序上,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群里聊天,应该怎样保证所有群员收到消息的显示时序一致性呢?不能像一对一聊天那样利用发送方的绝对时序来保证消息顺序,因为群聊发送方不单点,时间也不一致。可以利用服务器的单点做序列化。
这个方法:
群消息其实也不用保证全局消息序列有序,而只要保证一个群内的消息有序即可,这样的话,“消息id序列化”就成了一个很好的思路。

群消息其实不用保证全局消息序列有序,而只要保证一个群内的消息有序即可,这样的话,“消息id序列化”就成了一个很好的思路。service层不再需要去一个统一的后端拿全局seq(序列号),而是在service连接池层面做细小的改造,保证一个群的消息落在同一个service上,这个service就可以用本地seq来序列化同一个群的所有消息,保证所有群友看到消息的时序是相同的。这样就需要全局消息ID生成方案,对于IM系统来说,绝对意义上的时序很难保证,但通过服务端生成的单调递增消息ID的方式,利用递增ID来保证时序性,也是一个很可性的方案。
一般的数据加密可以在通信的3个层次来实现:链路加密、节点加密和端到端加密。
链路加密
对于在两个网络节点间的某一次通信链路,链路加密能为网上传输的数据提供安全保证。对于链路加密(又称在线加密),所有消息在被传输之前进行加密,在每一个节点对接收到的消息进行解密,然后先使用下一个链路的密钥对消息进行加密,再进行传输。
在到达目的地之前,一条消息可能要经过许多通信链路的传输。由于在每一个中间传输节点消息均被解密后重新进行加密,因此,包括路由信息在内的链路上的所有数据均以密文形式出现,这样,链路加密就掩盖了被传输消息的源点与终点。
节点加密
尽管节点加密能给网络数据提供较高的安全性,但它在操作方式上与链路加密是类似的:两者均在通信链路上为传输的消息提供安全性,都在中间节点先对消息进行解密,然后进行加密。因为要对所有传输的数据进行加密,所以加密过程对用户是透明的。然而,与链路加密不同,节点加密不允许消息在网络节点以明文形式存在,它先把收到的消息进行解密,然后采用另一个不同的密钥进行加密,这一过程是在节点上的一个安全模块中进行。
节点加密要求报头和路由信息以明文形式传输,以便中间节点能得到如何处理消息的信息,因此这种方法对于防止攻击者分析通信业务是脆弱的。
端到端加密
端到端加密允许数据在从源点到终点的传输过程中始终以密文形式存在。采用端到端加密(又称脱线加密或包加密),消息在被传输时到达终点之前不进行解密,因为消息在整个传輸过程中均受到保护,所以即使有节点被损坏也不会使消息泄露。端到端加密系统的价格便宜些,并且与链路加密和节点加密相比更可靠,更容易设计、实现和维护。端到端加密还避免了其它加密系统所固有的同步问题,因为每个报文包均是独立被加密的,所以一个报文包所发生的传输错误不会影响后续的报文包。端到端加密系统通常不允许对消息的目的地址进行加密,这是因为每一个消息所经过的节点都要用此地址来确定如何传输消息。由于这种加密方法不能掩盖被传输消息的源点与终点,因此它对于防止攻击者分析通信业务是脆弱的。
在IM系统中,当用户A发送消息给用户B时,IM系统会生成一对公钥和私钥,并将公钥发送给用户B。用户A使用用户B的公钥对消息进行加密,然后将加密后的消息发送给用户B。在用户B接收到消息后,使用自己的私钥对消息进行解密,从而获取明文内容。由于私钥只有用户B拥有,因此除了用户B之外,任何人都无法解密消息。
通讯效率低:由于端对端加密需要对通讯数据进行加密和解密,因此可能会导致通信效率较低。
需双向支持:端对端加密需要发送方和接收方都需要支持该技术,否则就将无法实现端对端加密通信。
安全性问题:虽然端对端加密可以防止中间人攻击,但如果黑客能够获得了私钥或公钥,那么他们也能够轻易地获取到通信数据。
IM系统设计设计方方面面,这里不作细究。从功能来说包括账号,关系链,在线状态显示,消息交互等等。
一种简单架构如下

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

高并发上又可以从水平扩展,线程模型,多层缓存,长连接等优化。
高可用上包括心跳,稳定性以及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)方案一:

设计思路:
注:上述生成器可以用18年[(2^29-1)/3600/24/365]左右,一秒内最多产生4个消息。
优点:可以在断线重连和重装APP的情况下,18年之内是有序的。
缺点:每秒只能发4个消息,限制太大,对于群发场景不合适。
改进:使用long进行传输,年限扩展很久并且有序。
2)方案二:
设计思路:
优点:可以在断线重连和重装APP的情况下,接收者可以按照发送者发送时序来显示,并且对发送消息的速率没限制。
IM接入层的高可用、负载均衡、扩展性全部在这里面做。客户端通过LSB,来获取gate IP地址,通过IP直连。
这样做的目的是:
上述设计存在一个问题:就是当某个实例重启后,该实例的连接断开后,客户端会发起重连,重连就大概率转移其他实例上,导致最近启动的实例连接数较少,最早启动的实例连接数较多。
解决方法:
一个正常的消息流转需要如下图所示的流程:

如上图所示:
消息首先到达网关节点,执行以下校验:
ClientMsgId,在 Redis 中检查是否为重发请求,防止因网络抖动导致重复入库。这是流程中最关键的“逻辑中枢”:
SessionID 去 Redis 中请求一个单调递增的序号。这个 SeqID 是保证 B 侧消息不乱序的唯一标准。SeqID 等完整写入分布式数据库(如 HBase 或 MySQL)。消息存好后,需要找到 B 到底在哪台机器上:
UserId,查询其当前的在线状态及对应的 NodeID(网关节点 ID)。通过消息队列(MQ)进行服务器间的“转运”:
NodeID 命名)。ChannelID(长连接通道)找到 B 的物理连接。SeqID 的消息包推给客户端 B。为了确保“不丢失”,必须完成闭环:
SERVER_ACK 给 A,告知消息已妥投(此时 A 端的“发送中”菊花图标消失)。CLIENT_ACK,才将该消息从重传列表中删除。IM系统中最核心的部分是消息系统,消息系统中最核心的功能是消息的同步和存储:
1)消息的同步:将消息完整的、快速的从发送方传递到接收方,就是消息的同步。消息同步系统最重要的衡量指标就是消息传递的实时性、完整性以及能支撑的消息规模。从功能上来说,一般至少要支持在线和离线推送,高级的IM系统还支持『多端同步』;
2)消息的存储:消息存储即消息的持久化保存,这里不是指消息在客户端本地的保存,而是指云端的保存,功能上对应的就是『消息漫游』。『消息漫游』的好处是可以实现账号在任意端登陆查看所有历史消息,这也是高级IM系统特有的功能之一。
一种传统架构设计是消息先同步后存储

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

现代架构中最核心的就是两个消息库『消息同步库』和『消息存储库』,是消息同步和存储最核心的基础。
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内的所有数据进行持久化。

消息存储要求每个会话都对应一个独立的Timeline。如图例子所示,A与B/C/D/E/F均发生了会话,每个会话对应一个独立的Timeline,每个Timeline内存有这个会话中的所有消息,服务端会对每个Timeline进行持久化。服务端能够对所有会话Timeline中的全量消息进行持久化,也就拥有了消息漫游的能力。
消息的同步一般有读扩散和写扩散两种不同的方式,分别对应不同的Timeline物理模型。
读扩散
也叫“发件箱模型”。A 发一条消息,系统只存一份到群的 Timeline 里。B、C、D 上线时,去群的表里拉取属于自己的消息。
读扩散的消息同步模式下,每个会话中产生的新的消息,只需要写一次到其用于存储的Timeline中,接收端从这个Timeline中拉取新的消息。
优点是消息只需要写一次,相比写扩散的模式,能够大大降低消息写入次数,特别是在群消息这种场景下。但其缺点也比较明显,接收端去同步消息的逻辑会相对复杂和低效。接收端需要对每个会话都拉取一次才能获取全部消息,读被大大的放大,并且会产生很多无效的读,因为并不是每个会话都会有新消息产生。
优点:
缺点:
写扩散
写扩散的消息同步模式,也叫“收件箱模型”。,需要有一个额外的Timeline来专门用于消息同步,通常是每个接收端都会拥有一个独立的同步Timeline,用于存放需要向这个接收端同步的所有消息。
每个会话中的消息,会产生多次写,除了写入用于消息存储的会话Timeline,还需要写入需要同步到的接收端的同步Timeline。在个人与个人的会话中,消息会被额外写两次,除了写入这个会话的存储Timeline,还需要写入参与这个会话的两个接收者的同步Timeline。而在群这个场景下,写入会被更加的放大,如果这个群拥有N个参与者,那每条消息都需要额外的写N次。
写扩散同步模式的优点是,在接收端消息同步逻辑会非常简单,只需要从其同步Timeline中读取一次即可,大大降低了消息同步所需的读的压力。其缺点就是消息写入会被放大,特别是针对群这种场景。
优点:
缺点:
在IM这种应用场景下,通常会选择写扩散这种消息同步模式。
IM场景下,一条消息只会产生一次,但是会被读取多次,是典型的读多写少的场景,消息的读写比例大概是10:1。若使用读扩散同步模式,整个系统的读写比例会被放大到100:1。
一个优化的好的系统,必须从设计上去平衡这种读写压力,避免读或写任意一维触碰到天花板。所以IM系统这类场景下,通常会应用写扩散这种同步模式,来平衡读和写,将100:1的读写比例平衡到30:30。
当然写扩散这种同步模式,还需要处理一些极端场景,例如万人大群。针对这种极端写扩散的场景,会退化到使用读扩散。一个简单的IM系统,通常会在产品层面限制这种大群的存在,而对于一个高级的IM系统,会采用读写扩散混合的同步模式,来满足这类产品的需求.
现代 IM(如微信、钉钉)不会只用一种,而是动态切换:
A. 单聊与常规小群(写扩散为主)
B. 超大群/直播间(读扩散为主)
消息同步库
消息同步库用于存储所有用于消息同步的Timeline,每个Timeline对应一个接收端,主要用作写扩散模式的消息同步。
这个库不需要永久保留所有需要同步的消息,因为消息在同步到所有端后其生命周期就可以结束,就可以被回收。但是如前面所介绍的,一个实现简单的多端同步消息系统,在服务端不会保存有所有端的同步状态,而是依赖端自己主动来做同步。
所以服务端不知道消息何时可以回收,通常的做法是为这个库里的消息设定一个固定的生命周期,例如一周或者一个月,生命周期结束可被淘汰。
消息存储库
消息存储库用于存储所有会话的Timeline,每个Timeline包含了一个会话中的所有消息。这个库主要用于消息漫游时拉取某个会话的所有历史消息,也用于读扩散模式的消息同步。
数据库选型
| 消息同步库 | 消息存储库 |
|---|---|
| 高并发写,十万级TPS | 高并发写,少量读,万级TPS |
| 高并发范围读,十万级TPS | 少范围读,千级TPS |
| 保存一段时间的同步消息,TB级别。保留千万级的Timeline规模 | 保存全量信息,百TB级别。保留亿级Timeline规模 |
1)表结构设计能够满足Timeline模型的功能要求:不要求关系模型,能够实现队列模型,并能够支持生成自增的SeqId;
2)能够支持高并发写和范围读,规模在十万级TPS;
3)能够保存海量数据,百TB级;
4)能够为数据定义生命周期。
IM的客户端与服务器通过发送报文(也就是请求包)来完成消息的传递。
报文分为三种:
用户A给用户B发送一个“你好”,流程如下:

发送方client-A收到msg:A后,只能说明im-server成功接收到了消息,并不能说明client-B接收到了消息。在若干场景下,可能出现msg:N包丢失,且发送方client-A完全不知道,例如:
接收方client-B是否有收到msg:N,发送方client-A完全不可控
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-A,在收到了ack:N报文后,才能确认client-B真正接收到了“你好”。
一条消息的发送,分别包含(上)(下)两个半场,即msg的R/A/N三个报文,ack的R/A/N三个报文。一个应用层即时通讯消息的可靠投递,共涉及6个报文,这就是im系统中消息投递的最核心技术(如果某个im系统不包含这6个报文,不要谈什么消息的可靠性)。
期望六个报文完成消息的可靠投递,但实际情况下:
客户端A发送msg:R,收到msg:A后,在期待时间内如果没收到ack:N,client-A会尝试将msg:R重发。可能client-A同时发出了很多消息,故client-A需要在本地维护一个等待ack队列,并配合timer超时机制,来记录哪些消息没有收到ack:N,以定时重发。一旦收到了ack:N,说明client-B收到了“你好”消息,对应的消息将从“等待ack队列”中移除。

核心问题就是重复收到
例如,msg:N报文,ack:N报文都有可能丢失:
解决方法很简单,由发送方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个报文。
消息接收方不在线时的典型消息发送流程
消息发送出去后,无论是对方实时在线收到还是对方不在线而被服务端离线存储了,对于发送方而言只要消息没有因为网络等原因莫名消失,就应该认为是“被收到了”。
离线表设计
1 |
|
接收方B要拉取发送方A给ta发送的离线消息,只需在receiver_uid(即接收方B的用户ID), sender_uid(即发送方A的用户ID)上查询,然后把离线消息删除,再把消息返回B即可。
1 | SELECT` `msg_id, send_time, msg_type, msg_content |
整体流程
Stelp 1:用户B开始拉取用户A发送给ta的离线消息;
Stelp 2:服务器从DB(或对应的持久化容器)中拉取离线消息;
优化方案
如果用户B有很多好友,登陆时客户端需要对所有好友进行离线消息拉取,客户端与服务器交互次数就会比较多。
例如,对于每个好友,进行一次查询。
1 |
|
1.客户端先拉离线消息数量,当用户真正查看时才向服务器发送拉取请求
2.一次性拉取所有好友发送给用户B的离线消息,到客户端本地再根据sender_uid进行计算,这样的话,离校消息表的访问模式就变为->只需要按照receiver_uid来查询了。登录时与服务器的交互次数降低为了1次。

用户B一次性拉取所有好友发给ta的离线消息,消息量很大时,一个请求包很大、速度慢,容易卡顿怎么办?
可以分页拉取:根据业务需求,先拉取最新(或者最旧)的一页消息,再按需一页页拉取,这样便能很好地解决用户体验问题。

如何保证可达性,上述步骤第三步执行完毕之后,第四个步骤离线消息返回给客户端过程中,服务器挂点,路由器丢消息,或者客户端crash了,那离线消息岂不是丢了么
同在线消息的应用层ACK机制一样,离线消息拉时,不能够直接删除数据库中的离线消息,而必须等应用层的离线消息ACK,才能删除数据库中的离线消息。这个应用层的ACK可以通过实时消息通道告之服务端,也可以通过服务端提供的REST接口,以更通用、简单的方式通知服务端。
如果用户B拉取了一页离线消息,却在ACK之前crash了,下次登录时会拉取到重复的离线消息么?

拉取了离线消息却没有ACK,服务器不会删除之前的离线消息,故下次登录时系统层面还会拉取到。但在业务层面,可以根据msg_id去重。SMC理论:系统层面无法做到消息不丢不重,业务层面可以做到,对用户无感知。
假设有N页离线消息,现在每个离线消息需要一个ACK,那么岂不是客户端与服务器的交互次数又加倍了?有没有优化空间?

不用每一页消息都ACK,在拉取第二页消息时相当于第一页消息的ACK,此时服务器再删除第一页的离线消息即可,最后一页消息再ACK一次(实际上:最后一页拉取的肯定是空返回,这样可以极大地简化这个分页过程,否则客户端得知道当前离线消息的总页数,而由于消息读取延迟的存在,这个总页数理论上并非绝对不变,从而加大了数据读取不一致的可能性)。这样的效果是,不管拉取多少页离线消息,只会多一个ACK请求,与服务器多一次交互。
常见优化总结如下:
分布式IM聊天系统中,IM消息怎么做到不丢、不重、还按顺序到达?
这个问题,涉及到IM系统的两个核心:
1)消息不能丢(可靠性):比如用户点了发送,不能因为服务宕机或网络抖动,消息石沉大海。比如地铁隧道、电梯间,网络断了又连,消息不能卡住不动(要确保弱网也能用)。
2)顺序不能乱(有序性):比如“在吗?” 回成 “吗在?”,群聊时间线错乱,体验直接崩盘。

不同消息如果走不同服务节点,走不同网络路径,到达时间完全不可控,最终顺序错乱。
一个要“串行等”,一个想“并发冲”,天然冲突。
这时候有人会说:那我加个全局排序服务不就行了?
可以,但代价太大——一个中心节点最多撑几万 QPS,面对百万群聊、亿级用户,还没上线就已过载。
所以,全局有序不是解,而是枷锁。我们要的不是“天下大同”,而是“各聊各的别乱就行”。
所以根本不需要全局有序,只需要“会话内有序”。
分而治之+局部有序
具体怎么做?两步走稳:
第一步 - 业务分区:哈希分片,锁定归属
用 sessionId 做一致性哈希,确保同一个会话的所有消息始终路由到同一个处理节点。按“会话ID”做哈希,算出该消息该由哪个节点处理。同一会话 → 哈希值一样 → 路由到同一台机器 → 所有消息串行处理,天然避免跨节点乱序。这样一来,单个会话内的消息在服务端就是串行处理的,天然不会乱。
第二步 - 局部序号:独立发号,局部递增
每个会话独立维护一个计数器,每来一条消息就+1,作为它的“官方序号”。每个会话,可以配一个独立计数器(比如 Redis 的 INCR),每来一条消息就+1,生成唯一 SEQ。客户端不管什么时候收到消息,只认这个序号,按序号从小到大排列展示。这个 SEQ 就是这条消息的“官方身份证号”,客户端只认这个,不看接收时间。
1)服务端分片路由
1 | String sessionId = msg.getSessionId(); |
核心就一句:基于会话 ID 哈希取模,固定路由
2)服务端序号分配
1 | long msgSeq = redis.incr("msg_seq_" + sessionId); |
利用Redis 的 INCR,保证同一个会话下的 SEQ 绝对递增,且线程安全。同时用 sessionId_seq 作为唯一键,既能幂等去重,也能防止重试导致消息重复入库。
3)客户端排序逻辑:
最后一步,客户端收尾:别急着渲染,先排好队。
1 |
|
无论消息以什么顺序到达,统统按 seq 升序排列后再上屏。

对于客户端来说,客户端串行发送(最稳健的方案)
大多数 IM SDK(如微信、钉钉)在发送消息时,并不是并行的,而是维护了一个本地发送队列。
SERVER_ACK,或者该消息彻底超时失败,才会从队列取出消息 2 进行发送。三层兜底,像保险一样层层防
客户端兜底 —— 消息先存本地,解决网络不稳定问题
只要没收到 ACK,就当没发成功。
所以第一步不是联网,而是先把消息塞进手机本地数据库(比如 SQLite)。
1 | db.saveLocalMsg(msg); // 先落库,保命 |

再加上客户端scheduleRetry 采用阶梯式重试策略:
避免雪崩式刷屏,既保障可靠性,又不压垮服务。只有等到服务端明确说“我收到了”,才把这条消息从本地删掉。
服务端兜底 实现 服务端持久化的高可靠
客户端发来了,服务端能不能直接处理完就返回?绝对不行!如果此时机器宕机,消息还在内存里没来得及持久化,那就真的丢了。
正确做法是两步走:
伪代码如下:
1 | rocketMQ.send(msg); |
这一步的关键是:ACK 必须在落盘之后发
幂等性设计
网络可能超时、包可能丢失、ACK 可能没传回来。
用唯一键 + 幂等控制。
每个消息生成全局唯一的 key(如 sessionID:msgID),服务端通过 Redis 的原子操作判断是否已处理。
1 | String uniqueKey = msg.getUniqueKey(); |
setNx 是关键:只有 key 不存在时才设置成功,保证多实例并发下也不会重复消费。

美团Leaf-Segment和Leaf-Snowflake方案
生成全局唯一ID的系统ID要求
UUID标准型式包含32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的36个字符
优点:
性能非常高:本地生成,没有网络消耗。
缺点:
D作为主键时在特定的环境会存在一些问题,比如做DB主键的场景下,UUID就非常不适用:
Snowflake算法使用一个 64 bit 的 long 型的数字作为全局唯一 ID。其中 1 个 bit 是不用的,然后用其中的 41 bit 作为毫秒数,用 10 bit 作为工作机器 ID,12 bit 作为序列号。41 bit 可以表示的数字多达 2^41 - 1,也就是可以标识 2 ^ 41 - 1 个毫秒值,换算成年就是表示 69 年的时间。
这种方式的优缺点如下。
优点:
缺点:
强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。
数据库自增ID,这种方案的优缺点如下。
优点:
缺点:
对于MySQL性能问题,可用如下方案解决:在分布式系统中可以多部署几台机器,每台机器设置不同的初始值,且步长和机器数相等。比如有两台机器。设置步长step为2,TicketServer1的初始值为1(1,3,5,7,9,11…)、TicketServer2的初始值为2(2,4,6,8,10…)。
美团的Leaf-segment方案,实际上是在上面介绍的数据库自增ID方案上的一种改进方案,可生成全局唯一、全局有序的ID,可以用于:事务版本号、IM聊天中的增量消息、全局排序等业务中。
美团的Leaf-segment对数据库自增ID方案做了如下改变:
1 | + |
biz_tag:用来区分业务;
max_id:表示该biz_tag目前所被分配的ID号段的最大值;
step:表示每次分配的号段长度。
原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写频率变为1/seq
优点:
缺点:
Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。为此,我们希望DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。
Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,但不适用于订单ID生成场景。比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量。面对这一问题,美团技术团队实现了 Leaf-snowflake这个方案。
Leaf-snowflake方案是Twittersnowflake改进版,它完全沿用snowflake方案的bit位设计(如上图所示),即是“1+41+10+12”的方式组装ID号。
对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。
除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。这样做到了对三方组件的弱依赖。一定程度上提高了SLA。
服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:
以微信为例:可以PC端、phone端同时登录、同时收发消息。在任何一个终端的任何一个实例登录qq,都能够拉取到所有历史聊天消息,这个就是消息漫游。

接收方多点登录:pc登录、phone也登录,后一端登录不会将前一端踢出,cache中存储状态与登录点时,不再以user_id为key,改为以user_id+终端类型为key即可。
当用户A(phone端)给用户B发送消息时,除了要投递给B的所有多点登录端,还需要投递给A自已多点登陆的其他端(pc端),如上图中步骤4与步骤5。只有这样,才能在所有用户的所有端,恢复与还原双方聊天的上下文。
如果业务不需要支持“消息漫游”的功能,对于在线消息,如果用户实时接收到则是不需要存储到数据库的。但如果要支持“换一台机器(指的是用户的客户端)也能看到历史的聊天消息”,就需要对所有消息进行存储了。

消息投递如上图:用户A发送消息给用户B,虽然B在线,仍然要增加一个步骤2.5,在投递之前进行存储,以备B的其他端登陆时,可以拉取到历史消息。

消息拉取如上图:原本不在线的B(phone端),又重新登录了,他怎么拉取历史消息?只需要在客户端本地存储一个上一次拉取到的msg_id(time),到服务端重新拉取即可。这里还有个问题:由于服务端存储所有消息成本是非常高的,所以一般“消息漫游”是有时间(或者消息数)限制,不能拉取所有所有几年前的历史消息,比如只能拉取3个月内的云端消息等。
“多点登录”是指多个端同时登录一个帐号,同时收发消息,关键点是:
“消息漫游”是指一个用户在任何端,都可以拉取到历史消息,关键点是:
当用户 A 点击“撤回”时,客户端并不是发送“删除请求”,而是发送一条类型为 MSG_REVOKE 的消息包。
operatorId(操作者)、sessionId(会话ID)以及最重要的 targetMsgId(要撤回的那条消息的唯一ID)。服务端接收到撤回指令后,不会执行物理删除(DELETE),而是执行逻辑更新:
msgId,将其 msgType 修改为 REVOKED,并清空原有的文字/图片内容,改为“对方撤回了一条消息”。生成撤回信令:服务端会生成一条带有新 SeqId 的撤回通知,分发给会话中的所有参与者。
客户端的实时更新(UI 层)
当客户端(包括发送方和接收方)收到这条 REVOKED 类型的指令时:
targetMsgId,在当前聊天列表的内存缓存(List/Array)中找到那条旧消息。两个群业务的核心数据结构:
1 | 群成员表:用来描述一个群里有多少成员 |
群消息投递

群离线消息拉取

缺点:对于同一份群消息的内容,多个离线用户存储了很多份。假设群中有200个用户离线,离线消息则冗余了200份,这极大的增加了数据库的存储压力
优化1 减少存储量
为了减少离线消息的冗余度,增加一个群消息表,用来存储所有群消息的内容,离线消息表只存储用户的群离线消息msg_id,就能大大的降低数据库的冗余存储量
1 | 群消息表:用来存储一个群中所有的消息内容 |
这样优化后,群在线消息发送就做了一些修改:
拉取离线消息时也做了响应的修改:
存在的问题(如同单对单消息的发送一样):
优化2 应用层ACK

对应到群离线消息的拉取也一样:
对于离线的每一条消息,虽然只存储了msg_id,但是每个用户的每一条离线消息都将在数据库中保存一条记录
优化3 离线消息表 只需要存储最近一条拉取到的离线消息的time(或者msg_id),下次登录时拉取在那之后的所有群消息即可,而完全没有必要存储每个人未拉取到的离线消息msg_id。
1 | 群成员表:用来描述一个群里有多少成员,以及每个成员最后一条ack的群消息的msg_id(或者time) |
离线消息表优化后,群在线消息的投递流程:
群离线消息的拉取流程也类似:
由于“消息风暴扩散系数”的存在,假设1个群有500个用户,“每条”群消息都会变为500个应用层ACK,将对服务器造成巨大的冲击,有没有办法减少ACK请求量呢?
优化4 批量ACK
批量ACK的方式又有两种:
新的问题:批量ACK有可能导致:还没有来得及ACK群消息,用户就退出了,这样下次登录会拉取到重复的离线消息。
解决方案:msg_id去重,不对用户展现,保证良好的用户体验。
还可能存在的问题:群离线消息过多:拉取过慢。 解决方案:分页拉取(按需拉取),
即时消息的时序性(Message Ordering),简单来说就是保证消息的“发生顺序”与“呈现顺序”完全一致。
为什么分布式环境下即时消息时序性难以保证
假设只有一个发送方,一个接收方,上下游连接只有一条连接池,通过阻塞的方式通讯,难道不能保证先发出的消息msg1先处理么?
可以,但吞吐量会非常低,而且单发送方单接收方单连接池的假设不太成立,高并发高可用的架构不会允许这样的设计出现
生产环境下的优化方案:
保证发送端的物理序(即用户点击发送的顺序 $M1 \rightarrow M2$ 能够原样到达服务器)是 IM 系统的基本功。
在生产级框架(如微信 SDK、网易云信、钉钉等)中,这并不是通过单一手段实现的,而是通过“客户端串行队列”和“逻辑序号标记”双重机制来保证的。
这是最主流的生产级实现方式。客户端 SDK 内部并不是直接调用网络接口发送消息,而是维护了一个“待发送队列”。
SERVER_ACK,或者 $M1$ 彻底重试失败后,SDK 才会从队列取出 $M2$ 进行下发。为了应对极端情况(比如网络切换导致旧连接没断、新连接又开了),生产框架会给每个消息包打上一个本地递增的 ClientSeq。
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
其整个消息发送的流程如上图:
这个流程里,只要第二步消息落地完成,就能保证群消息不会丢失。
核心问题:如何保证接收方一定收到群消息?
**各个收到消息后,要修改各群成员的last_ack_msgid,以告诉系统,这一条消息确认收到了。
对于在线的群友,收到群消息后,第一时间会ack、修改last_ack_msgid。对于离线的群友,会在下一次登录时,拉取未读的所有群离线消息,并将last_ack_msgid修改为最新的一条消息。
如果ack丢失,群友会不会拉取重复的群消息?
会,可以根据msgid在客户端本地做去重,即使系统层面收到了重复的消息,仍然可以保证良好的用户体验。
上述流程,只能确保接收方收到消息,发送方仍然不知道哪些人在线阅读了消息,哪些人离线未阅读消息,并没有实现已读回执,那已读回执会对系统设计产生什么样的影响呢?
对于发送方发送的任何一条群消息,都需要知道,这条消息有多少人已读多少人未读,就需要一个基础表来记录这个关系。
消息回执表:用来记录消息的已读回执
msg_acks(sender_uid, msgid, recv_uid, gid,if_ack);
增加了已读回执逻辑后,群消息的流程会有细微的改变,接着,server收到消息后,除了要:
之外,还需要:
接收方修改last_ack_msgid的流程,会变为:
如果发送方不在线,ta会在下次登录的时候:
这里的初步结论是:
群消息已读回执的“消息风暴扩散系数”,假设每个群有200个用户,其中20%的用户在线,即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分钟才更新回执。当然,可以根据性能与产品体验来折衷配置这个轮询时间。
如何降低数据量?
答:回执数据不是核心数据
对于群消息已读回执,一般来说:
如果要对进行优化,可以:
用户uid-A登录时,如何获取自己全部好友的在线状态?
1)服务器要存储所有用户的在线状态(往往存储在保证高可用的缓存集群里)
2)用户状态实时变更,任何用户登录时,需要将服务端自己的在线状态置为online;任何用户登出时,需要将服务端自己的状态置为offline -> 保证服务端状态存储的一致性与实时性
3)uid-A登录时,先去数据库拉取自己的好友列表,再去缓存获取所有好友的在线状态 -> 保证登录时好友状态获取的一致性与实时性
用户uid-A的好友uid-B状态改变时(由登录、登出、隐身等动作触发),uid-A如何知道这一事件
方案一的逻辑:
uid-A向服务器轮询拉取uid-B(其实是自己的全部好友)的状态,例如每1分钟一次。
方案一的缺点:
方案二的逻辑:
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 | public IdleStateHandler(int readerIdleTimeSeconds, int writerIdleTimeSeconds, int allIdleTimeSeconds) { |
1 |
|
实现思路大致如下:
1)每发一个消息,消息接收者的会话未读数+1,并且接收者所有未读数+1;
2)消息接收者返回消息接收确认ack后,消息未读数会-1;
3)消息接收者的未读数+1,服务端就会推算有多少条未读数的通知。
分布式锁保证总未读数和会话未读数一致:
1)原因:当总未读数增加,这个时候客户端来了请求将未知数置0,然后再增加会话未读数,那么会导致不一致;
2)保证:为了保证总未读数和会话未读数原子性,需要用分布式锁来保证。
群聊消息未读数的难点和优化思路
对于群聊来说,消息未读数的技术难点主要是:一个群聊每秒几百的并发聊天,比如消息未读数,相当于每秒W级别的写入redis,即便redis做了集群数据分片+主从,但是写入还是单节点,会有写入瓶颈。
按群ID分组或者用户ID分组,批量写入,写入的两种方式:定时flush和满多少消息进行flush。
对称加密: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算法有三个特性:

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

服务端从db里获取这个“具备用户特性的东西”,解密。
安全评估:用户客户端内存是安全的,属于黑客远端范畴,不能被破解。当然,用户中了木马,用户的机器被控制的情况不在此列,如果机器真被控制,监控用户屏幕就好了,就不用搞得这么麻烦了;
每次通信前,进行密钥协商,一次一密。密钥协商过程,如上图所述,需要随机生成三次密钥,两次非对称加密密钥(公钥,私钥),一次对称加密密钥,简称安全信道建立的“三次握手”,在客户端发起安全信道建立请求后:
至此,安全信道建立完毕,后续通讯用key=X加密,以保证信息的安全性。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。