登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

WebSocket断线重连与消息去重的客户端设计

来源:17golang原创

时间:2026-09-23 13:10:03 171浏览 收藏

实时通知一旦遇到地铁、电梯或切换网络,WebSocket 客户端通常会同时暴露两个问题:连接断了却没有恢复,或者恢复后同一条业务消息被渲染两遍。可靠的做法不是在 close 回调里无条件新建连接,而是把连接代次、退避计时器、消息标识和待发送队列放进同一个客户端控制器。收到消息后先去重,再修改页面状态;涉及写入、扣款等副作用时,还要把同一个请求标识交给服务端做幂等处理。

要点速览
  • 每次连接都绑定一个代次,旧连接的异步事件不能修改新连接状态。
  • messageId 适合挡重复到达,seq 适合发现乱序或缺口,两者用途不同。
  • 重连使用指数退避加随机抖动,待发送队列必须有容量、过期和失败策略。
  • 客户端去重只保护当前页面,服务端仍要用 requestId 保证真正的业务幂等。

先把 WebSocket 连接状态收进一个控制器

浏览器的 WebSocket 提供 openmessageerrorclose 事件。实际项目中最容易出错的是旧连接晚到的 close 覆盖了新连接,以及多个定时器同时发起重连。连接代次可以把这两个问题隔开:创建新 socket 时递增代次,事件回调只接受属于当前代次的实例。

class RealtimeClient {
  constructor(url, onMessage) {
    this.url = url;
    this.onMessage = onMessage;
    this.socket = null;
    this.generation = 0;
    this.retryCount = 0;
    this.retryTimer = null;
    this.seen = new Map();
    this.pending = [];
  }

  connect() {
    const generation = ++this.generation;
    const socket = new WebSocket(this.url);
    this.socket = socket;

    socket.addEventListener("open", () => {
      // 只有当前连接能清零退避计数并冲刷队列。
      if (generation !== this.generation) return;
      this.retryCount = 0;
      this.flushPending();
    });

    socket.addEventListener("message", (event) => {
      // 先校验消息结构和幂等标识,再交给业务状态层。
      if (generation !== this.generation) return;
      const message = JSON.parse(event.data);
      if (!this.acceptOnce(message)) return;
      this.onMessage(message);
    });

    socket.addEventListener("close", () => {
      // 旧连接关闭时不能替新连接安排第二个定时器。
      if (generation === this.generation) this.scheduleReconnect();
    });
  }

  acceptOnce(message) {
    if (!message.messageId) return true;
    if (this.seen.has(message.messageId)) return false;
    this.seen.set(message.messageId, Date.now());
    return true;
  }

  scheduleReconnect() {
    if (this.retryTimer) return;
    const base = Math.min(30000, 500 * 2 ** this.retryCount++);
    const delay = base + Math.floor(Math.random() * 400);
    this.retryTimer = setTimeout(() => {
      this.retryTimer = null;
      this.connect();
    }, delay);
  }

  flushPending() {
    while (this.pending.length && this.socket?.readyState === WebSocket.OPEN) {
      this.socket.send(JSON.stringify(this.pending.shift()));
    }
  }
}

这段代码只展示客户端结构,JSON.parse 在生产代码中还应放进异常处理,并校验消息版本、类型和大小。close 事件只负责调度恢复;如果用户主动退出,应先设置关闭标记,避免把正常退出重新连回来。

WebSocket 客户端连接代次、当前 socket、open close 事件与重连定时器的静态关系说明图
图1:连接代次把当前 socket、事件回调和重连定时器绑定在同一边界内,是静态关系说明图,不是运行截图。

messageId 和 seq 要解决不同的重复问题

messageId 解决的是“这一条消息是否已经处理过”。客户端可以保留一个有上限的 Map,超过时间窗口或容量后淘汰旧标识,不能无限增长。若服务端消息带有递增 seq,则可以额外记录 lastSeq:小于等于已确认序号的消息直接忽略,大于下一序号的消息标记为存在缺口,再决定请求补发还是刷新快照。

不要把“收到过同样内容”当作去重依据。两次文本相同的库存更新可能发生在不同时间,稳定的消息 ID 才能表达一次事件。相反,如果服务端重连后会重新发送最近一段消息,seq 还能帮助客户端判断消息是否乱序,但它并不能替代跨分区生成的唯一 ID。

WebSocket 消息信封中的 messageId、seq、requestId 与业务状态层的去重边界说明图
图2:消息信封把 messageId、seq、requestId 和业务状态更新分层,说明显示层去重与服务端幂等的关系。

断线期间的发送队列要有边界

实时客户端不应把所有离线操作都无限缓存。通知类消息可以直接丢弃或只保留最后一条;用户明确提交的操作则应携带 requestId,进入有限队列,连接恢复后再发送。队列项至少记录创建时间、操作类型和重试次数,超过有效期就提示用户,而不是静默重复提交。

数据类型客户端策略服务端要求
状态通知按 messageId 去重,缺口时拉快照消息可重放,快照有版本
用户操作有限队列,过期需提示requestId 幂等,返回最终结果
输入事件通常只保留最新值允许覆盖或按版本拒绝旧值

退避参数也应可观测:记录当前连接代次、重连次数、队列长度、最近一次 close code 和去重丢弃数。这样遇到“页面看起来卡住”时,能先判断是网络未恢复、消息缺口,还是客户端因为重复保护丢弃了本应处理的新事件。

常见问题

客户端去重后还会不会重复扣款?

会。客户端刷新、多个标签页或请求已到达服务端但响应丢失时,仍可能再次提交。扣款、下单等副作用必须在服务端以 requestId 做幂等。

为什么重连要加随机抖动?

固定延迟会让同时断线的客户端在同一时刻集中连接。指数退避降低频率,随机抖动则把请求摊开,减少恢复瞬间的连接尖峰。

只保存最后一个 seq 是否足够?

只适合服务端保证连续序号且客户端允许缺口时。需要严格顺序时要记录缺口范围,必要时拉取快照或补发,不能把大于下一序号的消息直接当成正常更新。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>