前言

又是好久没有更新了,先自责一波....本期我想开启一个新的专题,即零基础入门IM(即时聊天)系列。由于目前我本身也是做IM相关的,因此也想总结分享一下关于这个领域的知识。
IM本身其实是一个比较复杂常见的领域,每个公司都有自己的实现,如果细说可以讲出很多东西。本系列主要列出IM的常见问题,以及比较主流的方法论

IM的核心——保证消息的可靠

说到IM(即时聊天),可能大家下意识会觉得很简单,不就是把一条消息从A发给B嘛。其实不然,由于复杂的网络环境,分布式等问题。消息可能会出现丢失,时序错乱,消息重复等等问题。一个优秀的IM系统,必然要解决这些问题,消息的可靠则是一个IM系统的核心。

保证消息被送达

既然是聊天,那必然有消息的发送方与接收方。这里我们暂时先只讨论单聊的情况,不考虑群聊的情况,因此发送方与接收方都只有一个人。首先我们要确定如何才算消息被送达?根据接收方的在线与否我们可以分成两种场景:

  • 接收方在线时,需要保证消息被接收方实时拉取到
  • 接收方离线时,需要保证接收方下次上线时消息能被准确拉取到

接下来我们会对这两种场景分别讨论。

保证在线消息被送达

如何去保证一条消息能被稳定的送达呢?其实这个问题已经有很多程序猿给过我们答案了,我们可以参考TCP协议,接收方在收到发送方的每一条消息都返回一个ACK表示应答。
当然了对于IM系统而言,消息会更为复杂,因为一条消息的参与者实际上有三方:发送方,服务端,接收方

基于此我们可以把消息种类分为三类:

  1. 请求报文(request):客户端主动发送给服务器的报文
  2. 应答报文(acknowledge):服务器被动应答客户端的报文
  3. 通知报文 (notify):服务器主动发送给客户端的报文

消息送达保证机制(QoS机制)

我们可以把整个消息投递流程分成两部分去看:
一部分为发送方将消息投递给接收方,在这里我们称为Msg过程。
另一部分则为接收方对收到的消息进行应答,在这里我们称为Ack过程。

接下来的我们以ClientA给ClientB发送一条消息举例:

发送方投递消息(Msg过程)

image.png

  1. client-A向server发送一个消息请求包,即msg:request

  2. server在成功处理后,回复client-A一个消息响应包,即msg:acknowledge

  3. 如果此时client-B在线,则server主动向client-B发送一个消息通知包,即notify(当然,如果client-B不在线,则消息会存储离线)

接收方对消息进行应答(Ack过程)

image.png

  1. client-B向server发送一个ack请求包,即ack:request

  2. server在成功处理后,回复client-B一个ack响应包,即ack:acknowledge

  3. server主动向client-A发送一个ack通知包,即ack:notify

为什么需要ACK过程?

在前3步中,clientA端收到Msg(ACK)只能表示server端收到了消息,并不能保证clientB接收到了消息。因此仍然需要ClientB进行确认才能保证消息的可靠性。

完整消息送达保证机制

一个应用层即时通讯消息的可靠投递,共涉及6个报文,这就是im系统中消息投递的最核心技术

image.png

消息送达保证机制会遇到的问题

Msg(Request),Msg(ACK) 报文的丢失

代表消息发送至服务端时出现问题,此时只需发送方提示发送失败或红点处理即可。

Msg(Notify),Ack(Request),Ack(Ack),Ack(Notify)报文的丢失

此时发送方收不到期待的ACK(Notify)报文,无法确认ClientB是否收到消息。此时我们可以使用使用超时重试机制解决,即由发送方本地维护一个需要收到ACK的消息队列,来记录哪些消息没收到ACK(Notify)来定时重发。当然这样也引入了消息的重复性问题,下文也会介绍解决方案。

保证离线消息被送达

如果接受方不在线时,我们则需要保证在接收方下次上线时能再次收到该条消息,因此这些消息理所当然的会被持久化保存起来。
注:由于我所在行业的特殊性,必须持久化所有的消息(无论接收方是否在线即消息漫游)。但是在大多数IM场景中(如微信,QQ),服务端是没有必要持久化所有的消息的,因为维护这么多的消息需要很大成本,离线保存的消息在接收方最终收到后也会被删除。

我们仍然以ClientA给ClientB发送消息举例:

离线消息持久化流程

image.png

  1. client-A向server发送一个消息请求包,即msg:request

  2. server在成功处理后,回复client-A一个消息响应包,即msg:acknowledge

  3. server检查发现client-B已经离线

  4. server将client-A发送的消息持久化至数据库中

  5. server主动向client-A发送一个ack通知包,即ack:notify

离线消息拉取流程

image.png

  1. client-B上线后开始想server端请求拉取离线消息

  2. server端从数据库拉取离线消息

  3. server端返回离线消息给client-B

  4. client-B收到离线消息后,成功渲染页面无误后向服务端返回ACK,表示离线消息已收到

  5. server端将刚才拉取的离线消息从数据库中删除

为什么收到ACK后才能删除离线消息?

为了保证消息的可靠性,如果在第3步时因为网络问题数据丢失了,那么离线消息也就直接丢失了。因此一定要确保接收方已经收到了离线消息再删除。

拉取离线消息优化

上文只提到了拉取离线消息,但是究竟是拉取哪些离线消息呢?一般有两种方案:

一次只拉取一个好友的离线消息

用户上线后先拉取出所有好友的离线消息数量,当用户想点进去看某一个好友消息时再拉取该好友的离线消息。这样的好处是按需拉取,可以节省部分流量电量(如果是移动端的话)。

一次拉取所有好友的离线消息

用户上线后直接拉取出所有好友的离线消息,存储至本地客户端,当用户想看好友消息时直接客户端计算出所需要展示的消息即可。这样的好处是减少前后端交互次数。目前主流的IM(微信,QQ)都采用这种方案,毕竟用户体验会更好。当然也可能会出现拉取数据量过多的问题,不过我们可以使用优秀的编码协议(如Protocol Buffer)去缓解。

保证消息的不重复

一个健壮完善的的IM系统一定能保证消息是不重复的,试想一下如果微信是这样的你还会用吗?

小明:在吗?
小明:明天晚上有空吗?
小明:在吗?
小明:在吗?
小明:在吗?
小明:明天晚上有空吗?
小明:在吗?
小明:明天晚上有空吗?
小明:明天晚上有空吗?
小明:明天晚上有空吗?
小明:明天晚上有空吗?
女神:你有病呀!滚!!
小明: ???

上文我们有提到过,为了保证消息的送达,发送方一般会对某些没有收到ACK的消息进行定时重发。此时也会区分成两种场景

  1. 接收方没有收到发送的消息,此时超时重试机制就非常有效
  2. 接收方收到了消息,但是ACK因为网络问题丢失了,此时超时重试可能会导致接受方重复收到消息。

解决方案也非常简单,每一条消息由发送方去生成唯一的UUID,重试时也使用该UUID,由Client-B对消息进行去重即可。

保证消息的时序性

什么是消息的时序性?

要保证消息的时序性,我们先得弄懂什么才是消息的时序性,简单来说就是发送方发送顺序与接收方展现顺序一致。
举个栗子:
小明给女神发的信息是:

小明:我的兴趣爱好有:
小明:写代码
小明:打篮球
小明:唱歌
小明:你的兴趣爱好是什么?

女神收到的则是:

小明:打篮球
小明:你的兴趣爱好是什么?
小明:写代码
小明:唱歌
小明:我的兴趣爱好有:
女神:你是个好人。

如何保证消息的时序性

一般来说我们可以为消息生成单调递增的序列号(seq)来设置顺序,在消息展示时根据序列号对消息进行排序即可。
至于如何生成该序列号,也有很多种方案,如:基于db的自增,分布式id框架生成等等,感兴趣的可以自己去查阅,这里就不再展开了。

以客户端(发送方)还是服务端的时序为准?

由于网络延迟,服务端接收消息的顺序与客户端发送消息的顺序可能是不一致的(当然一般误差不会太大),因此保证消息时序性时,我们需要先界定下究竟是以客户端发送顺序为准还是服务端接收消息的顺序为准。此时我们可以根据自己的业务去判断。

针对于单聊的场景

对于单聊来说我们既可以以客户端的时序为准,也可以以服务端的时序为准。当然我更倾向于按照客户端的时序为准是更为贴切的。

针对于群聊的场景

对于群聊来说,只能以服务端的时序为准。因为发送方客户端并不是单点的,时间可能也是不一致的,因此此时应该以服务端的时序为准。

结语

现在你知道了你给女神发出的一条消息,它可能会丢,可能会重复,甚至可能会错乱,不过你已经学会了怎样去解决这些问题。
你以为这就结束了?当然远没有,传输使用TCP还是UDP?使用什么编码格式?如何实现多端同步?如何去支持群聊?想做好一个IM系统还有许多路要走,后续将会一一带来。

参考资料

Q.E.D.


吃的苦中苦,卷成王中王🏆