【WebSocket】消息丢失的补偿/补发机制

2 阅读9分钟

背景

因为本人一直在负责会议模块,而会议中许多的操作都需要使用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 维护一个已发送消息缓存队列

该缓存队列的作用包括:

  • 缓存最近一段时间内已成功发送的消息;
  • 支持根据客户端反馈的序列号区间进行精确补发;
  • 提供有限窗口内的重放能力。

补发/补偿消息

在实际业务场景中,并非所有类型的消息都适合采用统一的“逐条补发”策略。不同类型的消息,其语义模型和一致性要求不同,因此补偿策略也应进行分层设计。 举个例子:

  1. 用户的频繁地开麦关麦:我们不需要补发中间所有的操作,只需要最终的状态即可,即补发最后的消息。
  2. 会议的多个状态的修改:当丢失的消息存在大量的会议的多个不同的状态修改时,其实只需要查一次会议的状态,而不是每个状态单独查询,且每条消息都进行补发,即需要服务端发送状态同步的消息作为补偿。而且为了幂等性,凡是涉及到状态修改的,一律采用查询最新状态作为补偿消息,而不是补发原有消息

而一些消息需要有明确的先后顺序,则直接按照序列号进行补发即可。

于是我们可以将消息的补偿机制分为三种:

  • Sequential:有序的消息补发。
  • Collapsible:根据唯一键,将相同唯一键的消息,只补发最后一条。
  • StateSync:状态同步,自定义对应消息的补偿消息体,多种消息可以共用一套补偿机制。

于是我们可以有如下几个接口,作为各个业务的消息接口

  1. 通用消息抽象:用于统一标识具备补偿语义的消息。
public interface DeliveryMessage {
}
  1. 有序的消息接口:该消息必须按照序列号严格重放。
public interface SequentialMessage extends DeliveryMessage {

    @Override
    default DeliveryPolicy getDeliveryPolicy() {
        return DeliveryPolicy.SEQUENTIAL;
    }
}
  1. 可折叠消息接口:允许在补偿阶段进行事件压缩,仅补发最后一条消息。

可折叠键由子消息自己实现,因为可以做到多种消息类型进行合并,尽可能更大的提升灵活度。

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;
    }
}
  1. 状态同步消息接口:用于标识当前的消息丢失时,采用对应的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 领域的一些新探索与实践总结。