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

你好,我是袁武林。

在第一节的课程中,我们说到了即时消息系统中的四个重要特性,实时性、可靠性、一致性、安全性。

上一节课我们从如何保证消息实时性方面,了解了业界常用的一些方式以及背后具体的原理。那么今天我们接着来讲一讲,在即时消息的系统架构设计里,如何来保证消息的可靠投递。

首先,我们来了解一下,什么是消息的可靠投递?

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

这两个特性对于用户来讲都是非常影响体验的。我们先说一下不丢消息。

试想一下,你把辛辛苦苦攒到的零花钱打赏给了中意的“主播小姐姐”,但由于系统或者网络的问题,这条对你来说至关重要的打赏消息并没有成功投递给“主播小姐姐”,自然也就没有后续小姐姐和你一对一的互动环节了,想想是不是很悲剧?

消息重复也不用多说,谁也不愿意浪费时间在查看一遍又一遍的重复内容上。

那么在一般的IM系统的设计中,究竟是如何解决这两大难题的呢?下面我们结合一些简单的案例,来看一看“不丢消息”“消息不重复”这些能力,在技术上到底是怎么实现的。

消息丢失有哪几种情况?

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

我们可以把它和少数P2P类型区别一下,P2P类型的消息投递是直接由用户A的网络发送到用户B的网络,不经过服务端路由。

那么,我们来假设一个场景:用户A给用户B发送一条消息。接下来我们看看哪些环节可能存在丢消息的风险?

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

  • 用户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机制

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

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

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“有消息丢失”的情况,就可以重新同步或者修复丢失的数据。

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

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

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

最后,给你留一个思考题:有了TCP协议本身的ACK机制,为什么还需要业务层的ACK机制?

你可以给我留言,我们一起讨论,感谢你的收听,我们下期再见。

精选留言

  • 王棕生

    2019-09-04 10:30:21

    有了 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机制。
    作者回复

    👍,嗯,即使数据成功发送到接收方设备了,tcp层再把数据交给应用层时也可能出现异常情况,比如存储客户端的本地db失败,导致消息在业务层实际是没成功收到的。这种情况下,可以通过业务层的ack来提供保障,客户端只有都执行成功才会回ack给服务端。

    2019-09-04 19:46:03

  • 小可

    2019-09-04 08:56:25

    两个ack的作用不同,tcp的ack表征网络层消息是否送达;业务层ack是真正的业务消息是否送达和是否正确处理,达到不丢消息,消息不重复的目的,即我们要保证的消息可靠性
    作者回复

    👍

    2019-09-04 19:24:56

  • 影随

    2019-09-10 10:53:56

    老师您好,服务A向客户端B发送消息,第一次发送msg1,timestamp假设为 01(简写),序号为 01,这条消息因为某种原因,未存储时间戳和序号01,也未发送ack通知。A第二次发送msg2,timestamp为 02,序号为02,它做了存储,保存了最新的时间戳和序号。A第三次发送 msg3,此时B宕机了。 等B重启时,向A发送最新的时间戳和序号 02, 那么A发送大于02序号的消息,即 msg3, 那么 msg1如何保证不丢失呢?
    作者回复

    是的,如果只是时间戳或者“只是有序但不连续的序号”的话,是只能保证消息的时序性,不能保证消息的连续性。这种情况可以通过版本号机制来解决,通过两个版本号组成的链表(推送的每条消息携带前一条消息的版本号和当前这条消息的版本号)来检测消息的连续性和时序性。

    2019-09-10 23:29:02

  • 墙角儿的花

    2019-09-04 09:41:59

    1、回答老师的问题:TCP层的ACK只是TCP包分片的ACK,并不能代表整个应用层的消息得到应答。理论上操作系统的TCP栈肯定是知道整个TCP消息得到对方的ACK了,但是操作系统好像并没提供这种接口。发送成功的接口返回成功通常都表示为操作系统发送成功了,至于链路上有没有问题就不知道了。
    2、向老师请教下其他问题,恳请解答。
    A、如果接收方本地保存了所有曾经接收过的消息id,接收方是很方便去重,但是,如果用户clear了本地消息该怎么办,是要一直存储所有已经接收的消息id吗
    B、对于防范服务器宕机的时间戳机制,其实本质是序号,但是网络传输并不能保证服务器按序号发送的消息,低序号的就一定先于高序号的被接收方接收。所以如果高序号的已经被接收方处理且应答,而某个低序号的消息还没得到接收方应答的场景,通过序号保证完整性貌似不可取。
    作者回复

    A. 接收方本地去重只需要针对本机已经接收到的存在的消息来做就可以了,服务端接收时实际上已经会做一次存储层的去重了,只会存在没有回ack的消息导致接收方重复接收的情况,这种两次之间一般时间间隔都比较短的。
    B. 如果低序号的消息还没到,由于没有收到客户端的ack服务端会有超时重传机制会重传这条低序号的消息,另外即使这个时候用户关机不等那条消息了,再次上线时,采用版本号机制的话客户端也是可以知道消息不完整,可以触发服务端进行重推。

    2019-09-04 19:40:05

  • 飞翔

    2019-10-09 21:42:26

    老师 从客户端到服务端,服务端要对客户端发送的消息去重, 用哪个字段呀。 这个字段应该是客户端发送消息由客户端产生的吧。 那如何能保证这个字段全局唯一,而不是客户端A 产生了和客户端B 同样的这个字段? 去重的步骤是什么呢? 是去数据库查找是否有这个字段的内容嘛?
    作者回复

    一般可以通过业务层的多个字段一起来排重:比如接收方uid和内容,发送时间等,不需要客户端额外生成一个字段。去重实现上可以通过上面字段组合成生成一个hash然后根据消息收到的时间加上一个比较短的过期时间来写入到一个中央存储里。

    2019-10-10 21:00:58

  • L

    2019-10-30 09:09:54

    老师你好,关于完整性检查我有个问题。
    下次会话时用户重装了软件/清空缓存/甚至更换了设备导致本地没有上次会话的时间戳了,这时候岂不是无法获取丢失的那些消息?
    作者回复

    嗯,是个好问题,如果上线时不携带时间戳或者版本号这种情况服务端默认会返回最新的n条消息给端上,后续旧消息用户再通过翻页来触发自动拉取。

    2019-10-30 20:32:30

  • null

    2019-09-29 13:19:17

    老师,您好!

    文中提到:用户 A 等待 IM 服务器返回超时,用户 A 被提示发送失败。但可以通过重试等方式来弥补。

    我有个疑问:客户端在超时时间内没有收到响应然后重试,但实际上,请求已经在服务端成功处理了。这时用户 A 和 IM 服务器的状态就不一致了,用户 A 看到的是发送失败,而 IM 服务器却是处理成功的。

    同样的,IM 服务器在等待​ ACK 通知也存在这样的问题:IM 服务器在有限的重试次数内,一直没收到 ACK 通知,而消息却成功推送给了用户 B,IM 服务器和用户 B 的状态也不一致了。

    在有限的重试次数内(线上不可能无限重试吧?),无法得到确定的返回结果,导致客户端和服务端的状态不一致,如何解决这个问题吖?
    作者回复

    是的,对于重试不可能保证一定会成功,这些情况一般会以服务端中真实处理为准,通过多终端消息同步机制来让客户端有机会重新同步状态。比如发消息服务端处理成功,但是客户端接收响应超时'这种情况,服务端在成功处理完后会给发送方的发送设备推送当前消息的版本号,如果发送方设备没收到这个版本号,下次上线时会重新同步服务端的状态,用服务端消息进行覆盖。对于你说的第二种情况也比较简单,接收方b需要对重复接收的消息进行去重处理就可以了。

    2019-10-05 18:07:09

  • 隰有荷

    2019-09-04 19:10:17

    您好,我在读到在消息完整性检查那里时有些疑惑,如果服务端将msg2发出之后,服务端和客户端断链,导致客户端无法接收消息,那么重新连接之后,是可以发送时间戳检测进行重传的。
    但是,如果在服务端存储了发送方客户端发送的消息后,正准备将该消息推送给接收方客户端时发生宕机,那么当接收方客户端和服务端重新连接之后,服务端该如何知道自己要将之前存储的消息发送给接收方的客户端呢?
    作者回复

    用户上线的时候携带本地最新一条消息的时间戳给服务端,服务端从离线缓存里取比这个时间戳大的消息发给客户端就行了呀

    2019-09-04 19:59:28

  • 阳仔

    2019-09-04 10:55:12

    保证消息不丢失的做法:
    1、发送消息阶段通过客户端的发送重试机制,和服务端的去重,保证发送时消息不丢失不重复
    2、服务端推送阶段通过ACK确认机制和客户端去重保证推送时消息不丢失不重复
    3、最后使用时间戳的同步机制来保证消息的完整性,这个应该要在服务端无法触发重推消息时才进行的一个操作
    作者回复

    👍

    2019-09-04 19:49:16

  • 小伟

    2019-09-12 10:27:24

    思考题:TCP和业务层做在的维度不一样,故虽然两者的ACK机制原理一样,但不能相互替代。TCP的ACK完成只能说明数据包已经正确传输完毕,但不代表数据包里的数据已经被正确处理完毕。业务层的ACK就是来保证数据包里的数据正确处理完毕的。TCP的ACK完成是业务层ACK的前提,业务层ACK完成是业务规则上的保证。
    作者回复

    👍

    2019-09-12 19:34:43

  • RuBy

    2019-09-04 22:22:28

    老师,请问消息落地的话传统的redis+mysql是否会有性能瓶颈?是否会考虑leveldb(racksdb)这种持久化kv存储呢?
    作者回复

    看量级吧,我们自己的场景里mysql和hbase做为永久存储,pika作为离线消息的buffer存储 没有碰到瓶颈。

    2019-09-05 19:38:34

  • 段先森

    2019-09-04 01:44:14

    想问一下老师,一般来说对于直播的业务场景,消息存储这个环节用什么MQ会比较好些
    作者回复

    如果只是离线消息暂存的话,可以用pika或者redis,如果是消息的永久存储还是落db比较好。

    2019-09-04 19:10:28

  • Stklose

    2022-02-06 19:50:49

    对于ack与seqid设计,这里不考虑使用im会话中唯一msgid标识,而引入额外的seqid作为ack处理有什么考虑吗?
  • 芒果少侠

    2020-03-14 11:55:39

    TCP层的ACK机制只能确保传输层(或者说网络传输上)消息传递没有丢失,并不能从“业务层”角度确保消息已经完全正确投递和展示。网络包到达客户端后,可能会出现解析失败,落db失败的情况。
    作者回复

    是的👍

    2020-03-15 21:42:13

  • 时隐时现

    2019-09-26 15:51:16

    消息ID要求全局唯一且时间趋势递增吗?如果是这样,像微信和QQ这样体量的系统,要构建一个多大的消息序列集群才能满足需求?另外,微信和QQ的消息序列集群是全球唯一一个,还是多个集群并行部署?如果是全球唯一1个集群,跨半球的消息发送会不会延时很大,如果是多个集群,又如何保证时间趋势递增?
    作者回复

    消息ID要求全局唯一且时间相关,这个时间相关的精度是可以自行控制的,比如说是毫秒级有序还是秒级有序,通过内存级资源是能够实现每秒百万级发号能力的。我了解到的,类似微信这种IM,不需要支持多终端消息同步的场景,不需要通过拉取来对消息进行排序,所以实际上这个消息ID全局有序的必要性就很小了,只需要保证每个人的维度消息有序就可以,所以发号可以做到人维度。整体实现上也不需要时间相关了,只需要保证序号递增就能在接收端进行排序。腾讯整体据对外的分享,是采用多机房Set架构,其中包括海外机房,每个用户消息发送只会到所属的Set,然后通过多机房同步工具进行同步,所以消息发送方面延迟控制上应该也是有保障的。

    2019-09-27 17:53:10

  • 小袁

    2019-09-09 00:24:05

    有2个问题
    1 服务端发消息给客户端,没收到ack重试有最大次数吗?是像tcp那样一直重试直到收到ack吗?

    如果客户端有bug导致处理消息一直失败,是否会导致服务端一直在重试,然后队列就满了。

    2 收到消息后处理失败了要怎么处理,是不做等待服务端重发吗?
    作者回复

    1. 会有重试次数,毕竟即使收不到还有离线消息来补充。
    重试多次仍然失败服务端可以主动断连来避免资源消耗。
    2. 一般等着服务端重推就好了。

    2019-09-09 21:40:24

  • Better me

    2019-09-04 21:20:05

    老师您好,我想问下服务端IM先后推送两条消息msg0、msg1、msg2到客户端B,如果msg0、msg2先到达,此时客户端B应该不会更新到msg2的发送时间戳吧,而是等待msg1到达。如果此时陆续msg3、msg4消息到达,msg1还没到达,客户端是否也可像tcp一样,发送3个msg0消息到服务端IM,而不需要等待超时就让IM服务端立即重发msg1,这样可以降低延时,等到msg1到达客户端B后,可以直接将时间戳更新至最新到达消息的时间戳吧,而不是msg1的时间戳,不知道的理解的对不对,老师有时间看看
    作者回复

    首先这里要能让客户端知道有一条消息msg1还没到,所以单纯使用时间戳可能是不够的,时间戳只能代表时序,并不能判断出完整性,所以可能还需要配合连续的序号来实现感知(比如每次建连,服务端针对这条连接的所有推送从0开始自增,随消息一起下推)。至于你说的类似tcp的重传机制,这个理论上是可以这么实现的,就是复杂度上会高一些。

    2019-09-05 19:34:42

  • 长江

    2019-09-04 08:29:49

    有了 TCP 协议本身的 ACK 机制为什么还需要业务层的 ACK 机制?
    1.TCP属于传输层,而IM服务属于应用层,TCP的ACK只能保证传输层的可靠性,即A端到B端的可靠性,但是不能保证数据能够被应用层正确可靠处理,比如应用层里面的业务逻辑导致消息处理失败了,TCP层是不知道的。
    2.TCP虽然是可靠性传输协议,但是如果传输过程中,假如数据报文还没被接收端接收完毕,接收端进程服务崩溃了,而用户又不再立刻启动这个应用程序,这也会导致消息丢失的吧。
    所以不能只依靠TCP的ACK机制的。
    作者回复

    1没问题哈,对于2的话如果出现接收端进程崩溃,一般这个时候接收端APP也是处于不可用状态了,这种情况实际上也没法通过这个ACK机制来避免丢消息。可以在用户再次上线时进行完整性检查的确认,如果有消息没有被正确接收,再由服务补推。

    2019-09-04 19:24:37

  • 慎独明强

    2020-07-04 23:39:02

    对于TCP协议不怎么了解,看到这篇文章,我脑海里蹦现的是mq的消息可靠性,感觉是如出一辙. 从客户端发送方保证消息发送成功,根据消息服务端的返回响应来做重试方式. 服务端的可靠性:无非就是保证消息能持久化成功,类比这里的写入到db. 同步双写方式; 消费方的可靠性,就是利用消息的幂等处理与提交消费进度,偏移量.与这里的时间戳或者全局唯一的顺序id,真的很像... rocketmq为了保证消息的可靠性,事务消息的发送,服务端的主从架构高可用,同步双写; 消费方的幂等处理加上手动提交消费进度.服务端宕机后,也会有对应的故障机制去根据消费偏移量去恢复消息. 老师这两方面的内容应该是相通的吧.
  • LiG❄️

    2020-04-23 12:48:35

    新学习中,请问老师对于消息完整性检查 ,在什么时机下构建消息链表比较合适呢?例如:(1)服务端收到某个会话消息时,怎样获取这个会话上一条消息呢(特别是高并发情况下,确保获取的就是上一条消息)?(2)客户端做检查时,存在已收到的消息、新接收的消息,以什么条件去服务端拉取离线消息比较合适呢?还望老师解惑