前面几篇文章从如何保证消息实时性方面,了解了业界常用的一些方式以及背后具体的原理。那么今天我们接着来讲一讲,在即时消息的系统架构设计里,如何来保证消息的可靠投递。
要做消息可靠投递,首先就得弄清楚,消息传输过程中,有哪些过程可能丢失,从理论上讲,在OSI的七层模型中,都存在丢失的可能。
消息丢失的七层定位
以OSI模型为框架分析典型丢包场景:
层级 | 典型问题 | 解决方案 |
---|---|---|
物理层 | 无线信号衰减 | 信道冗余(MIMO技术) |
数据链路层 | 以太网冲突 | CSMA/CD重传机制 |
网络层 | 路由黑洞 | BGP劫持检测 |
传输层 | TCP滑动窗口溢出 | 动态窗口调整算法 |
会话层 | NAT超时断开 | TCP Keepalive(默认2小时) |
表示层 | 序列化异常 | Protobuf Schema验证 |
应用层 | 业务逻辑丢弃 | 幂等校验+异步审计 |
但在实际项目工程实践中,我们大多数情况只需考虑,消息从客户端到服务端,再从服务端到客户端的宏观过程中的丢失的可能,见下图
参考上面时序图,发消息大概整体上分为两部分:
用户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却看不到消息。所以比较难处理。
上面两种情况都可能导致消息丢失,那么怎么避免这些异常情况下丢消息的问题呢?- 一般我们会用下面这些相应的解决方案:
-
针对第一部分,我们通过客户端A的超时重发和IM服务器的去重机制,基本就可以解决问题;
-
针对第二部分,业界一般参考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进行补偿发送。
需要说明的是,由于时间戳可能存在多机器时钟不同步的问题,所以可能存在一定的偏差,导致数据获取上不够精确。所以在实际的实现上,也可以使用全局的自增序列作为版本号来代替。
有TCP可靠协议为什么需要业务层ACK?
尽管TCP提供可靠传输保证,但业务场景需要更高级别的确认:
- 内核态与应用态的鸿沟:数据到达缓冲区≠业务处理成功
- 移动网络特殊性:NAT超时导致连接假活(平均发生概率12%)
- 多设备同步需求:需要端到端而不仅是点到点确认
- 业务语义明确:需要区分"已送达"与"已阅读"等状态
实测数据表明,纯依赖TCP ACK的消息系统丢失率约为0.8%,而引入业务层ACK后可将丢失率降至0.003%以下。