【WebSocket+MQTT】day58-heartbeat-reconnect

作者:mario 发布时间: 2026-09-03 阅读量:5 评论数:0

Day 58 · 心跳与重连 — 半开连接的死刑判决书与指数退避的艺术

昨天埋下的最大隐患今天爆雷:拔掉网线,你的页面 10 分钟内都显示"连接正常"。这不是 bug,是 TCP 的天性(半开连接)——物理链路断了,但没有人在上面说话,谁都不知道对方已经没了。今天的两个主题环环相扣:心跳负责判决连接死刑,重连负责安排转世。学完今天,你的 ReconnectingWebSocket 才算生产级。


目录


一、半开连接:最阴险的故障形态

1.1 复现实验(必做)

1. 连上昨天的本地 WS 服务器
2. 拔网线(或 DevTools → Network → Offline)
3. 观察:onclose 没触发、readyState 还是 OPEN、页面毫无感知
4. 重新插上网线:连接其实早就死了,发消息才报错

1.2 为什么 TCP 不告诉你

TCP 的设计哲学:沉默 ≠ 死亡。只有当它发送数据并等不到 ACK 时(重传超时,通常要几分钟),才会宣布连接失效。而你的 WS 连接大部分时间在安静收数据——链路断了,内核还抱着一条"僵尸连接"。

1.3 工业场景的代价

工厂监控大屏挂着一条死连接:值班员看着"温度正常"的假画面 10 分钟,实际炉温早已超限——半开连接不是体验问题,是安全事故。所以工业级实时链路的心跳间隔通常压到 5~15 秒,比互联网场景(30~60 秒)激进得多。


二、心跳:应用层的死亡判决

2.1 原理

客户端每 N 秒发一个 ping → 服务器回一个 pong
              │
              ├── R 秒内收到 pong → 连接还活着,继续
              └── R 秒没收到 pong  → 判决死亡 → ws.close() → 触发重连流程

2.2 实现要点

/**
 * 心跳检测器
 * 设计要点:
 * 1. ping/pong 用应用层消息(协议层的见第四节)
 * 2. 任何收到的消息都算"活着"的证据(不必非等 pong——数据帧也能证明链路通)
 * 3. 超时判决要 close 而不是傻等
 */
export class Heartbeat {
  private pingTimer = 0;
  private deathTimer = 0;
  private seq = 0;

  constructor(
    private send: (msg: string) => void,
    private onDead: () => void,
    private intervalMs = 10_000,    // 每 10 秒一次 ping
    private timeoutMs = 5_000       // ping 后 5 秒无任何消息判死
  ) {}

  /** 连接建立后启动 */
  start(): void {
    this.stop();
    this.pingTimer = window.setInterval(() => {
      this.send(JSON.stringify({ type: "ping", seq: this.seq++ }));
      // 每次 ping 后布置死亡判决计时器
      clearTimeout(this.deathTimer);
      this.deathTimer = window.setTimeout(() => this.onDead(), this.timeoutMs);
    }, this.intervalMs);
  }

  /** 收到任何消息时调用:撤销死刑判决 */
  feed(): void {
    clearTimeout(this.deathTimer);
  }

  /** 断开/重连前清理 */
  stop(): void {
    clearInterval(this.pingTimer);
    clearTimeout(this.deathTimer);
  }
}

2.3 参数选择

参数 工业监控 互联网应用 权衡
intervalMs 10s 30s 越小发现越快,流量/功耗越大
timeoutMs interval 的 1/2 interval 的 1/2 必须显著小于 interval,否则两次 ping 叠加

三、重连:指数退避与抖动

3.1 三种重连策略对比

// ❌ 立即重连:服务器宕机时 1000 个客户端同时每秒打 1000 次 → 重启瞬间被打挂
const delay = 0;

// ❌ 固定间隔:比立即重连好,但"同时断、同时试"的共振仍在
const delay = 3000;

// ✅ 指数退避 + 随机抖动:行业最佳实践(TCP 重传、AWS SDK、socket.io 都这么干)
//    每次 failures+1,延迟翻倍;上限封顶;抖动打散同时重试的客户端
const delay = Math.min(1000 * 2 ** failures, 30_000) * (0.5 + Math.random() * 0.5);

3.2 时间线示意

失败次数    退避基数     随机抖动后
   1         1s        0.5 ~ 1.0s
   2         2s        1.0 ~ 2.0s
   3         4s        2.0 ~ 4.0s
   4         8s        4.0 ~ 8.0s
   5        16s        8.0 ~ 16.0s
  ≥6        30s        15 ~ 30s(封顶)
        ──▶ 服务器恢复后最多 30s 内全部客户端回来,且不会共振

3.3 重连成功后必须做的事(明天展开,今天先列清单)

  1. 重新 subscribe(昨天的第四节的"有状态"代价)
  2. 拉取断线期间的数据缺口(Day 61 补传)
  3. 通知 UI 层从"重连中"恢复"正常"

四、协议层心跳 vs 应用层心跳

4.1 WebSocket 协议自带 ping/pong(opcode 0x9/0xA)

特点:
- 帧只有 2 字节头,零 payload 开销
- 但浏览器 JavaScript API 不能发协议层 ping,也收不到 pong
  (浏览器内核自动回 pong,但你的代码无感知)
- 只有服务器端(Node ws 库)能主动发协议层 ping

4.2 选型结论

场景 方案
纯浏览器客户端 只能应用层心跳(JSON 消息 ping/pong)
Node 服务器探测客户端 协议层 ping(ws.ping() + on('pong')
生产组合 双向各测各的:服务器协议层 ping 测下行,客户端应用层 ping 测上行

💡 为什么浏览器代码摸不到协议层心跳?安全模型决定:把 ping/pong 暴露给 JS 会给恶意页面提供流量指纹工具。基础设施的每个"不能用"背后都有设计理由——这周反复体会这一点。


五、生产级 ReconnectingWebSocket

把昨天 WsConnection 升级:集成心跳 + 指数退避重连。

import { Heartbeat } from "./heartbeat";

/** 连接状态(UI 层据此显示状态灯) */
export type ConnState = "connecting" | "open" | "reconnecting" | "closed";

/**
 * 生产级可重连 WebSocket:
 * - 心跳判死半开连接
 * - 指数退避 + 抖动重连
 * - 主动 close(code 1000) 不触发重连(用户意图优先)
 */
export class ReconnectingWebSocket {
  private ws: WebSocket | null = null;
  private state: ConnState = "closed";
  private failures = 0;                 // 连续失败计数(成功后清零)
  private retryTimer = 0;
  private manualClose = false;          // 区分"用户主动关"与"意外断"
  private heartbeat: Heartbeat;

  private msgListeners = new Set<(data: string) => void>();
  private stateListeners = new Set<(s: ConnState) => void>();

  constructor(
    private url: string,
    private opts = {
      heartbeatInterval: 10_000,
      heartbeatTimeout: 5_000,
      maxRetryDelay: 30_000,
    }
  ) {
    // 心跳:发不出去(send 返回 false)或超时无响应都判死
    this.heartbeat = new Heartbeat(
      () => this.send("ping"),
      () => {
        console.warn("心跳超时,判决连接死亡");
        this.ws?.close();               // 触发 onclose → 走统一重连流程
      },
      opts.heartbeatInterval,
      opts.heartbeatTimeout
    );
  }

  /** 建立连接(对外主入口,幂等) */
  connect(): void {
    this.manualClose = false;
    this.setState("connecting");
    this.openSocket();
  }

  /** 主动关闭:不重连(组件卸载时调用) */
  close(): void {
    this.manualClose = true;
    this.heartbeat.stop();
    clearTimeout(this.retryTimer);
    this.ws?.close(1000, "client-shutdown");
    this.setState("closed");
  }

  /** 发送文本(仅 OPEN 状态) */
  send(data: string): boolean {
    if (this.ws?.readyState !== WebSocket.OPEN) return false;
    this.ws.send(data);
    return true;
  }

  onMessage(listener: (data: string) => void): () => void {
    this.msgListeners.add(listener);
    return () => this.msgListeners.delete(listener);
  }

  onState(listener: (s: ConnState) => void): () => void {
    this.stateListeners.add(listener);
    return () => this.stateListeners.delete(listener);
  }

  // ===== 内部实现 =====

  private openSocket(): void {
    const ws = new WebSocket(this.url);
    ws.binaryType = "arraybuffer";
    this.ws = ws;

    ws.onopen = () => {
      this.failures = 0;              // 成功:退避序列归零
      this.setState("open");
      this.heartbeat.start();
      // 重连成功后的会话恢复钩子(Day 61 补传逻辑挂这里)
      this.onSessionRestored?.();
    };

    ws.onmessage = (ev) => {
      this.heartbeat.feed();          // 任何消息都撤销死刑判决
      if (typeof ev.data === "string") {
        this.msgListeners.forEach((l) => l(ev.data as string));
      }
    };

    ws.onclose = (ev) => {
      this.heartbeat.stop();
      this.ws = null;
      if (this.manualClose || ev.code === 1000) return;   // 用户意图:不重连
      this.scheduleReconnect();
    };
    // onerror 统一交给 onclose 处理(Day 57 坑 6)
  }

  /** 指数退避 + 抖动重连 */
  private scheduleReconnect(): void {
    const base = Math.min(1000 * 2 ** this.failures, this.opts.maxRetryDelay);
    const delay = base * (0.5 + Math.random() * 0.5);
    this.failures++;
    this.setState("reconnecting");
    console.log(`将在 ${Math.round(delay)}ms 后第 ${this.failures} 次重连`);
    clearTimeout(this.retryTimer);
    this.retryTimer = window.setTimeout(() => this.openSocket(), delay);
  }

  /** 会话恢复钩子(子类/外部注入重连后的恢复逻辑) */
  onSessionRestored: (() => void) | null = null;

  private setState(s: ConnState): void {
    this.state = s;
    this.stateListeners.forEach((l) => l(s));
  }

  get connectionState(): ConnState {
    return this.state;
  }
}

类职责一览(本周持续迭代这个类)

版本 增加能力
Day 57 版 类型化封装、环境 URL
Day 58 版(今天) 心跳判死、指数退避重连、状态广播
Day 61 版 发送缓冲、补传请求、会话恢复

六、验证实验:亲手制造故障

生产代码的容错必须亲手验证。今天设计 4 个故障实验(全部要做,截图进博客):

实验 1:半开连接(心跳判死)

1. 连接后立刻 DevTools → Network → Offline
2. 观察控制台:10s 后 ping 发出 → 15s 时判决死亡 → 进入 reconnecting
3. 恢复网络:退避后自动重连成功 → open

实验 2:服务器宕机(terminate)

服务器代码里加 setTimeout(() => ws.terminate(), 10_000),观察客户端:close code 1006 → 重连序列(1s→2s→4s…)→ 重启服务器后恢复。

实验 3:服务器拒绝连接(端口没人听)

连一个不存在的端口:立即 close(code 1006,wasClean false)→ 退避重连持续失败但节奏正确(封顶 30s)。

实验 4:共振测试(抖动的价值)

同时开 10 个 Tab 连接,杀服务器再启动:观察 Messages 面板,10 个客户端的重连时刻分散在不同时间点(抖动生效),而不是齐刷刷重试。


七、常见坑点

坑 1:心跳计时器没清理导致"僵尸心跳"

断线后 Heartbeat.stop() 没调用,旧连接的死刑计时器还在跑,新连接刚建立就被旧计时器误杀。修复:openSocket 前必 stop,close 里必 stop(见 5 的实现顺序)。

坑 2:重连后 listeners 丢失

每次 openSocket 新建 WebSocket 对象——如果 onmessage 绑在新 ws 上但忘了把 listener 转接(或反之绑在旧 ws 上),重连后收不到消息。修复:listener 集合放在外层类(5 的结构保证这点)。

坑 3:退避无上限

服务器长期宕机时延迟涨到分钟级,恢复后回来太慢。修复:Math.min(..., 30_000) 封顶。

坑 4:手动 close 触发了重连

用户切页面主动 close(1000),结果 2 秒后自动重连(页面已卸载,报错+泄漏)。修复:manualClose 标志 + close code 1000 双重判断(见 5)。

坑 5:心跳消息打崩统计

ping 消息混进业务消息分发,图表画出锯齿。修复:onmessage 里先过滤 type === 'ping'/'pong',或心跳用独立的 message type 前缀。

坑 6:用 setTimeout 模拟 setInterval 做心跳

setInterval 的回调可能因主线程阻塞而堆积。心跳用 setInterval + 每次判死刑的 clearTimeout(2.2 实现)已经规避了最坏情况;更严格的生产实现会用时间戳比对(“上次收到消息距今多久”)代替计时器计数。


八、自测挑战

T1 · 完成 ReconnectingWebSocket(45 分钟)

把第五节代码敲进项目(含 Heartbeat 类),服务器加 terminate 测试钩子。今天核心作业

T2 · 四故障实验(30 分钟)

跑第六节全部 4 个实验,控制台输出 + WS Messages 面板截图整理成"故障响应报告"。

T3 · 状态灯 UI(20 分钟)

页面右上角做连接状态灯:open 绿 / reconnecting 黄闪烁 / closed 红,订阅 onState 驱动。这个小组件明天直接进 BOSS 战面板。

T4 · 心跳 RTT 监控(进阶,25 分钟)

ping 消息带 t: Date.now(),服务器 pong 原样回显,客户端算 RTT 并画 60 点迷你折线(复用 Day 51 的 LineChart)。RTT 突增往往是断线前兆——这是"网络健康度"监控的雏形。


九、总结

问题 武器 关键参数
半开连接 应用层心跳 10s 间隔 / 5s 超时(工业级)
重连风暴 指数退避 + 抖动 min(1s × 2^n, 30s) × (0.5~1.0)
用户主动断 manualClose + code 1000 不重连
计时器泄漏 stop 时机纪律 openSocket 前 / close 里

今天解决了"发现死亡 + 回到人间",但断线期间错过的数据永远消失了——明天的二进制协议和后天的补传机制负责把丢的数据找回来。先从"数据怎么高效传"开始:1000 点/秒的传感器流,JSON 和二进制的差距是 5 倍。

评论