背景
因为本人一直在负责会议模块,而会议中许多的操作都需要使用WebSocket进行广播,比如主持人禁言,用户入会等消息。但是在用户内测的时候,偶尔会发现状态不同步的情况。
- 主持人发起了一个kr流程,有的人在投票阶段,但有的人却还在写的阶段。
- 有的人会议结束了,但是却还有人停留在会议页面。
我们发现是消息部分人收到,部分人收不到的情况,在代码方面排查无误之后,我们怀疑大概率是因为数据传输中的消息丢失,由于丢消息的情况非常难于复现(丢失的消息类型和丢失的接收端并不固定,且丢失概率极低),可能是弱网/后台/切换网络的情况下导致的WebSocket消息丢失。
所以我们需要建设一套可靠的,保证消息可达,幂等的WebSocket方案,主要解决如何检测消息丢失,和消息丢失之后如何补偿。
结合会议实际业务,会议的所有的socket都是用来广播或通知,客户端调用服务端都是使用的接口,而服务端向客户端才会发送WebSocket消息,所以本文主要是解决 服务端 -> 客户端 这条链路上的消息丢失。
什么情况下消息会丢失?
因为我们要解决消息丢失问题,那么第一步要搞清楚为什么消息会丢失。
网络突然断开/切换网络
首先我们知道,WebSocket是基于TCP的,对于TCP而言,首先我们要知道四元组(本机IP,连接端口号,对方IP,对方端口号)。当切换网络时,TCP就会断开然后建立一个新的连接,这个时候对之前连接发送的消息就会丢失。
网络断开,重连之后,由于WebSocket是实时的,则这段时间的消息一定会丢失。
客户端假死/后台
当客户端挂在后台时,如果未允许后台访问,则会导致系统停止给客户端分配CPU和网络资源,但是因为是挂起状态,未能触发断连,导致“看上去还活着”,但是无法正常发送消息,也就是说连接上实际已经不可用了。
面对这种情况最好的方式就是心跳机制。
TCP的“不可靠”?
在网上探索时,偶然发现了这篇文章TCP收发数据“丢失”问题的排查与解决,遂研究一下。
这篇文章主要是说在TCP处理时,作者在将大消息进行分片后,send() 只发送了一部分,作者却以为“全部发送成功”,就会认为整个消息都成功的情况,导致消息丢失。
而我们采用的WebSocket自己帮我们处理好了分片,帧重组和send缓冲区,所以这部分不是我们考虑的原因。
心跳机制
关于心跳机制,网上有很多的文章,这里不作为重点。基本上现在大多数需要进行持续信息交换的中间件(如 MQ、RPC 框架等)都会设计心跳机制。心跳机制本质就是为了“探活”,保证通信双方处于可用的连接状态。
客户端会每隔一段时间向服务端发送一个心跳包(ping),服务端在收到心跳包之后返回一个响应包(pong)。通过这种方式可以确认当前连接仍然可用。如果在约定时间内未收到 pong,则认为连接可能已经失效,客户端会主动关闭连接并尝试重连。
而在我们会议当中,因为我们要尽可能保证用户的在线状态准确,所以心跳机制对于我们尤为重要。
消息补发/补偿
因为我们几乎没有办法解决 弱网/切换网络,或假死之类的情况下的消息丢失。所以我们只能通过检测消息丢失,来判断是否消息丢失了,当消息丢失时进行补偿/补发。这就是我们这次会议模块WebSocket改造的重点。
seq 序列号
要解决消息丢失后的补偿,我们首先要确认消息是否丢失,和丢失了哪些消息。所以我们可以给每一个消息加上seq(序列号),作为唯一标识。其实大多的通信中间件或者同步机制都会有seq,可能换个名字叫做offset,本质上是为了确认对方收到或未收到哪些消息,属于消息确认机制。
使用队列保证消息有序
在引入 序列号之后,户端即可通过检测序列号的连续性来判断是否存在消息丢失。例如,当客户端收到的序列号出现跳跃时(如期望 N+1,实际收到 N+2),即可推断序列号为 N+1 的消息可能丢失。
但在实际网络环境中,仅依赖序列号连续性判断仍然存在一个问题:消息乱序 。如果消息在传输过程中发生乱序,客户端可能会误判为消息丢失,而实际上该消息只是延迟到达。由于Websocket是基于TCP的,在单条 WebSocket 连接内,消息在网络传输层面不会乱序。所以我们要保障在发出消息时,消息处于串行化。所以我们可以维护一个发送队列。设计要点如下:
- 每个 WebSocket Session 维护一个独立的发送队列;
- 所有待发送的业务消息统一进入该队列;
- 发送线程按 FIFO 顺序从队头依次取出消息;
- 在真正发送前为消息分配递增的 seq;
- 当前消息发送完成(或进入发送缓冲区)后,才继续发送下一条。
仅保证顺序发送仍不足以实现可靠投递。为了支持断线重传或区间补发,还需要为每个 session 维护一个已发送消息缓存队列 。
该缓存队列的作用包括:
- 缓存最近一段时间内已成功发送的消息;
- 支持根据客户端反馈的序列号区间进行精确补发;
- 提供有限窗口内的重放能力。
补发/补偿消息
在实际业务场景中,并非所有类型的消息都适合采用统一的“逐条补发”策略。不同类型的消息,其语义模型和一致性要求不同,因此补偿策略也应进行分层设计。 举个例子:
- 用户的频繁地开麦关麦:我们不需要补发中间所有的操作,只需要最终的状态即可,即补发最后的消息。
- 会议的多个状态的修改:当丢失的消息存在大量的会议的多个不同的状态修改时,其实只需要查一次会议的状态,而不是每个状态单独查询,且每条消息都进行补发,即需要服务端发送状态同步的消息作为补偿。而且为了幂等性,凡是涉及到状态修改的,一律采用查询最新状态作为补偿消息,而不是补发原有消息。
而一些消息需要有明确的先后顺序,则直接按照序列号进行补发即可。
于是我们可以将消息的补偿机制分为三种:
- Sequential:有序的消息补发。
- Collapsible:根据唯一键,将相同唯一键的消息,只补发最后一条。
- StateSync:状态同步,自定义对应消息的补偿消息体,多种消息可以共用一套补偿机制。
于是我们可以有如下几个接口,作为各个业务的消息接口
- 通用消息抽象:用于统一标识具备补偿语义的消息。
public interface DeliveryMessage {
}
- 有序的消息接口:该消息必须按照序列号严格重放。
public interface SequentialMessage extends DeliveryMessage {
@Override
default DeliveryPolicy getDeliveryPolicy() {
return DeliveryPolicy.SEQUENTIAL;
}
}
- 可折叠消息接口:允许在补偿阶段进行事件压缩,仅补发最后一条消息。
可折叠键由子消息自己实现,因为可以做到多种消息类型进行合并,尽可能更大的提升灵活度。
public interface CollapsibleMessage extends DeliveryMessage {
@Override
default DeliveryPolicy getDeliveryPolicy() {
return DeliveryPolicy.COLLAPSIBLE;
}
/**
* 获取折叠键后缀
* <p>
* 子类可重写此方法返回自定义后缀(如用户ID),用于区分不同实体的消息。
* <p>
* 完整折叠键 = {@code channel:topic:action} + (suffix != null ? {@code ":suffix"} : "")
*
* @return 折叠键后缀,返回 null 或空字符串则不追加后缀
*/
default String getCollapseKeySuffix() {
return null;
}
}
- 状态同步消息接口:用于标识当前的消息丢失时,采用对应的syncType类型的补偿机制
public interface StateSyncMessage extends DeliveryMessage {
@Override
default DeliveryPolicy getDeliveryPolicy() {
return DeliveryPolicy.STATE_SYNC;
}
/**
* 获取状态同步类型
* <p>
* 相同 syncType 的消息在补偿时只会触发一次
* <p>
* 例如:AddMember、RemoveMember、UpdateMember 可以返回相同的 "memberList",
* 这样无论丢失了多少条成员变更消息,只会调用一次状态同步推送完整列表。
*
* @return 状态同步类型标识
*/
String getSyncType();
}
有了这几个接口,我们还需要有一个注册器,用于将所有的补偿机制注册进去,用于注册StateSync类型的消息补偿机制,大概如下(我使用的是micronaut框架)
@Singleton
public class StateSyncRegistry<C extends Context<U>, U extends User> {
/**
* syncType -> callback 映射
*/
private final Map<String, StateSyncCallback<C, U>> callbackMap = new ConcurrentHashMap<>();
/**
* 构造函数,自动注入所有 StateSyncCallback 实现
*
* @param callbacks 所有实现 StateSyncCallback 接口的 Bean
*/
@Inject
public StateSyncRegistry(List<StateSyncCallback<C, U>> callbacks) {
callbacks.forEach(this::register);
}
/**
* 注册状态同步回调
*
* @param callback 回调实例
*/
public void register(StateSyncCallback<C, U> callback) {
String name = callback.getSyncStateName();
callbackMap.put(name, callback);
log.debug("注册状态同步回调,syncType={}", name);
}
/**
* 获取状态同步回调
*
* @param syncType 状态同步类型
* @return 回调实例,不存在则返回 null
*/
public StateSyncCallback<C, U> getCallback(String syncType) {
return callbackMap.get(syncType);
}
总结
本文提供了大概的补偿思路,这套方案如果详细设计,细节也是比较多的,比如丢失消息超出缓存队列时的通知机制、串行化消息发送的性能优化等。更多的也是业务上的思考,以及补偿策略的考量,比如哪些消息适合补发,哪些消息需要状态同步。以上也是笔者在 WebSocket 领域的一些新探索与实践总结。