首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >WebSocket 可靠消息投递:心跳保活、断线重连与消息幂等去重

WebSocket 可靠消息投递:心跳保活、断线重连与消息幂等去重

原创
作者头像
用户5598620
发布2026-09-11 09:07:55
发布2026-09-11 09:07:55
10
举报

WebSocket 让服务端能主动推送,但它并不是"连上就万事大吉"的可靠通道:网络中间设备会回收空闲连接,移动网络会在切基站、进电梯时静默断开,消息可能在链路中丢失或重复。很多人第一次用 WebSocket 做通知、IM、实时数据,都会踩"明明 send 成功了对方却没收到""重连后消息重复弹出"这类坑。可靠投递不是 WebSocket 自带的,需要在应用层补齐心跳、重连、确认、去重和离线补偿这几块。本文逐一拆解。

一、先认清 WebSocket 的不可靠来自哪里

ws.send() 的成功只代表数据写进了本地发送缓冲区,并不保证对端应用真的收到。连接可能处于"半开"状态:TCP 没有立刻感知对端掉线,双方都以为连接还在,消息却发进了黑洞。可靠性设计要回答四个问题:

  • 怎么及时发现连接已经死了(心跳);
  • 断了之后怎么自动、克制地重连(重连策略);
  • 怎么保证每条消息被对端确实收到(确认与重发);
  • 怎么避免重连和重发导致重复(消息编号与去重)。

二、心跳保活:主动探测而不是干等

客户端定时发一个轻量心跳包,服务端收到后回 pong;若连续若干次没收到响应,就判定连接已死、主动断开重连。服务端也要记录每个连接的最后活跃时间,超时未收到任何数据就清理,释放资源。

代码语言:javascript
复制
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,便于在业务层统一统计活跃。

三、断线重连:指数退避,避免雪崩

重连不能写死成固定间隔无脑重试,否则服务端重启时大量客户端同时重连会形成"重连风暴"把刚恢复的服务再次压垮。要用带随机抖动的指数退避,并设置上限:

代码语言:javascript
复制
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 就重发:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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 应由服务端或发送方按会话单调分配,保证可比较、可去重;对于强一致的业务(如扣费、状态变更),除了通道层去重,业务接口本身也要幂等,做到双保险。

六、离线消息与状态同步

重连后,断线期间产生的消息不能丢。常见做法是连接建立时携带"我最后收到的消息序号",服务端据此把缺失的消息增量补发:

代码语言:javascript
复制
// 重连成功后的握手
this.rawSend({ type: "sync", lastSeq: this.lastSeq });
// 服务端返回 lastSeq 之后的消息列表,客户端顺序补齐

这要求服务端为每个会话保留一段可回溯的消息(按序号存储,设置保留时长),而不是发完即弃。对于实时状态类场景(如在线人数、最新报价),补发逐条消息意义不大,更适合重连后直接拉一次"当前全量状态",用状态覆盖代替消息补齐。要按业务区分"消息型补历史"和"状态型取最新"。

七、踩坑清单

  • 以为 send 成功就是送达,没有 ACK,消息进了半开连接的黑洞;
  • 不做心跳或心跳间隔过长,断了几分钟才发现;
  • 固定间隔重连,服务重启瞬间被重连风暴二次打垮;
  • 重发消息但接收方不做幂等,通知重复弹、计数重复加;
  • 用随机 id 去重,重连后无法判断先后、无法补历史;
  • 已处理消息集合只增不清,长时间运行内存泄漏;
  • 断线期间消息发完即弃,重连后静默丢失;
  • 状态类数据还逐条补消息,其实直接同步最新状态更简单可靠。

八、工程落地建议

可以把可靠层封装成一个与业务解耦的通道组件,内部统一负责心跳、退避重连、ACK 重发、id 去重和 sync 补历史,业务只面对"发送可靠消息、接收不重复消息"两个接口。服务端要为连接维护活跃时间与会话消息缓存,并在多实例部署时用共享存储或消息路由保证"同一用户重连到任意实例都能取到未读"。上线前重点做故障注入:手动断网再恢复、杀掉服务端单节点、设置高延迟和丢包,观察是否能自动重连、消息不丢不重、顺序正确。这些能力都是通用工程模式,自己实现时建议先把状态机(连接中/已连接/重连中/已关闭)和消息生命周期画清楚,再写代码会少走很多弯路。

九、上线前复盘清单

  1. 心跳间隔是否小于网络回收时间,连续无响应是否主动判死;
  2. 重连是否指数退避加抖动、是否封顶,能否在网络恢复时立即检测;
  3. 每条消息是否有单调 id、ACK 与超时重发;
  4. 接收方是否先 ACK 后处理、是否对重复 id 幂等;
  5. 去重窗口是否有界,避免长期运行内存增长;
  6. 重连后是否按 lastSeq 补消息或同步最新状态;
  7. 是否在断网、丢包、服务端单点故障下验证过不丢不重。

结语

WebSocket 只提供了一条全双工的"管子",可靠与否取决于你在管子之上搭了多少机制。心跳解决"死活感知",退避重连解决"怎么回来",ACK 与重发解决"丢没丢",id 去重解决"重没重",离线同步解决"断的那段怎么办"。把这五块补齐,实时通道才真正配得上"可靠"两个字。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、先认清 WebSocket 的不可靠来自哪里
  • 二、心跳保活:主动探测而不是干等
  • 三、断线重连:指数退避,避免雪崩
  • 四、消息确认与重发:知道哪条没送达
  • 五、幂等去重:重发和重连都不能让消息执行两次
  • 六、离线消息与状态同步
  • 七、踩坑清单
  • 八、工程落地建议
  • 九、上线前复盘清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档