WebSocket 让服务端能主动推送,但它并不是"连上就万事大吉"的可靠通道:网络中间设备会回收空闲连接,移动网络会在切基站、进电梯时静默断开,消息可能在链路中丢失或重复。很多人第一次用 WebSocket 做通知、IM、实时数据,都会踩"明明 send 成功了对方却没收到""重连后消息重复弹出"这类坑。可靠投递不是 WebSocket 自带的,需要在应用层补齐心跳、重连、确认、去重和离线补偿这几块。本文逐一拆解。
ws.send() 的成功只代表数据写进了本地发送缓冲区,并不保证对端应用真的收到。连接可能处于"半开"状态:TCP 没有立刻感知对端掉线,双方都以为连接还在,消息却发进了黑洞。可靠性设计要回答四个问题:
客户端定时发一个轻量心跳包,服务端收到后回 pong;若连续若干次没收到响应,就判定连接已死、主动断开重连。服务端也要记录每个连接的最后活跃时间,超时未收到任何数据就清理,释放资源。
class WsClient {
constructor(url) { this.url = url; this.pending = new Map(); }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => this.startHeartbeat();
this.ws.onmessage = (e) => this.onMessage(JSON.parse(e.data));
this.ws.onclose = () => this.reconnect();
}
startHeartbeat() {
clearInterval(this.timer);
this.miss = 0;
this.timer = setInterval(() => {
if (this.miss >= 2) { this.ws.close(); return; } // 两次没回应判死
this.miss++;
this.rawSend({ type: "ping" });
}, 25000);
}
}心跳间隔要小于中间设备的连接回收时间(常见 NAT 超时约几十秒到几分钟),25~30 秒是较稳妥的取值;同时可以用应用层 ping/pong 而不是只依赖协议层 ping,便于在业务层统一统计活跃。
重连不能写死成固定间隔无脑重试,否则服务端重启时大量客户端同时重连会形成"重连风暴"把刚恢复的服务再次压垮。要用带随机抖动的指数退避,并设置上限:
reconnect() {
clearInterval(this.timer);
const delay = Math.min(1000 * 2 ** this.retries, 30000) + Math.random() * 1000;
this.retries++;
setTimeout(() => this.connect(), delay);
}
// 连接成功后重置 this.retries = 0要点:重连延迟指数增长并封顶、加随机抖动打散峰值;页面从后台切回前台、网络从离线变在线时立即触发一次检测;重连成功后不能假装什么都没发生,要进入下一步的消息补偿。
给每条消息分配全局唯一、单调递增的消息 id,发送方把"已发出但未被确认"的消息放进待确认队列,接收方收到后回 ACK,发送方收到 ACK 才移除;超过时间没收到 ACK 就重发:
send(msg) {
const packet = { id: this.nextId++, ...msg };
this.pending.set(packet.id, packet);
this.rawSend(packet);
packet.timer = setTimeout(() => this.resend(packet.id), 5000);
}
onMessage(packet) {
if (packet.type === "ack") {
const p = this.pending.get(packet.ackId);
if (p) { clearTimeout(p.timer); this.pending.delete(packet.ackId); }
return;
}
this.rawSend({ type: "ack", ackId: packet.id }); // 先回 ACK
this.handle(packet); // 再处理业务
}注意顺序:接收方应当先回 ACK 再处理业务,避免业务处理耗时或异常导致发送方无谓重发;而真正防重复还要靠下一步。
重发必然带来"同一条消息到达多次",因此接收方必须幂等。维护一个最近已处理消息 id 的滑动窗口(或有序集合),收到消息先查 id:
handle(packet) {
if (this.seen.has(packet.id)) return; // 已处理过,直接忽略
this.seen.add(packet.id);
// 窗口只保留最近 N 个,超出清理,防止内存无限增长
if (this.seen.size > 500) this.seen.delete(this.seen.values().next().value);
dispatch(packet); // 真正执行业务
}消息 id 应由服务端或发送方按会话单调分配,保证可比较、可去重;对于强一致的业务(如扣费、状态变更),除了通道层去重,业务接口本身也要幂等,做到双保险。
重连后,断线期间产生的消息不能丢。常见做法是连接建立时携带"我最后收到的消息序号",服务端据此把缺失的消息增量补发:
// 重连成功后的握手
this.rawSend({ type: "sync", lastSeq: this.lastSeq });
// 服务端返回 lastSeq 之后的消息列表,客户端顺序补齐这要求服务端为每个会话保留一段可回溯的消息(按序号存储,设置保留时长),而不是发完即弃。对于实时状态类场景(如在线人数、最新报价),补发逐条消息意义不大,更适合重连后直接拉一次"当前全量状态",用状态覆盖代替消息补齐。要按业务区分"消息型补历史"和"状态型取最新"。
可以把可靠层封装成一个与业务解耦的通道组件,内部统一负责心跳、退避重连、ACK 重发、id 去重和 sync 补历史,业务只面对"发送可靠消息、接收不重复消息"两个接口。服务端要为连接维护活跃时间与会话消息缓存,并在多实例部署时用共享存储或消息路由保证"同一用户重连到任意实例都能取到未读"。上线前重点做故障注入:手动断网再恢复、杀掉服务端单节点、设置高延迟和丢包,观察是否能自动重连、消息不丢不重、顺序正确。这些能力都是通用工程模式,自己实现时建议先把状态机(连接中/已连接/重连中/已关闭)和消息生命周期画清楚,再写代码会少走很多弯路。
WebSocket 只提供了一条全双工的"管子",可靠与否取决于你在管子之上搭了多少机制。心跳解决"死活感知",退避重连解决"怎么回来",ACK 与重发解决"丢没丢",id 去重解决"重没重",离线同步解决"断的那段怎么办"。把这五块补齐,实时通道才真正配得上"可靠"两个字。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。