即时消息:如何保证消息的一致性

什么是消息一致性

所谓消息收发一致性,一般指的是消息的时序一致性,也就是保证消息不会乱序

  • 对于点对点的聊天场景,时序一致性需要保证接收方的接收顺序和发送方的发送顺序一致
  • 对于群组聊天,时序一致性保证的是群里所有接收人看到的消息展现顺序都一样

为什么保证消息的时序一致性很困难

从理论上来说,保证消息的时序一致性貌似并不难。

  • 理论上,我们想象中的消息收发场景中,只有单一的发送方、单一的接收方。
  • 如果发送方和接收方的消息收发都是单线程操作,并且和IM服务端都只有一个唯一的TCP连接来进行消息传输,IM服务端也只有一个线程来处理消息接收和消息推送。这种场景下,消息的时序一致性是比较容易得到保障的
  • 但在实际的后端工程上,由于单发送方、单接收方、单处理线程的模型吞吐量和效率都太低,基本上不太可能存在。
  • 更多的场景下,我们可能需要面对的是多发送方、多接收方、服务端多线程并发处理的情况。

知道了难点,我们再来看一看究竟在后端工程是线上,保证消息的时序一致性都存放哪些难点:

  • 消息的时序一致性其实是要求我们的消息具备“时序可比较性”,也就是消息相对某一个共同的“时序基准”可以来进行比较
  • 所以,要保证消息的时序一致性的一个关键问题是:我们是否能找到这么一个时序基准,使得我们的消息具备“时序可比较性”

在工程实现上,我们可以分成这样几步:

  • 首先是:如何找到时序基准
  • 其次是:时序基准的可用性是不是可以
  • 最后是:有了时序基准,还有其他的误差吗?有没有什么办法可以减少这个误差

如何找到时序基准

(1)发送方的本地序号和本地时钟是否可以作为“时序基准”

  • 所谓发送方的本地序号和本地时钟是指发送方在发送消息时,连同消息再携带一个本地的时间戳,或者本地维护一个序号给到IM服务端
  • IM服务端再把这个时间或者序号和消息,一起发送给消息接收方,消息接收方根据这个时间戳或者序号,来进行消息的排序

不合适,原因:

  • 发送方时钟存在较大的不稳定因素,用户可以随时调整时钟导致序号回退等问题
  • 发送方本地序号如果重装会导致序号清零,也会导致序号回退的问题
  • 类似“群聊消息”和“单用户的多点登陆”这种多发送方场景,都存在:
    • 同一时钟的某一时间点,都可能有多条消息发给同一接收对象。比如同一个群里,多个人同时发言;或者同一个用户登录两台设备,两台设备同时给某一接收方发消息。
    • 多设备间由于存在时钟不同步的问题,并不能保证设备带上来的时间是准确的,可能存在群里的用户 A 先发言,B 后发言,但由于用户 A 的手机时钟比用户 B 的慢了半分钟,如果以这个时间作为“时序基准”来进行排序,可能反而导致用户 A 的发言被认为是晚于用户 B 的。

(2)IM服务器的本地时钟是否可以作为“时序基准”

  • 发送方把消息提交给IM服务器
  • IM服务器根据自身服务器的时钟生成一个时间槽,再把消息推送给接收方时2携带这个时间戳
  • 接收方根据这个时间戳来进行消息的排序

不合适。原因:

  • 在实际工程中,IM服务都是集群化部署,集群化部署也就是很多服务器同时部署任务
  • 虽然多台服务器通过NTP时间同步服务,能降低服务集群机制键的差异到毫秒级别,但仍然还是存在一定的时间误差
  • 而且IM服务器规模相对比较大,时钟的统一性维护也比较有挑战,整体时钟很难保持极低的误差,因此一般也不能用IM服务器的本地时钟来作为消息的“时序基准”

(3)既然单机本地化的时钟或者序号都存在问题,那么如果有一个全局的时钟或者序号,是不是就能解决这个问题了呢?

如果所有消息的排序都依托于这个全局的序号,这样就不存在时钟不同步的问题了。

  • 比如如果有一个全局递增的序号生成器,应该就能避免多服务器时钟不同步的问题了,IM服务端就能通过这个序号生成器发出的序号,来作为消息排序的“时序基准”
  • 这种“全局序号生成器”可以通过多种方法来实现。比如redis的原子自增命令incr、DB自带的自增ID、类似Twitter的snowflake算法、“时间相关”的分布式序号生成服务等。

综上,M 服务端可以用全局序号生成器,来做为时序基准

如何确保“时序基准”的可用性

使用“全局序号生成器”发出的序号,来作为消息排序的“时序基准”,能解决每一条消息没有标准“生产日期”的问题,但如果是面向高并发和需要保证高可用的场景,还需要考虑这个“全局时序生成器”的可用性问题:

  • 首先,类似redis的原子自增和DB的自增ID,都要求在主库上来执行“取号”操作,而主库基本上都是单点部署,在可用性上的保障会相对较差。另外,针对高并发的取号操作这个单点的主库可能容易出现性能瓶颈。
  • 而采用类似snowflake顺丰的时间相关的分布式“序号生成器”也存在一些问题。
    • 一个是发出的号携带的时间精度有限,一般只能到秒级或者毫秒级。
    • 另外由于这种服务大多是集群化部署,携带的时间采用的服务器时间,也存在时钟不一致问题(虽然时钟同步上比控制大量的 IM 服务器也相对容易一些)

由上可知,基于“全局序号生成器”仍然存在不少问题,那是不是说基于“全局序号生成器”生成的序号来对消息进行排序的做法不可取呢?我们从后端业务实现的角度,来具体分析一下:

  • 从业务层面考虑,对于群聊和多点登录这种场景,没有必要保证全局的跨多个群的绝对时序性,只要保证某一个群的消息有序就可以了。 这样的话,如果可以针对每一个群有独立一个“ID 生成器”,能通过哈希规则把压力分散到多个主库实例上,大量降低多群共用一个“ID 生成器”的并发压力。
  • 对于大部分即时消息业务来说,产品层面可以接收消息时序上存在一定的细微误差。比如同一秒收到同一个群的多条消息,业务上是可以接收这一秒的多条消息,未严格按照“接收时的顺序”来排序的。实际上,这种细微误差对于用户来说,基本也是无感知的。

那么,对于依赖于“分布式的时间相关的ID生成器”生成的序号来进行排序,如果时间精度业务上可以接受,也是没问题的。

从之前微信对外的分享,我们可以了解到:微信的聊天和朋友圈的消息时序也是通过一个“递增”的版本号服务来进行实现的。不过这个版本号是每个用户独立空间的,保证递增,不保证连续。

微博的消息箱则是依赖“分布式的时间相关的 ID 生成器”来对私信、群聊等业务进行排序,目前的精度能保证秒间有序。

如何解决“时序基准”之外的其他误差

有了“时序基准”,是不是就能确保消息能按照“既定顺序”到达接收方呢?不一定。原因:

  • IM 服务器都是集群化部署,每台服务器的机器性能存在差异,因此处理效率有差别,并不能保证先到的消息一定可以先推送到接收方,比如有的服务器处理得慢,或者刚好碰到一次 GC,导致它接收的更早消息,反而比其他处理更快的机器更晚推送出去。
  • IM服务器接收到发送方的消息后,之后相应的处理一般是多线程进行处理的,比如“取序号”、“暂存消息”、“查询接收方连接信息”等。由于多线程处理流程,并不能保证先取到序号的消息能先到达接收方,这样的话对于多个接收方看到的消息顺序可能是不一致的。

所以,我们一般还需要端上能支持对消息的“本地整流”。我们来看一下,如何去做本地整流。

消息服务端包内整流

虽然大部分情况下,聊天、直播互动等即时消息业务能接受“小误差的消息乱序”,但某些特定场景下,可能需要IM服务能保证绝对的时序。

比如发放方的某一个行为同时触发了多条消息,而且这多条消息在业务层面需要严格按照触发的时序来投递。

一个例子:用户 A 给用户 B 发送最后一条分手消息同时勾上了“取关对方”的选项,这个时候可能会同时产生“发消息”和“取关”两条消息,如果服务端处理时,把“取关”这条信令消息先做了处理,就可能导致那条“发出的消息”由于“取关”了,发送失败的情况。

对于这种情况,我们一般可以调整实现方式,在发送方对多个请求进行业务层合并,多条消息合并成一条;也可以让发送方通过单发送线程和单TCP连接能保证两条消息有序到达。

但即使 IM 服务端接收时有序,由于多线程处理的原因,真正处理或者下推时还是可能出现时序错乱的问题,解决这种“需要保证多条消息绝对有序性”可以通过 IM 服务端包内整流来实现。

整个过程是这样的:

  • 首先生产者为每个消息包生成一个packageID,为包内的每条消息加个有序自增的seqID
  • 其次消费者根据每条消息的packageID和seqID进行整流,最终执行模块只有在一定超时时间内完整有序的收到所有消息才能执行最终操作,否则根据业务需要触发重试或者直接放弃操作
    • 服务端或者客户端收到一条消息,然后是等待后面来的消息进行排序呢还是直接就推送或显示呢?
    • 这个看需求,客户端接收到消息即使不连续一般也可以直接先显示,然后等前面的消息到了再在页面进行一次插入排序。也有的实现,如果接收到不连续的消息会尝试等待一个非常短的时间看前面的消息会不会到,如果还没到也一般会直接显示出来。

服务端包内整流大概就是这个样子,我们要做的是在最终服务器取到 TCP 连接后下推的时候,根据包的 ID,对一定时间内的消息做一个整流和排序。

packageId可以理解为一次需要保证时序的多条消息和信令的集合。比如,用户上线获取离线消息时需要推送多条消息,这多条消息就可以是一个packageId和多个不同的seqId

消息接收端整流

携带不同序号的消息到达接收端之后,可能会出现“先产生的消息后到”、“后产生的消息先到”等问题。消息接收端的整流就是究竟这样一个问题的。

消息客户端本地整流的方式可以根据具体业务的特点来实现,目前业界比较常见的实现方式也很简单,步骤如下:

  • 下推消息时,连同消息和序号一起推送给接收方
  • 接收方收到消息后进行判定,如果当前消息序号大于前一条消息的序号,就将当前消息追加到会话里
  • 否则继续向前查找倒数第二条、第三条等,一直查找到恰好小于当前推送消息的那条消息,然后插入在其后展示。

问题:感觉消息接收端整流是必做的,那服务端的整流是不是就显得多余了?

如果只是消息推送的话,接收端的整流基本就ok了,但是通道里推送的不仅仅是消息,还有信令(比如删除某一个会话的信令),这种情况服务端整流能够减少消息和信令乱序推送到接收端后导致端上逻辑异常的问题。

小结

在多发送方、多接收方、服务端多线程并发处理的情况下,保持消息时序一致性是很重要的。

保证消息的时序一致性的关键在于:需要找到一个时序基准来标识每一条消息的顺序。

这个时序基准可以通过全局的序号生成器来确定。常见的实现方式包括支持单调自增序号的资源生成,或者分布式时间相关的 ID 生成服务生成。两种方式各有一些限制,不过,你都可以根据业务自身的特征来进行选择。

有了通过时序基准确定的消息序号,由于 IM 服务器差异和多线程处理的方式,不能保证服务端的消息一定能按顺序推到接收方。我们可以通过“服务端包内整流”机制来保证需要“严格有序”批量消息的正确执行;或者,接收方根据消息序号来进行消息本地整流,从而确保多接收方的最终一致性。

问题:在即时消息收发场景中,用于保证消息接收时序的序号生成器为什么可以不是全局递增的?

  • 因为没有全局排序的需求。而且全局自增ID肯定有单点和性能问题。 我们目前的需求有两点:单聊和群聊。单聊我们可以通过针对于会话id的自增id解决,群聊通过基于群id的自增id解决
  • 这也是有业务场景决定的,这个群的消息和另一个全的消息在逻辑上是完全隔离的,只要保证消息的序号在群这一个局部范围内递增即可
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值