保证消息的可靠投递——即时消息 ACK机制

即时消息 ACK机制:如何保证消息的可靠投递?

什么是消息的可靠投递?

站在使用者的角度来看,消息的可靠投递主要是指:消息在发送接收过程中,能够做到不丢消息、消息不重复两点

那么在一般的 IM 系统的设计中,究竟是如何解决这两大难题的呢?本文将对“不丢消息”“消息不重复”进行分析,在技术上到底是怎么实现的。

消息丢失有哪几种情况?

我们以最常见的“服务端路由中转”类型的 IM 系统为例(非 P2P),即一条消息从用户 A 发出后,需要先经过 IM 服务器来进行中转,然后再由 IM 服务器推送给用户 B,这个也是目前最常见的 IM 系统的消息分发类型。

假设有这样一个场景:用户 A 给用户 B 发送一条消息,哪些环节可能存在丢消息的风险?

保证消息的可靠投递——即时消息 ACK机制_第1张图片

参考上面时序图,发消息大概整体上分为两部分:

  • 用户 A 发送消息到 IM 服务器,服务器将消息暂存,然后返回成功的结果给发送方 A(步骤 1、2、3);
  • IM 服务器接着再将暂存的用户 A 发出的消息,推送给接收方用户 B(步骤 4)。

其中可能丢失消息的场景有下面这些。

在第一部分中,步骤 1、2、3 都可能存在失败的情况。

  • 由于用户 A 发消息是一个“请求”和“响应”的过程,如果用户 A 在把消息发送到 IM 服务器的过程中,由于网络不通等原因失败了;或者 IM 服务器接收到消息进行服务端存储时失败了;或者用户 A 等待 IM 服务器一定的超时时间,但 IM 服务器一直没有返回结果,那么这些情况用户 A 都会被提示发送失败。

  • 接下来,他可以通过重试等方式来弥补,但是应注意这里可能会导致发送重复消息的问题。

    • 比如:客户端在超时时间内没有收到响应然后重试,但实际上,请求可能已经在服务端成功处理了,只是响应慢了,因此这种情况需要服务端有去重逻辑,一般发送端针对同一条重试消息有一个唯一的 ID,便于服务端去重使用。

在第二部分中,消息在 IM 服务器存储完后,响应用户 A 告知消息发送成功了,然后 IM 服务器把消息推送给用户 B 的在线设备。

  • 在推送的准备阶段或者把消息写入到内核缓冲区后,如果服务端出现掉电,也会导致消息不能成功推送给用户 B。

这种情况实际上由于连接的 IM 服务器可能已经无法正常运转,需要通过后期的补救措施(重传等)来解决丢消息的问题。

即使我们的消息成功通过 TCP 连接给到用户 B 的设备,但如果用户 B 的设备在接收后的处理过程出现问题,也会导致消息丢失。比如:用户 B 的设备在把消息写入本地 DB 时,出现异常导致没能成功入库,这种情况下,由于网络层面实际上已经成功投递了,但用户 B 却看不到消息。所以比较难处理。

上面两种情况都可能导致消息丢失,那么怎么避免这些异常情况下丢消息的问题呢?
一般我们会用下面这些相应的解决方案:

  1. 针对第一部分,我们通过客户端 A 的超时重发和 IM 服务器的去重机制,基本就可以解决问题;

  2. 针对第二部分,业界一般参考 TCP 协议的 ACK 机制,实现一套业务层的 ACK 协议。

解决丢失的方案:业务层 ACK 机制

我们知道在 TCP 协议中,默认提供了 ACK 机制,通过一个协议自带的标准的 ACK 数据包,来对通信方接收的数据进行确认,告知通信发送方已经确认成功接收了数据。

我们要设计实现的业务层 ACK 机制也是类似,解决的是:IM 服务推送后如何确认消息是否成功送达接收方。具体实现如下图:

保证消息的可靠投递——即时消息 ACK机制_第2张图片

IM 服务器在推送消息时,携带一个标识 SID(安全标识符,类似 TCP 的 sequenceId),推送出消息后会将当前消息添加到“待 ACK 消息列表”,客户端 B 成功接收完消息后,会给 IM 服务器回一个业务层的 ACK 包,包中携带有本条接收消息的 SID,IM 服务器接收后,会从“待 ACK 消息列表”记录中删除此条消息,本次推送才算真正结束。

ACK 机制中的消息重传

如果消息推给用户 B 的过程中丢失了怎么办?比如:

  • B 网络实际已经不可达,但 IM 服务器还没有感知到;
  • 用户 B 的设备还没从内核缓冲区取完数据就崩溃了;
  • 消息在中间网络途中被某些中间设备丢掉了,TCP 层还一直重传不成功等。

以上的问题都会导致用户 B 接收不到消息。

解决这个问题的常用策略其实也是参考了 TCP 协议的重传机制。类似的,IM 服务器的“等待 ACK 队列”一般都会维护一个超时计时器,一定时间内如果没有收到用户 B 回的 ACK 包,会从“等待 ACK 队列”中重新取出那条消息进行重推。

消息重复推送的问题

刚才提到,对于推送的消息,如果在一定时间内没有收到 ACK 包,就会触发服务端的重传。收不到 ACK 的情况有两种,除了推送的消息真正丢失导致用户 B 不回 ACK 外,还可能是用户 B 回的 ACK 包本身丢了。

对于第二种情况,ACK 包丢失导致的服务端重传,可能会让接收方收到重复推送的消息。

针对这种情况,一般的解决方案是:服务端推送消息时携带一个 Sequence ID,Sequence ID 在本次连接会话中需要唯一,针对同一条重推的消息 Sequence ID 不变,接收方根据这个唯一的 Sequence ID 来进行业务层的去重,这样经过去重后,对于用户 B 来说,看到的还是接收到一条消息,不影响使用体验。

能否完全覆盖所有丢消息的场景?

由上分析可以发现,通过“ACK+ 超时重传 + 去重”的组合机制,能解决大部分用户在线时消息推送丢失的问题,那是不是就能完全覆盖所有丢消息的场景呢?

设想一下,假设一台 IM 服务器在推送出消息后,由于硬件原因宕机了,这种情况下,如果这条消息真的丢了,由于负责的 IM 服务器宕机了无法触发重传,导致接收方 B 收不到这条消息。

这就存在一个问题,当用户 B 再次重连上线后,可能并不知道之前有一条消息丢失的情况。对于这种重传失效的情况该如何处理?

补救措施:消息完整性检查

针对服务器宕机可能导致的重传失效的问题我们来分析一下,这里的问题在于:服务器机器宕机,重传这条路走不通了。

那如果在用户 B 在重新上线时,让服务端有能力进行完整性检查,发现用户 B“有消息丢失”的情况,就可以重新同步或者修复丢失的数据。

比较常见的消息完整性检查的实现机制有“时间戳比对”,具体的实现如下图:

保证消息的可靠投递——即时消息 ACK机制_第3张图片

下面我们来看一下“时间戳机制是如何对消息进行完整性检查的,我用这个例子来解释一下这个过程。

  • IM 服务器给接收方 B 推送 msg1,顺便带上一个最新的时间戳 timestamp1,接收方 B 收到 msg1 后,更新本地最新消息的时间戳为 timestamp1。
  • IM 服务器推送第二条消息 msg2,带上一个当前最新的时间戳 timestamp2,msg2 在推送过程中由于某种原因接收方 B 和 IM 服务器连接断开,导致 msg2 没有成功送达到接收方 B。
  • 用户 B 重新连上线,携带本地最新的时间戳 timestamp1,IM 服务器将用户 B 暂存的消息中时间戳大于 timestamp1 的所有消息返回给用户 B,其中就包括之前没有成功的 msg2。
  • 用户 B 收到 msg2 后,更新本地最新消息的时间戳为 timestamp2。
    通过上面的时间戳机制,用户 B 可以成功地让丢失的 msg2 进行补偿发送。

需要说明的是,由于时间戳可能存在多机器时钟不同步的问题,所以可能存在一定的偏差,导致数据获取上不够精确。所以在实际的实现上,需要考虑分布式系统中的一致性问题, 可以使用全局的自增序列作为版本号来代替。

小结

保证消息的可靠投递是 IM 系统设计中至关重要的一个环节,“不丢消息”“消息不重复”对用户体验的影响较大,我们可以通过以下手段来确保消息下推的可靠性。

  • 大部分场景和实际实现中,通过业务层的 ACK 确认和重传机制,能解决大部分推送过程中消息丢失的情况。
  • 通过客户端的去重机制,屏蔽掉重传过程中可能导致消息重复的问题,从而不影响用户体验。
  • 针对重传消息不可达的特殊场景,我们还可以通过“兜底”的完整性检查机制来及时发现消息丢失的情况并进行补推修复,消息完整性检查可以通过时间戳比对,或者全局自增序列等方式来实现。

PS:

有了 TCP 协议本身的 ACK 机制为什么还需要业务层的ACK 机制?

这个问题从操作系统(linux/windows/android/ios)实现TCP协议的原理角度来说明更合适:
1 操作系统在TCP发送端创建了一个TCP发送缓冲区,在接收端创建了一个TCP接收缓冲区;
2 在发送端应用层程序调用send()方法成功后,实际是将数据写入了TCP发送缓冲区;
3 根据TCP协议的规定,在TCP连接良好的情况下,TCP发送缓冲区的数据是“有序的可靠的”到达TCP接收缓冲区,然后回调接收方应用层程序来通知数据到达;
4 但是在TCP连接断开的时候,在TCP的发送缓冲区和TCP的接收缓冲区中可能还有数据,那么操作系统如何处理呢?
首先,对于TCP发送缓冲区中还未发送的数据,操作系统不会通知应用层程序进行处理(试想一下:send()函数已经返回成功了,后面再告诉你失败,这样的系统如何设计?太复杂了…),通常的处理手段就是直接回收TCP发送缓存区及其socket资源;
对于TCP接收方来说,在还未监测到TCP连接断开的时候,因为TCP接收缓冲区不再写入数据了,所以会有足够的时间进行处理,但若未来得及处理就发现了连接断开,仍然会为了及时释放资源,直接回收TCP接收缓存区和对应的socket资源。

总结一下就是: 发送方的应用层程序,调用send()方法返回成功的时候,数据实际是写入到了TCP的发送缓冲区,而非已经被接收方的应用层程序处理。怎么办呢?只能借助于应用层的ACK机制。

你可能感兴趣的:(IM,分布式,网络)