Codex写WebSocket重连为什么越重连消息越多?用连接状态机避免重复订阅
使用 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 稳定性。
更多推荐


所有评论(0)