WebSocket断线重连与消息去重的客户端设计
来源:17golang原创
时间:2026-09-23 13:10:03 171浏览 收藏
实时通知一旦遇到地铁、电梯或切换网络,WebSocket 客户端通常会同时暴露两个问题:连接断了却没有恢复,或者恢复后同一条业务消息被渲染两遍。可靠的做法不是在 close 回调里无条件新建连接,而是把连接代次、退避计时器、消息标识和待发送队列放进同一个客户端控制器。收到消息后先去重,再修改页面状态;涉及写入、扣款等副作用时,还要把同一个请求标识交给服务端做幂等处理。
- 每次连接都绑定一个代次,旧连接的异步事件不能修改新连接状态。
messageId适合挡重复到达,seq适合发现乱序或缺口,两者用途不同。- 重连使用指数退避加随机抖动,待发送队列必须有容量、过期和失败策略。
- 客户端去重只保护当前页面,服务端仍要用
requestId保证真正的业务幂等。
先把 WebSocket 连接状态收进一个控制器
浏览器的 WebSocket 提供 open、message、error 和 close 事件。实际项目中最容易出错的是旧连接晚到的 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 事件只负责调度恢复;如果用户主动退出,应先设置关闭标记,避免把正常退出重新连回来。

messageId 和 seq 要解决不同的重复问题
messageId 解决的是“这一条消息是否已经处理过”。客户端可以保留一个有上限的 Map,超过时间窗口或容量后淘汰旧标识,不能无限增长。若服务端消息带有递增 seq,则可以额外记录 lastSeq:小于等于已确认序号的消息直接忽略,大于下一序号的消息标记为存在缺口,再决定请求补发还是刷新快照。
不要把“收到过同样内容”当作去重依据。两次文本相同的库存更新可能发生在不同时间,稳定的消息 ID 才能表达一次事件。相反,如果服务端重连后会重新发送最近一段消息,seq 还能帮助客户端判断消息是否乱序,但它并不能替代跨分区生成的唯一 ID。

断线期间的发送队列要有边界
实时客户端不应把所有离线操作都无限缓存。通知类消息可以直接丢弃或只保留最后一条;用户明确提交的操作则应携带 requestId,进入有限队列,连接恢复后再发送。队列项至少记录创建时间、操作类型和重试次数,超过有效期就提示用户,而不是静默重复提交。
| 数据类型 | 客户端策略 | 服务端要求 |
|---|---|---|
| 状态通知 | 按 messageId 去重,缺口时拉快照 | 消息可重放,快照有版本 |
| 用户操作 | 有限队列,过期需提示 | requestId 幂等,返回最终结果 |
| 输入事件 | 通常只保留最新值 | 允许覆盖或按版本拒绝旧值 |
退避参数也应可观测:记录当前连接代次、重连次数、队列长度、最近一次 close code 和去重丢弃数。这样遇到“页面看起来卡住”时,能先判断是网络未恢复、消息缺口,还是客户端因为重复保护丢弃了本应处理的新事件。
常见问题
客户端去重后还会不会重复扣款?
会。客户端刷新、多个标签页或请求已到达服务端但响应丢失时,仍可能再次提交。扣款、下单等副作用必须在服务端以 requestId 做幂等。
为什么重连要加随机抖动?
固定延迟会让同时断线的客户端在同一时刻集中连接。指数退避降低频率,随机抖动则把请求摊开,减少恢复瞬间的连接尖峰。
只保存最后一个 seq 是否足够?
只适合服务端保证连续序号且客户端允许缺口时。需要严格顺序时要记录缺口范围,必要时拉取快照或补发,不能把大于下一序号的消息直接当成正常更新。
-
130 收藏
-
311 收藏
-
447 收藏
-
493 收藏
-
156 收藏
-
490 收藏
-
305 收藏
-
252 收藏
-
367 收藏
-
文章 · 前端 | 3天前 | 前端 · 性能优化 · javascript · ArrayBuffer postMessage TypedArray 结构化克隆 Web Worker Transferable140 收藏
-
176 收藏
-
242 收藏
-
359 收藏
-
308 收藏
-
143 收藏
-
209 收藏
-
360 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习