上一节我们实现了消息协议的定义和交互测试,这一节我们需要实现消息的可靠性。
从端到端的设计思想来看,无论底层依赖何种通信协议(无论是 TCP、UDP 还是 QUIC),业务层都必须对自己业务数据的可靠性负责。底层协议的职责是有限的:TCP 仅仅是帮我们解决了字节流在网络链路上的传输问题,保证了数据不丢包、不乱序地到达对方网卡。
但业务的可靠性远不止于此。我们需要 ACK 来确认对方业务逻辑真正处理了消息;需要重传机制来应对消息半路失踪或回执丢失的异常;需要超时机制来作为触发重传的判官。因此,在业务层实现这一套完整的可靠性闭环(ACK + 超时 + 重传 + 去重),是构建高可靠 IM 系统的必经之路,而不能仅仅寄希望于底层的 TCP 协议栈。
可靠性分析
对于消息来说,可靠性就是用户A发送消息显示发送成功之后用户B就一定可以收到,但是消息不是从A到B直接发送的,而是会经过服务端处理路由到用户B,所以这个和双端消息可靠性有点不一样,是一个三端消息可靠性。如果是从A到B直接发送的话,做消息可靠性就是直接实现一套ACK+超时+重传+去重机制就可以了,但是三端消息可靠应该怎么做呢。
可以把这个三端通信拆分为A给服务端发送的上行消息,和服务端给B的下行消息,只需要保证上行和下行消息可靠客户端A发送成功的消息就一定可以到达客户端B。上行消息可靠是说A发送的消息到达服务端后回复ACK必然会送达到对端B上,下行消息可靠是说消息从服务端下发给B时,只要服务端收到了ACK就说明B已经收到并且显示了消息,而且不重不乱不漏。
ACK机制
在项目初代设计中,消息收发设计遵循了最直觉的Webscoket开发模式,直接利用 conn.ReadMessage() 和 conn.WriteMessage() 进行数据的读取与下发。只要TCP协议是可靠的,那么经过TCP字节流解析出来的WebSocket帧就是可靠的,我的消息系统自然也是可靠的。
这样的话旧的设计没有做自己的消息可靠机制,当代码执行Conn.ReadMessage()时,websocket库帮助我们解析TCP的字节流,翻译为websocket帧,然后业务层再拿这个websocket帧反序列化为业务请求结构体,执行相关操作。但是如果解析出来请求结构体之后,服务器突然断电,进程崩溃,重启,就会产生问题,此时客户端TCP已经收到了确认,认为发送成功,但实际上服务器并没有对消息做持久化或者转发处理,就尝试了消息丢失,客户端以为消息发送出去,但是实际上对方没有收到消息。
同样Conn.WriteMessage()也是如此,调用这个方法返回成功仅表示数据被成功拷贝到了服务器自己的网卡发送缓冲区,并不意味着数据到达客户端,用户看到消息,如果数据刚刚写入缓冲区,没来得及由网卡发送,也会有上述问题,服务端函数返回成功,代码继续往下走(可能修改状态为已发送),但是客户端完全没收到,就会造成用户使用上的困扰。
因此,我们必须在业务层引入 ACK 确认机制,而不能仅仅依赖底层的 TCP 状态。TCP 协议的 ACK 仅能承诺数据包已安全抵达服务器的网卡缓冲区,它对数据内容的语义一无所知。当发生数据库写入超时、代码逻辑崩溃或服务触发限流时,传输层往往已经完成了握手确认,导致客户端误以为消息发送成功,从而产生严重的数据一致性问题。只有当发送方收到了包含特定消息 ID 的业务层 ACK 包时,才能确信这条消息不仅送到了,而且被接收端的业务逻辑处理了(如成功持久化落库、通过风控检查等)。这个业务 ACK 就像是接收端给发送端开具的“正式收据”,在此之前,发送端必须认为消息处于“未完成”状态,并做好随时触发重传的准备。
ACK超时与重传
一旦引入了ACK机制,如果一直等不到ACK怎么办?发送方不能无限期地等待下去,必须有一个超时时间,因为在网络中,发送方无法区分对方没收到消息和对方收到了但 ACK 包丢了这两种情况。因此,发送方在发出消息后,必须启动一个倒计时,如果在规定时间内部没收到ACK,发送方就必须默认之前的尝试失败了。客户端在超时时间内没收到服务端ACK的情况下,客户端不知道服务端是尚未执行,执行中还是已成功但ACK丢失,我们需要消除这种客户端未知状态,这样客户端才可以进行后续处理。为了避免因死等 TCP 重传而导致的延迟和开销,业务层必须通过 Context 设定等待底线,超时后就进行相关业务逻辑处理,比如客户端自己显示消息发送失败的红色感叹号。
那么超时之后应该怎么处理呢,现在分析。如果此时网络拥塞,丢包严重导致对端TCP缓冲区还没有收到对应字节流,此时操作系统的 TCP 协议栈会自动重传丢失的 TCP 段,业务层在超时之后不需要进行任何操作,依赖TCP的重传。然而业务层经常会遇到仅仅依赖 TCP 无法解决的场景,比如数据包确实通过 TCP 完整送达了服务器,但在服务器试图将消息写入数据库时,发生了连接池满、死锁等待超时或临时性的服务限流。此时内核的TCP协议栈认为任务已经完成,但是业务层的目标却失败了。
对于这类临时性故障,直接向用户报错会极大损害体验。因此,业务层必须捕获这些超时或错误信号,并主动发起重试。通过牺牲少许延迟来换取高可用性,屏蔽掉底层的瞬时抖动,从而实现发送必达的最终一致性承诺。只有当重试超过一定次数(如 3 次)仍然失败时,才最终认定发送失败并通知用户。
业务层的重传机制还可以实现以下特性:
TCP的重传由操作系统内核自动完成,重传时TCP必须保持连接建立状态,一旦连接断开,操作系统的 TCP 协议栈会立即丢弃发送缓冲区里所有未确认的数据包,并报错给上层应用,此时就会产生如下场景:用户发了一条消息,刚发出去一半,手机从wifi切换到流量,TCP 连接断开重建。此时,操作系统会把没发完的数据直接扔掉,而不是存起来等你下次联网再发。 这时候,如果业务层没有重传机制,这条消息就彻底丢了。
业务层如果实现了跨TCP连接的重传机制,当 TCP 连接断开后,业务层的定时器依然在运行。等到客户端网络恢复、重新建立新的 TCP 连接,业务层会检测到用户有消息还没发送出去,于是,它会利用新的TCP连接 ,把那条老消息重新发送一次。还是刚才那个进电梯的例子,用户进电梯,连接断了。出电梯,手机自动重连服务器。客户端 App 的业务逻辑发现刚才那条消息没成功,于是通过新连接自动补发。用户看到的效果就是:消息转圈圈(发送中)-> 进电梯 -> 出电梯 -> 消息发送成功。
总结来说:
- ACK:核心目的是 确保服务端真正执行了请求 (不仅仅是收到了数据),从而强制客户端和服务端的状态保持一致。
- 超时:是为了打破无限等待的僵局 。当网络或服务出现异常导致ACK丢失时,客户端不能一直卡死,必须设定一个时间底线,一旦超时,客户端就需要介入处理(比如准备报错或触发重试)。
- 重传:主要是为了优化用户体验 ,掩盖网络波动或服务瞬时抖动带来的影响,它与超时机制紧密配合——在最终判定失败(显示红色感叹号)之前,先在后台静默重试几次。只有当重试多次仍未收到ACK时,才最终放弃并告知用户发送失败。
去重
我们在发送端引入了超时重传机制后,就必须在接收端处理重传后的消息,如果接收端不加分辨地处理这些重传请求,会导致用户看到两条一样的消息,数据库插入重复记录。这部分就是至多一次,至少一次和恰好一次的区别了。
实现恰好一次的业务语义需要在接收端实现幂等控制,每一条消息在由客户端生成时必须携带一个全局唯一的ID,这个 ID 伴随消息的整个生命周期,无论重传多少次,ID 始终不变。接收端维护一个去重表,当收到新消息时,先用 Message ID 去查这张表,如果是新 ID,则执行业务逻辑(落库、转发),并将该 ID 写入去重表,如果发现 ID 已存在,说明这是个重复包。当检测到重复消息时,直接丢弃消息体(不进行业务处理),但 必须再次向发送方回复 ACK ,发送方之所以重传,是因为他没收到 ACK,如果你只是静默丢弃,就会引起客户端的更多次重传,所以需要回复ACK。
上行消息可靠
为了确保服务端向客户端发送消息保证收到,客户端在发送消息前,生成全局唯一的Message UUID,将消息按顺序存入本地的Pending Queue,然后通过TCP投递消息,服务端收到消息后,立即执行业务逻辑,无论业务成功与否,必须向客户端回复一个包含该 Message UUID 的 ACK 包确认收到。客户端收到ACK之后,从Pending Queue中移除该消息,标志发送流程结束。客户端的定时器检测到某条消息超过阈值(如 5s)仍未收到 ACK,则判定为“潜在失败”,触发重传。由于重传机制可能导致服务端收到重复消息,服务端必须维护一个已处理消息表,收到消息之后查表,如果存在就静默丢弃 业务载荷,但 必须再次补发ACK以终止客户端的重传循环。
关于上行消息是否会乱序的问题,我们知道在同一个 TCP 长连接中,TCP 协议栈底层保证了字节流的严格有序性。只要客户端是串行写入(单线程或加锁顺序写入),服务端收到的顺序必然与发送顺序一致,不会乱序。如果我先发送消息A再发送消息B,由于连接有序性服务端一定是先收到消息A再收到消息B,gateWay收到消息之后对应FD被触发,之后把这个fd给协程池中的worker处理,worker按照顺序读取FD缓冲区中的数据,客户端发送消息的顺序全部体现在缓冲区的TCP有序性中了。又由于对每条解析出来的消息的调用是同步调用,不会并发处理消息,每次StateServer返回结果之后才会再读下一个消息进行处理,所以服务端处理顺序一定是有序的。
然后上面的分析高度依赖于“连接不断开”且“处理逻辑不并发”这两个核心前提,一旦涉及到跨连接的业务连续性(如信号断开后通过新连接补发消息),或者未来为了提升吞吐量将 StateServer 升级为协程池并发处理模式,原本靠 TCP 维持的天然队形就会因为不同连接的物理竞争或不同 Worker 处理速度的差异(如 A 消息涉及慢查询、B 消息命中缓存)而导致最终落库的业务顺序错乱。所以如果要实现弱网环境下消息可靠传输的话依旧要实现消息序列号机制来确保消息顺序正确。
下行消息可靠
下行消息是服务端推送给客户端的消息链路,那么下行消息是否可以照搬上行消息那一套呢,下面我们进行分析。
如果用户在线的话,理论上服务端可以模仿上行逻辑,为每个用户维护一个 Pending Queue 并配合超时重传机制。但这种做法在海量并发场景下存在严重的性能隐患。如果采用每条消息对应一个 Timer的策略,当网络抖动导致大面积 ACK 延迟时,服务端内存中会堆积海量的 Timer 对象和待确认消息队列。这不仅会消耗巨大的内存,还会因为频繁的定时器调度拖慢 CPU,导致整体吞吐量雪崩。而且移动端网络极不稳定,TCP 连接常处于假死状态(Client 已断网,但 Server 感知滞后),此时服务端进行的每一次重传都是在做无用功。等到用户真正重连上来时,往往已经是一个全新的 TCP 连接,旧连接上的重试队列难以迁移,反而增加了架构复杂度。
为了规避服务端重传带来的资源问题,我们可以采用推拉结合的策略,将可靠性的保障重心从服务端的定时重传转移到了客户端的主动同步上。
当用户处于离线状态时,服务端产生的新消息显然无法实时触达。此时,我们不能让消息在内存中无限堆积,而是必须将其持久化存储到数据库,并且为每条消息分配一个全局递增的 Sequence ID 。当用户重新上线时,客户端并不需要被动等待服务端重放错过的消息,而是根据本地存储的 MaxSeq (最大已读序列号),主动向服务端发起一个 SyncRequest (同步请求):我本地最新的消息时Seq=100,请把 100 之后的消息都给我,之后服务端就查询数据库,一次性打包返回 Seq 101 到最新(如 105)的所有消息,收到消息包,更新本地 MaxSeq=105 ,完成同步。
现在解决了离线和重新上线的问题,我们回过头来看在线场景,会发现一个绝妙的解法:利用 Sequence ID 实现推拉结合的空洞检测机制。既然我们已经为每条下行消息分配了严格递增的 Sequence ID,那么在线消息的丢失问题,本质上就变成了Sequence ID 不连续的问题,我们可以不再依赖笨重的服务端重传,而是将可靠性检查的责任下放到客户端。
当服务端产生新消息时尝试将消息通过Websocket推送给客户端,服务端不开启定时器,不关心是否送达,客户端收到推送的消息 Seq=103 后,立即对比本地的 MaxSeq (假设是 101 ),如果连续 ( Seq == MaxSeq + 1 ):说明消息没丢,直接展示并更新 MaxSeq 。如果出现空洞的话就说明有消息丢了,一旦检测到空洞,客户端立即复用离线时的同步逻辑,主动向服务端发送 SyncRequest(Start=101) 。服务端收到请求后,会把丢失的 102 (甚至可能还有刚才推送失败的 103 )一并打包返回。
如果丢失的是最新的一条消息,或者连续多条消息全部丢失,客户端根本收不到更新的 Seq,也就无法感知到空洞的存在,从而导致消息静默丢失。此时我们需要引入心跳机制完善这部分,客户端在定期发送的心跳包(Ping)中,携带本地当前的 MaxSeq ,服务端收到心跳后,将其与数据库中该用户的最新 Seq 进行比对。一旦发现服务端的数据更新(ServerSeq > ClientMaxSeq),服务端可以在心跳响应(Pong)中提示客户端,或者直接触发一次推送。这样,即使推送彻底失败,最晚在下一次心跳周期(如 30 秒)内,客户端也能感知到数据落后并主动发起同步,从而实现 100% 的消息可靠性闭环。
主要思想差不多就是这样,详细细节等待后面实现的时候再讨论。 上一节我们实现了消息协议的定义和交互测试,这一节我们需要实现消息的可靠性。
从端到端的设计思想来看,无论底层依赖何种通信协议(无论是 TCP、UDP 还是 QUIC),业务层都必须对自己业务数据的可靠性负责。底层协议的职责是有限的:TCP 仅仅是帮我们解决了字节流在网络链路上的传输问题,保证了数据不丢包、不乱序地到达对方网卡。
但业务的可靠性远不止于此。我们需要 ACK 来确认对方业务逻辑真正处理了消息;需要重传机制来应对消息半路失踪或回执丢失的异常;需要超时机制来作为触发重传的判官。因此,在业务层实现这一套完整的可靠性闭环(ACK + 超时 + 重传 + 去重),是构建高可靠 IM 系统的必经之路,而不能仅仅寄希望于底层的 TCP 协议栈。
可靠性分析
对于消息来说,可靠性就是用户A发送消息显示发送成功之后用户B就一定可以收到,但是消息不是从A到B直接发送的,而是会经过服务端处理路由到用户B,所以这个和双端消息可靠性有点不一样,是一个三端消息可靠性。如果是从A到B直接发送的话,做消息可靠性就是直接实现一套ACK+超时+重传+去重机制就可以了,但是三端消息可靠应该怎么做呢。
可以把这个三端通信拆分为A给服务端发送的上行消息,和服务端给B的下行消息,只需要保证上行和下行消息可靠客户端A发送成功的消息就一定可以到达客户端B。上行消息可靠是说A发送的消息到达服务端后回复ACK必然会送达到对端B上,下行消息可靠是说消息从服务端下发给B时,只要服务端收到了ACK就说明B已经收到并且显示了消息,而且不重不乱不漏。
ACK机制
在项目初代设计中,消息收发设计遵循了最直觉的Webscoket开发模式,直接利用 conn.ReadMessage() 和 conn.WriteMessage() 进行数据的读取与下发。只要TCP协议是可靠的,那么经过TCP字节流解析出来的WebSocket帧就是可靠的,我的消息系统自然也是可靠的。
旧的设计没有做自己的消息可靠机制,当代码执行Conn.ReadMessage()时,websocket库帮助我们解析TCP的字节流,翻译为websocket帧,然后业务层再拿这个websocket帧反序列化为业务请求结构体,执行相关操作。但是如果解析出来请求结构体之后,服务器突然断电,进程崩溃,重启,就会产生问题,此时客户端TCP已经收到了确认,认为发送成功,但实际上服务器并没有对消息做持久化或者转发处理,就尝试了消息丢失,客户端以为消息发送出去,但是实际上对方没有收到消息。
同样Conn.WriteMessage()也是如此,调用这个方法返回成功仅表示数据被成功拷贝到了服务器自己的网卡发送缓冲区,并不意味着数据到达客户端,用户看到消息,如果数据刚刚写入缓冲区,没来得及由网卡发送,也会有上述问题,服务端函数返回成功,代码继续往下走(可能修改状态为已发送),但是客户端完全没收到,就会造成用户使用上的困扰。
因此,我们必须在业务层引入 ACK 确认机制,而不能仅仅依赖底层的 TCP 状态。TCP 协议的 ACK 仅能承诺数据包已安全抵达服务器的网卡缓冲区,它对数据内容的语义一无所知。当发生数据库写入超时、代码逻辑崩溃或服务触发限流时,传输层往往已经完成了握手确认,导致客户端误以为消息发送成功,从而产生严重的数据一致性问题。只有当发送方收到了包含特定消息 ID 的业务层 ACK 包时,才能确信这条消息不仅送到了,而且被接收端的业务逻辑处理了(如成功持久化落库、通过风控检查等)。这个业务 ACK 就像是接收端给发送端开具的“正式收据”,在此之前,发送端必须认为消息处于“未完成”状态,并做好随时触发重传的准备。
ACK超时与重传
一旦引入了ACK机制,如果一直等不到ACK怎么办?发送方不能无限期地等待下去,必须有一个超时时间,因为在网络中,发送方无法区分对方没收到消息和对方收到了但 ACK 包丢了这两种情况。因此,发送方在发出消息后,必须启动一个倒计时,如果在规定时间内部没收到ACK,发送方就必须默认之前的尝试失败了。客户端在超时时间内没收到服务端ACK的情况下,客户端不知道服务端是尚未执行,执行中还是已成功但ACK丢失,我们需要消除这种客户端未知状态,这样客户端才可以进行后续处理。为了避免因死等 TCP 重传而导致的延迟和开销,业务层必须通过 Context 设定等待底线,超时后就进行相关业务逻辑处理,比如客户端自己显示消息发送失败的红色感叹号。
那么超时之后应该怎么处理呢,现在分析。如果此时网络拥塞,丢包严重导致对端TCP缓冲区还没有收到对应字节流,此时操作系统的 TCP 协议栈会自动重传丢失的 TCP 段,业务层在超时之后不需要进行任何操作,依赖TCP的重传。然而业务层经常会遇到仅仅依赖 TCP 无法解决的场景,比如数据包确实通过 TCP 完整送达了服务器,但在服务器试图将消息写入数据库时,发生了连接池满、死锁等待超时或临时性的服务限流。此时内核的TCP协议栈认为任务已经完成,但是业务层的目标却失败了。
对于这类临时性故障,直接向用户报错会极大损害体验。因此,业务层必须捕获这些超时或错误信号,并主动发起重试。通过牺牲少许延迟来换取高可用性,屏蔽掉底层的瞬时抖动,从而实现发送必达的最终一致性承诺。只有当重试超过一定次数(如 3 次)仍然失败时,才最终认定发送失败并通知用户。
业务层的重传机制还可以实现以下特性。
TCP的重传由操作系统内核自动完成,重传时TCP必须保持连接建立状态,一旦连接断开,操作系统的 TCP 协议栈会立即丢弃发送缓冲区里所有未确认的数据包,并报错给上层应用,此时就会产生如下场景:用户发了一条消息,刚发出去一半,进电梯没信号了,TCP 连接断开。此时,操作系统会把没发完的数据直接扔掉,而不是存起来等你下次联网再发。 这时候,如果业务层没有重传机制,这条消息就彻底丢了。
业务层如果实现了跨TCP连接的重传机制,当 TCP 连接断开后,业务层的定时器依然在运行。等到客户端网络恢复、重新建立新的 TCP 连接,业务层会检测到用户有消息还没发送出去,于是,它会利用新的TCP连接 ,把那条老消息重新发送一次。还是刚才那个进电梯的例子,用户进电梯,连接断了。出电梯,手机自动重连服务器。客户端 App 的业务逻辑发现刚才那条消息没成功,于是通过新连接自动补发。用户看到的效果就是:消息转圈圈(发送中)-> 进电梯 -> 出电梯 -> 消息发送成功。
总结来说:
- ACK:核心目的是 确保服务端真正执行了请求 (不仅仅是收到了数据),从而强制客户端和服务端的状态保持一致。
- 超时:是为了打破无限等待的僵局 。当网络或服务出现异常导致ACK丢失时,客户端不能一直卡死,必须设定一个时间底线,一旦超时,客户端就需要介入处理(比如准备报错或触发重试)。
- 重传:主要是为了优化用户体验 ,掩盖网络波动或服务瞬时抖动带来的影响,它与超时机制紧密配合——在最终判定失败(显示红色感叹号)之前,先在后台静默重试几次。只有当重试多次仍未收到ACK时,才最终放弃并告知用户发送失败。
去重
我们在发送端引入了超时重传机制后,就必须在接收端处理重传后的消息,如果接收端不加分辨地处理这些重传请求,会导致用户看到两条一样的消息,数据库插入重复记录。这部分就是至多一次,至少一次和恰好一次的区别了。
实现恰好一次的业务语义需要在接收端实现幂等控制,每一条消息在由客户端生成时必须携带一个全局唯一的ID,这个 ID 伴随消息的整个生命周期,无论重传多少次,ID 始终不变。接收端维护一个去重表,当收到新消息时,先用 Message ID 去查这张表,如果是新 ID,则执行业务逻辑(落库、转发),并将该 ID 写入去重表,如果发现 ID 已存在,说明这是个重复包。当检测到重复消息时,直接丢弃消息体(不进行业务处理),但 必须再次向发送方回复 ACK ,发送方之所以重传,是因为他没收到 ACK,如果你只是静默丢弃,就会引起客户端的更多次重传,所以需要回复ACK。
上行消息可靠
为了确保服务端向客户端发送消息保证收到,客户端在发送消息前,生成全局唯一的Message UUID,将消息按顺序存入本地的Pending Queue,然后通过TCP投递消息,服务端收到消息后,立即执行业务逻辑,无论业务成功与否,必须向客户端回复一个包含该 Message UUID 的 ACK 包确认收到。客户端收到ACK之后,从Pending Queue中移除该消息,标志发送流程结束。客户端的定时器检测到某条消息超过阈值(如 5s)仍未收到 ACK,则判定为“潜在失败”,触发重传。由于重传机制可能导致服务端收到重复消息,服务端必须维护一个已处理消息表,收到消息之后查表,如果存在就静默丢弃 业务载荷,但 必须再次补发ACK以终止客户端的重传循环。
关于上行消息是否会乱序的问题,我们知道在同一个 TCP 长连接中,TCP 协议栈底层保证了字节流的严格有序性。只要客户端是 串行写入 (单线程或加锁顺序写入),服务端收到的顺序必然与发送顺序一致,不会乱序。如果我先发送消息A再发送消息B,由于连接有序性服务端一定是先收到消息A再收到消息B,gateWay收到消息之后对应FD被触发,之后把这个fd给协程池中的worker处理,worker按照顺序读取FD缓冲区中的数据,客户端发送消息的顺序全部体现在缓冲区的TCP有序性中了,又由于对grpc的调用是同步调用,每次StateServer返回结果之后才会再读下一个消息进行处理,所以服务端处理顺序一定是有序的。
在当前架构中其实并不需要上行消息的序列号,因为只是对于同一个长连接的操作,而且服务端不是并发的,操作完一个消息才会操作下一个,所以当前架构不需要上行消息序列号,如果后面把gateWay转发给StateServer的架构升级为并发的话就需要序列号了,因为会有落库和处理速度快慢的问题。
下行消息可靠
下行消息是服务端推送给客户端的消息链路,那么下行消息是否可以照搬上行消息那一套呢,下面我们进行分析。
如果用户在线的话,理论上服务端可以模仿上行逻辑,为每个用户维护一个 Pending Queue 并配合超时重传机制。但这种做法在海量并发场景下存在严重的性能隐患。如果采用每条消息对应一个 Timer的策略,当网络抖动导致大面积 ACK 延迟时,服务端内存中会堆积海量的 Timer 对象和待确认消息队列。这不仅会消耗巨大的内存,还会因为频繁的定时器调度拖慢 CPU,导致整体吞吐量雪崩。而且移动端网络极不稳定,TCP 连接常处于“假死”状态(Client 已断网,但 Server 感知滞后),此时服务端进行的每一次重传都是在做无用功。等到用户真正重连上来时,往往已经是一个全新的 TCP 连接,旧连接上的重试队列难以迁移,反而增加了架构复杂度。
为了规避服务端重传带来的资源问题,我们可以采用推拉结合的策略,将可靠性的保障重心从服务端的定时重传转移到了客户端的主动同步上。
当用户处于离线状态时,服务端产生的新消息显然无法实时触达。此时,我们不能让消息在内存中无限堆积,而是必须将其持久化存储到数据库,并且为每条消息分配一个全局递增的 Sequence ID 。当用户重新上线时,客户端并不需要被动等待服务端重放错过的消息,而是根据本地存储的 MaxSeq (最大已读序列号),主动向服务端发起一个 SyncRequest (同步请求):我本地最新的消息时Seq=100,请把 100 之后的消息都给我,之后服务端就查询数据库,一次性打包返回 Seq 101 到最新(如 105)的所有消息,收到消息包,更新本地 MaxSeq=105 ,完成同步。
现在解决了离线和重新上线的问题,我们回过头来看在线场景,会发现一个绝妙的解法:利用 Sequence ID 实现推拉结合的空洞检测机制。既然我们已经为每条下行消息分配了严格递增的 Sequence ID,那么在线消息的丢失问题,本质上就变成了Sequence ID 不连续的问题。我们可以不再依赖笨重的服务端重传,而是将可靠性检查的责任下放到客户端。
当服务端产生新消息时尝试将消息通过Websocket推送给客户端,服务端不开启定时器,不关心是否送达,客户端收到推送的消息 Seq=103 后,立即对比本地的 MaxSeq (假设是 101 ),如果连续 ( Seq == MaxSeq + 1 ):说明消息没丢,直接展示并更新 MaxSeq 。如果出现空洞的话就说明有消息丢了,一旦检测到空洞,客户端立即复用离线时的同步逻辑,主动向服务端发送 SyncRequest(Start=101) 。服务端收到请求后,会把丢失的 102 (甚至可能还有刚才推送失败的 103 )一并打包返回。
如果丢失的是最新的一条消息,或者连续多条消息全部丢失,客户端根本收不到更新的 Seq,也就无法感知到空洞的存在,从而导致消息静默丢失。此时我们需要引入心跳机制完善这部分,客户端在定期发送的心跳包(Ping)中,携带本地当前的 MaxSeq ,服务端收到心跳后,将其与数据库中该用户的最新 Seq 进行比对。一旦发现服务端的数据更新(ServerSeq > ClientMaxSeq),服务端可以在心跳响应(Pong)中提示客户端,或者直接触发一次推送。这样,即使推送彻底失败,最晚在下一次心跳周期(如 30 秒)内,客户端也能感知到数据落后并主动发起同步,从而实现 100% 的消息可靠性闭环。
但是这个解决方法又有一个问题,就是如果服务端的第一次push失败的话,下一次客户端同步消息就需要等待心跳同步,会有明显的延迟,客户端可以感知,所以我们可以采用每次push一个消息控制信令,这个控制信令携带着客户端最新的MaxSeq,客户端收到之后就会去服务端主动拉取消息,由于控制信令每个客户端只有一个,不是每个消息维护一个,所以这部分重传定时器开销不是特别大。
主要思想差不多就是这样,具体架构的取舍等待后面实现的时候再讨论。