上周线上聊天的客服系统出大问题了:用户在从公司 Wi-Fi 走到电梯切到 4G 网络时,WebSocket 连接其实已经断开了,但是浏览器的 ws.readyState 居然还傻乎乎地显示为 OPEN。这就导致服务器发的通知前端根本收不到,而前端发的消息也全部沉入大海。这种状态在网络术语里叫“半打开连接”或者“伪连接”。等用户好不容易重新连上网,之前的历史通知也全部丢光了,被客诉投诉到头秃。

要解决这个痛点,只靠浏览器自带的连接状态回调是绝对不行的,必须自己在客户端封装一套心跳检测与重连机制。我们不仅需要通过定时发送 Ping 包来主动排查伪连接,还需要实现指数退避(Exponential Backoff)算法避免重连时把后端网关冲崩溃,同时还要在本地维护一个“离线消息队列”,并在长连接彻底拉闸时自动降级到 HTTP 轮询。

下面是我们重构后的核心客户端 RobustWebSocket 类实现,支持自动心跳、指数退避重连、离线队列缓冲以及轮询 Fallback:

type MessagePayload = string | object;

export class RobustWebSocket {
  private ws: WebSocket | null = null;
  private heartbeatTimer: any;
  private serverTimeoutTimer: any;
  private reconnectTimer: any;
  private reconnectAttempts = 0;
  private maxAttempts = 5;
  private offlineQueue: string[] = [];
  private isFallback = false;

  constructor(private url: string) {
    this.connect();
  }

  private connect() {
    if (this.isFallback) return this.startPolling();
    try {
      this.ws = new WebSocket(this.url);
      this.ws.onopen = () => {
        this.reconnectAttempts = 0;
        this.startHeartbeat();
        while (this.offlineQueue.length) {
          const data = this.offlineQueue.shift();
          if (data) this.ws.send(data);
        }
      };
      this.ws.onmessage = () => this.startHeartbeat();
      this.ws.onclose = () => this.handleReconnect();
      this.ws.onerror = () => this.ws?.close();
    } catch {
      this.handleReconnect();
    }
  }

  private startHeartbeat() {
    clearTimeout(this.heartbeatTimer);
    clearTimeout(this.serverTimeoutTimer);
    this.heartbeatTimer = setTimeout(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.ws.send("ping");
        this.serverTimeoutTimer = setTimeout(() => this.ws?.close(), 5000);
      }
    }, 10000);
  }

  private handleReconnect() {
    clearTimeout(this.heartbeatTimer);
    clearTimeout(this.serverTimeoutTimer);
    if (this.reconnectAttempts < this.maxAttempts) {
      const delay = Math.min(16000, 1000 * Math.pow(2, this.reconnectAttempts));
      this.reconnectTimer = setTimeout(() => {
        this.reconnectAttempts++;
        this.connect();
      }, delay);
    } else {
      this.isFallback = true;
      this.startPolling();
    }
  }

  public send(payload: MessagePayload) {
    const data = typeof payload === "string" ? payload : JSON.stringify(payload);
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(data);
    } else {
      this.offlineQueue.push(data);
    }
  }

  private startPolling() {
    setInterval(async () => {
      try {
        const res = await fetch("/api/fallback/messages");
        const messages = await res.json();
        console.log("[Poll] 轮询获取:", messages);
      } catch (err) {
        console.error(err);
      }
    }, 5000);
  }
}

引入这套机制后,无论用户怎么切网、进出电梯,前端都可以做到在网络恢复后的几秒钟内快速自动重连。而离线消息队列则保障了在断网边缘状态下,用户点击发送 the 按钮不会因为连接中断而被无情丢弃,极大地降低了前台交互报错的概率。

不过,这里其实还剩下一个架构上的大问题:当 HTTP 轮询拉取历史消息时,如何与 WebSocket 恢复连接后的数据流进行去重,避免重复消费?大家在做多通道降级时是如何做序列号或者消息 ID 去重的?