使用 Codex 开发聊天、实时通知、行情面板或在线协作功能时,WebSocket 是非常常见的技术方案。

刚开始只有一个页面、一个连接时,代码通常很简单:

const socket = new WebSocket(url);

socket.onmessage = event => {
  handleMessage(event.data);
};

但真正部署到线上以后,经常会遇到:

  • 网络恢复后同一条消息收到两三次;

  • 页面切换几次后连接数量越来越多;

  • 服务端重启后客户端疯狂重连;

  • 浏览器休眠再恢复,旧连接和新连接同时存在;

  • onmessage 被重复绑定;

  • 多个定时器同时发送心跳;

  • 网络不好时 CPU 和请求数量突然升高。

这类问题往往不是 WebSocket 本身不稳定,而是连接生命周期没有被明确管理


一、为什么重连后会出现重复消息?

看一个常见实现:

function connect() {
  const socket = new WebSocket(url);

  socket.onmessage = event => {
    handleMessage(event.data);
  };

  socket.onclose = () => {
    setTimeout(connect, 1000);
  };
}

表面上没有问题。

但如果其他地方也调用了:

connect();

就可能同时存在两个连接:

连接A
连接B

服务端发送一条消息时:

A收到一次
B收到一次

前端最终执行两次:

handleMessage(message);

用户就会看到:

  • 通知重复;

  • 聊天消息出现两条;

  • 未读数重复增加;

  • 同一个任务执行两次。

所以 WebSocket 重连的第一条原则是:

任何时刻,只允许一个有效连接拥有消息处理权。


二、不要让多个地方都负责connect

大型前端项目中最容易出现:

App启动
→ connect()

聊天页面
→ connect()

用户Store初始化
→ connect()

三个模块都觉得自己应该建立连接。

结果就是连接边界完全失控。

更合理的方式是建立统一管理器:

class SocketManager {
  private socket: WebSocket | null = null;

  connect() {
    if (
      this.socket &&
      this.socket.readyState !== WebSocket.CLOSED
    ) {
      return;
    }

    this.socket = new WebSocket(url);
  }
}

所有业务模块只能:

socketManager.subscribe(...)

而不能直接:

new WebSocket(...)

连接应该集中管理,业务模块只关心消息。


三、给连接建立明确状态

不要只通过:

socket !== null

判断连接是否存在。

WebSocket 至少有:

CONNECTING
OPEN
CLOSING
CLOSED

项目中还可以增加业务状态:

type SocketStatus =
  | "idle"
  | "connecting"
  | "connected"
  | "reconnecting"
  | "closed";

例如:

if (
  status === "connecting" ||
  status === "connected" ||
  status === "reconnecting"
) {
  return;
}

这样可以避免:

第一次连接还没完成
↓
第二次connect又进来
↓
同时创建两个WebSocket

四、重连不能固定每秒一次

错误做法:

socket.onclose = () => {
  setTimeout(connect, 1000);
};

如果服务端宕机10分钟,几万个客户端可能每秒同时发起连接。

这会形成:

重连风暴。

更合理的是指数退避:

第1次:1秒
第2次:2秒
第3次:4秒
第4次:8秒
第5次:16秒

并加入随机抖动:

const delay =
  Math.min(
    1000 * 2 ** retryCount,
    30000
  ) + Math.random() * 1000;

这样大量客户端不会在同一时刻重新连接。


五、主动关闭和异常断线必须区分

用户退出账号时:

socket.close();

这属于主动关闭。

如果 onclose 中无条件重连:

socket.onclose = () => {
  reconnect();
};

那么用户刚退出,连接又自动建立。

所以需要记录:

let manuallyClosed = false;

主动退出:

manuallyClosed = true;
socket.close();

关闭事件:

socket.onclose = () => {
  if (!manuallyClosed) {
    scheduleReconnect();
  }
};

否则“退出登录后仍然收到消息”这类 Bug 很容易出现。


六、心跳定时器一定要清理

很多 WebSocket 会定期发送心跳:

heartbeatTimer = setInterval(() => {
  socket.send("ping");
}, 30000);

如果每次重连都创建新的 timer,却没有删除旧 timer:

第一次连接:1个心跳
第二次连接:2个心跳
第三次连接:3个心跳

最终服务端会收到大量重复 Ping。

重连前必须:

clearInterval(heartbeatTimer);

然后再重新创建。

除了心跳,还要清理:

  • 重连定时器;

  • 超时定时器;

  • 消息监听器;

  • 旧 Socket 引用。


七、检测“半死连接”

有时浏览器认为:

WebSocket仍然OPEN

但实际网络已经断开。

比如:

  • Wi-Fi 切换;

  • 手机进入后台;

  • VPN变化;

  • 路由器断网;

  • 浏览器长时间休眠。

这时单纯依赖 onclose 可能不够。

可以使用心跳:

客户端发送 ping
↓
服务端返回 pong
↓
记录最后响应时间

如果超过阈值没有收到 Pong:

判定连接失效
↓
主动关闭
↓
进入重连流程

例如:

if (
  Date.now() - lastPongAt >
  60000
) {
  socket.close();
}

八、重新连接后可能需要重新订阅

一些系统连接成功后,还要发送订阅信息:

{
  "type": "subscribe",
  "channel": "order:1001"
}

重连后旧连接已经失效,所以需要重新发送订阅。

但要注意:

重新订阅 ≠ 重复绑定本地监听器。

错误流程:

重连
→ subscribe()
→ addEventListener()
→ 再重连
→ subscribe()
→ 再addEventListener()

监听器数量不断增加。

更合理的是:

本地监听器
→ 初始化一次

服务端订阅
→ 每次新连接成功后重新发送

这两个生命周期必须区分。


九、消息本身也应该支持去重

即使客户端连接管理正确,也不能完全假设服务端消息只会到达一次。

对于关键业务消息,最好包含:

{
  "messageId": "msg_10001",
  "type": "order_updated",
  "data": {}
}

客户端可以记录近期已经处理的:

messageId

如果重复收到:

if (processedIds.has(message.id)) {
  return;
}

消息去重特别适合:

  • 订单状态;

  • 支付结果;

  • 系统通知;

  • 异步任务状态;

  • 重要事件广播。


十、不要把WebSocket当成唯一数据源

假设用户掉线5分钟。

这期间服务端产生了20条新消息。

重新连接以后,如果服务端只推送“以后发生的消息”,客户端会永久缺少掉线期间的数据。

所以实时系统通常需要:

HTTP / API
负责获取当前完整状态

WebSocket
负责接收增量变化

例如:

重新连接成功
↓
请求 /messages?after=lastMessageId
↓
补齐离线消息
↓
再继续接收实时推送

不要把:

WebSocket连接成功

等同于:

本地状态一定完整

十一、记录最后处理位置

消息系统可以维护:

lastSequence

例如:

{
  "sequence": 1058,
  "messageId": "msg-1058"
}

客户端已经处理:

1058

重连后告诉服务端:

请从1059开始继续

这种方式比简单“重新连接,然后等新消息”更加可靠。

如果发现:

1060

直接跳到:

1065

客户端还可以主动检测中间消息是否缺失。


十二、React/Vue组件不要直接掌控连接生命周期

如果在 React 中:

useEffect(() => {
  const socket = new WebSocket(url);

  return () => {
    socket.close();
  };
}, []);

单页面场景可以。

但如果多个页面都需要实时数据,页面切换会导致:

进入页面
→ 建连接

离开页面
→ 断连接

进入另一个页面
→ 再连接

频繁建立连接没有必要。

更合适的是:

应用级Socket Manager

组件只进行:

订阅
取消订阅

而不是决定底层连接是否创建。


十三、让Codex先画连接生命周期

遇到 WebSocket Bug 时,不要直接让 Codex:

帮我修复重复消息。

可以先要求:

请先不要修改代码。

分析当前WebSocket生命周期:

1. 哪些地方会创建连接;
2. 哪些地方会关闭连接;
3. 谁负责重连;
4. 是否存在多个重连定时器;
5. 心跳在哪里创建和清理;
6. message监听器绑定几次;
7. 重连后如何重新订阅;
8. 是否有消息去重机制。

很多问题只要画出生命周期,就能直接看出原因。


十四、测试必须模拟异常网络

WebSocket 测试不能只验证:

连接成功
→ 收到消息

还应该覆盖:

网络突然断开

connected
→ disconnected
→ reconnecting
→ connected

连续重连失败

确认:

退避时间逐渐增加

用户主动退出

确认:

不会再次自动重连

多次页面切换

确认:

只有一个底层连接

服务端重复消息

确认:

同一个messageId只处理一次

浏览器休眠恢复

确认:

旧连接失效后能够恢复状态

十五、把WebSocket规则写进AGENTS.md

可以加入:

# WebSocket规则

- 应用内只允许一个底层实时连接管理器
- 禁止业务组件直接随意new WebSocket
- 所有重连必须使用指数退避和随机抖动
- 主动关闭不能触发自动重连
- 重连前必须清理旧Timer和监听器
- 重连成功后重新发送服务端订阅
- 本地监听器禁止重复绑定
- 重要消息必须包含messageId并支持去重
- WebSocket只负责增量数据,不作为唯一事实源
- 断线恢复必须评估离线数据补偿

这样 Codex 后续生成实时通信逻辑时,稳定性会明显更高。


十六、Plus还是Pro?

如果主要处理:

  • 单个 WebSocket 页面;

  • 简单聊天;

  • 实时通知;

  • 小型前端项目;

Plus 通常已经能够覆盖大部分 Codex 开发任务。

如果是:

  • 大型实时系统;

  • 多频道订阅;

  • 前后端联合排查;

  • 长时间连接日志分析;

  • 多模块连续测试;

  • 多个实时项目并行维护;

可以根据实际开发强度评估 Pro。

但无论使用哪个版本,真正决定实时系统可靠性的仍然是连接生命周期设计,而不是简单增加重连次数。

总结

Codex 写 WebSocket 重连后消息越来越多,最常见的原因不是服务端真的发送了多份数据,而是客户端产生了:

多个连接
+
多个监听器
+
多个心跳Timer
+
多个重连任务

通过统一连接管理器、连接状态机、指数退避、心跳清理、消息ID去重和断线补偿,可以让 WebSocket 在复杂网络环境下保持稳定。

真正可靠的实时通信系统应该能够回答三个问题:

当前到底有几个连接?谁拥有这个连接?断线以后如何恢复到正确状态?

只要这三个问题无法明确,重连次数越多,系统通常只会越来越乱。

CSDN文章描述

本文介绍 Codex 编写 WebSocket 实时通信时常见的重复消息和重连风暴问题,并通过连接状态机、指数退避、心跳检测、消息去重和断线补偿提高 WebSocket 稳定性。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐