WebSocket 心跳与重连:解决假在线和重连风暴

格 格达软件 2026-10-26 3 次阅读

一个「假在线」的排查:onclose 为什么不触发

线上反馈:用户进地铁,从 WiFi 切到 4G,界面右上角的绿点还是亮的,消息发出去一直转圈,30 秒内没有任何报错,也没触发重连。

复现路径很直接——把设备切成飞行模式 3 秒再切回来,然后看 ws.readyState:

// 断网 3 秒后执行
console.log(ws.readyState); // 1,也就是 WebSocket.OPEN
ws.send('hello');           // 不抛错,数据进了浏览器内核的发送缓冲区

readyState === OPEN 只说明「本端的 socket 对象没被关掉」,不说明「对端还活着」。TCP 的连接失败是惰性告知的:对端进程崩溃、NAT 表项老化、中间设备静默丢包,在内核还没发出数据之前,你什么都察觉不到。send() 出去的数据在内核缓冲区排队重传,耗尽了才报错;Chromium 的 WebSocket 还要等底层 socket 报错,才可能触发 onclose。这个过程几分钟起步。

所以结论只有一句:不能等 onclose,必须主动探活。这是心跳唯一不可替代的作用。至于「保活、别被中间设备掐断」,只是顺带的收益,而且只对「空闲超时」这一类原因有效。

顺带说个容易踩的:ws.onerror 也不可靠。很多场景下 onerror 根本不触发,只有 onclose。别把清理逻辑只挂在 onerror 上。

四层超时,先搞清楚你在跟谁赛跑

心跳间隔不是拍脑袋定的,它必须小于链路上最紧的那个空闲超时。

位置 典型超时 说明
浏览器侧 TCP keepalive 7200s Linux net.ipv4.tcp_keepalive_time 默认两小时,跨 NAT 基本没意义
NAT / 防火墙连接表项 60s ~ 300s 家用路由器和运营商 CGNAT 差异极大,查不到只能保守估计
云负载均衡空闲超时 60s ~ 900s ALB 默认 60s、NLB 350s、CLB TCP 默认 900s,以控制台数字为准
Nginx proxy_read_timeout 60s 定义是「从上游两次读之间的最大间隔」

取其中最小的(多数情况是 60s),心跳间隔要明显小于它。我一般用 25s 发 ping、8s 等 pong,最坏情况下 33s 内一定有一次有效数据流,留了将近一倍余量。

那心跳调到 10s 是不是更保险?算笔账,按 10 万长连接:

100000 连接 / 25s ≈ 4000 ping/s + 4000 pong/s = 8000 帧/s
每帧端到端 ≈ 100 字节(IP 头 20 + TCP 头 20 + TLS 记录头 ~29 + WS 帧头 2~4 + 载荷 4)
≈ 800 KB/s ≈ 6.4 Mbps 的纯心跳流量

改成 10s 就是 2 万帧/秒、16 Mbps,服务端要处理的 epoll 事件数翻 2.5 倍。心跳不是免费的。

客户端:应用层心跳 + 全抖动退避

浏览器端拿不到协议级 ping 的能力,WebSocket API 没有 ping() 方法,所以客户端心跳只能做成应用层消息,协议级 ping/pong 交给服务端发。

先看三个最常见的错误写法。

错误写法一:所有状态判断都用 readyState === OPEN

// CONNECTING 状态 send 会抛 InvalidStateError;CLOSING/CLOSED 静默丢数据
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));

正确做法是把待发消息放进队列,onopen 后统一 flush。

错误写法二:用 setInterval 发心跳

setInterval(() => ws.send('ping'), 25000); // 坑

主线程被长任务阻塞(比如渲染大表格)时,setInterval 的回调会排队堆积,恢复瞬间连发好几次;而且这个定时器在 ws 关闭后还在跑。用递归 setTimeout,每次发完再排下一次。

错误写法三:onclose 里直接重连

ws.onclose = () => connect(); // 服务端返回 502 的时候,这就是个忙循环

服务端重启的瞬间,所有客户端在同一毫秒重新发起连接,把刚起来的进程再打挂一次。必须退避 + 随机抖动。

完整实现:

// reliable-socket.ts
export type Status = 'connecting' | 'open' | 'closed';

export interface ReliableSocketOptions {
  url: string | (() => string);   // 用函数是为了每次重连能带上新的 token
  heartbeatInterval?: number;
  pongTimeout?: number;
  maxRetryDelay?: number;
  onMessage?: (data: string) => void;
  onStatusChange?: (s: Status) => void;
}

export class ReliableSocket {
  private ws: WebSocket | null = null;
  private wantOpen = false;      // 用户意图,和 ws 的实际状态分开存
  private status: Status = 'closed';
  private retry = 0;
  private pingTimer: ReturnType<typeof setTimeout> | null = null;
  private pongTimer: ReturnType<typeof setTimeout> | null = null;
  private retryTimer: ReturnType<typeof setTimeout> | null = null;
  private lastRecvAt = 0;
  private outbox: string[] = [];
  private opts: {
    url: string | (() => string);
    heartbeatInterval: number;
    pongTimeout: number;
    maxRetryDelay: number;
    onMessage?: (data: string) => void;
    onStatusChange?: (s: Status) => void;
  };

  constructor(options: ReliableSocketOptions) {
    this.opts = {
      heartbeatInterval: 25_000,
      pongTimeout: 8_000,
      maxRetryDelay: 30_000,
      ...options,
    };
    window.addEventListener('online', this.handleOnline);
    document.addEventListener('visibilitychange', this.handleVisibility);
  }

  connect() {
    this.wantOpen = true;
    this.retry = 0;
    this.open();
  }

  close(code = 1000, reason = 'client close') {
    this.wantOpen = false;          // 主动关闭,不再重连
    this.clearRetry();
    this.stopHeartbeat();
    const ws = this.ws;
    this.ws = null;
    this.setStatus('closed');
    if (ws) {
      ws.onclose = null;
      ws.onmessage = null;
      ws.onerror = null;
      try { ws.close(code, reason); } catch { /* 已经关了 */ }
    }
  }

  send(data: string) {
    const ws = this.ws;
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(data);
      return;
    }
    // 断线期间最多缓存 200 条,超了丢最旧的;真实业务要配合 clientMsgId 做幂等
    if (this.outbox.length >= 200) this.outbox.shift();
    this.outbox.push(data);
  }

  getStatus() { return this.status; }

  private open() {
    const cur = this.ws;
    if (cur && (cur.readyState === WebSocket.OPEN || cur.readyState === WebSocket.CONNECTING)) return;
    this.clearRetry();

    const url = typeof this.opts.url === 'function' ? this.opts.url() : this.opts.url;
    this.setStatus('connecting');
    const ws = new WebSocket(url);
    this.ws = ws;

    ws.onopen = () => {
      if (this.ws !== ws) return;   // 已被更新的连接取代,丢弃这次回调
      this.retry = 0;
      this.lastRecvAt = Date.now();
      this.setStatus('open');
      this.flush();
      this.schedulePing(this.opts.heartbeatInterval);
    };

    ws.onmessage = (ev) => {
      if (this.ws !== ws) return;
      this.lastRecvAt = Date.now();
      if (ev.data === 'pong') {     // 心跳不上抛给业务层
        this.clearPongTimer();
        return;
      }
      this.opts.onMessage?.(ev.data as string);
    };

    ws.onclose = () => {
      if (this.ws !== ws) return;
      this.ws = null;
      this.stopHeartbeat();
      this.setStatus('closed');
      this.scheduleReconnect();     // onerror 之后必然有 onclose,只在这里处理
    };
  }

  private schedulePing(delay: number) {
    this.clearPingTimer();
    this.pingTimer = setTimeout(() => {
      const ws = this.ws;
      if (!ws || ws.readyState !== WebSocket.OPEN) return;
      ws.send('ping');
      // 发了 ping 才开始等 pong,超时就判定链路已死,主动关掉触发重连
      this.clearPongTimer();
      this.pongTimer = setTimeout(() => this.hardClose('pong timeout'), this.opts.pongTimeout);
      this.schedulePing(this.opts.heartbeatInterval);
    }, delay);
  }

  private scheduleReconnect() {
    if (!this.wantOpen || this.retryTimer) return;
    const base = Math.min(1000 * 2 ** this.retry, this.opts.maxRetryDelay);
    // full jitter:0.5~1 倍随机,把重连请求摊开
    const delay = base * (0.5 + Math.random() * 0.5);
    this.retry++;
    this.retryTimer = setTimeout(() => {
      this.retryTimer = null;
      if (this.wantOpen) this.open();
    }, delay);
  }

  private hardClose(_reason: string) {
    const ws = this.ws;
    this.ws = null;
    this.stopHeartbeat();
    if (ws) {
      ws.onclose = null;
      ws.onmessage = null;
      ws.onerror = null;
      try { ws.close(); } catch { /* noop */ }
    }
    this.setStatus('closed');
    this.scheduleReconnect();
  }

  // 从后台切回前台:定时器可能被冻结,ws 看着是 OPEN 其实早就死了
  private handleVisibility = () => {
    if (document.visibilityState !== 'visible' || !this.wantOpen) return;
    const ws = this.ws;
    if (!ws || ws.readyState !== WebSocket.OPEN) {
      this.scheduleReconnect();
      return;
    }
    if (Date.now() - this.lastRecvAt > this.opts.heartbeatInterval * 2) {
      // 立刻探活,超时缩到 3s,别让用户干等 8s
      this.clearPongTimer();
      ws.send('ping');
      this.pongTimer = setTimeout(() => this.hardClose('resume probe timeout'), 3000);
    }
  };

  private handleOnline = () => {
    if (!this.wantOpen) return;
    const ws = this.ws;
    if (ws && ws.readyState === WebSocket.OPEN) return;
    this.retry = 0;                 // 网络刚恢复,不用等退避
    this.clearRetry();
    this.open();
  };

  private flush() {
    const ws = this.ws;
    if (!ws || ws.readyState !== WebSocket.OPEN) return;
    while (this.outbox.length) ws.send(this.outbox.shift()!);
  }

  private setStatus(s: Status) {
    if (this.status === s) return;
    this.status = s;
    this.opts.onStatusChange?.(s);
  }

  private clearPingTimer() { if (this.pingTimer) { clearTimeout(this.pingTimer); this.pingTimer = null; } }
  private clearPongTimer() { if (this.pongTimer) { clearTimeout(this.pongTimer); this.pongTimer = null; } }
  private clearRetry() { if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; } }
  private stopHeartbeat() { this.clearPingTimer(); this.clearPongTimer(); }
}

几处值得单独拎出来说:

  • this.ws !== ws 这个判断在每个回调里都有。没有它,重连之后旧连接的 onclose 迟到,会把新连接的状态改回 closed,然后再触发一次重连——线上表现是「连接每隔几秒抖一次」,非常难查。
  • wantOpen 和 readyState 分开存。主动 close() 时置 false,onclose 就不会再拉起来。只靠 readyState 判断的话,业务主动下线也会被重连逻辑拽回来。
  • pongTimeout 必须独立于心跳间隔。只发不验,等于没心跳。
  • 退避序列大概是 1s → 2s → 4s → 8s → 16s → 30s,每次再乘 0.5~1 的随机数。

服务端:协议级 ping 探的是另一件事

浏览器不能主动发协议级 ping,服务端可以。ws 库的 ws.ping() 发出去,浏览器由网络栈自动回 pong,前端一行代码都不用写。

但要清楚它探的到底是什么:Chromium 里 pong 帧由网络线程回复,主线程卡死也照样回。所以协议级 ping 只能证明「对端网络栈还在」,证明不了「页面 JS 还活着」。后者只能靠应用层的 'ping' 文本消息——那是业务代码在跑才发得出来的。

两层都要有,各管各的:

// server.js  Node 18+,npm i ws
import { WebSocketServer } from 'ws';

const HEARTBEAT_INTERVAL = 30_000;
const APP_IDLE_TIMEOUT = 90_000;   // 应用层 3 个周期没动静就踢

const wss = new WebSocketServer({ port: 8080, path: '/ws' });

wss.on('connection', (ws) => {
  ws.isAlive = true;               // 协议级,由 pong 置位
  ws.lastAppAt = Date.now();       // 应用级,由业务消息置位

  ws.on('pong', () => { ws.isAlive = true; });

  ws.on('message', (raw) => {
    const text = raw.toString();
    ws.lastAppAt = Date.now();
    if (text === 'ping') {
      ws.send('pong');             // 固定字符串,不走 JSON.parse
      return;
    }
    // ... 业务消息处理
  });

  ws.on('error', (err) => console.error('ws error:', err.message));
});

const timer = setInterval(() => {
  const now = Date.now();
  for (const ws of wss.clients) {
    if (ws.isAlive === false) {
      ws.terminate();              // 关键:不是 close
      continue;
    }
    if (now - ws.lastAppAt > APP_IDLE_TIMEOUT) {
      ws.close(4001, 'app heartbeat timeout');
      continue;
    }
    ws.isAlive = false;
    ws.ping();
  }
}, HEARTBEAT_INTERVAL);

wss.on('close', () => clearInterval(timer));

terminate() 和 close() 的差别在这种场景里很致命。close() 是优雅关闭:发关闭帧、等对端回关闭帧、TCP 走四次挥手。对一个已经半死的连接,你等不到回应,这个 socket 会挂在服务端直到操作系统重传超时,几分钟到十几分钟不等,期间占着 fd 和内存。terminate() 直接销毁 TCP。对已经判定死亡的连接不用讲礼貌。

还有个坑:遍历 wss.clients 时别在对端消息回调里顺手删元素,会打乱 Set 的迭代。要删就 setImmediate 里删,或者像上面这样只做标记。

重连风暴:一次服务端重启能把自己再打挂

这个我做过压测:10 万连接,服务端滚动重启,客户端不带退避、1 秒后集体重连。新进程起来的头 0.5 秒接了 6 万多条 TCP 连接,TLS 握手把 CPU 打满,健康检查超时,负载均衡判定不健康又摘掉,循环。

加上 full jitter 之后,同样 10 万连接,重连被摊到 15~30 秒窗口里,峰值降到 4000 连接/秒左右。光靠客户端还不够,服务端侧要补两刀:

# nginx.conf
location /ws {
    proxy_pass http://ws_backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;

    # 默认 60s。心跳 25s 足够,别调成 3600 假装自己能保活
    proxy_read_timeout 75s;
    proxy_send_timeout 75s;
    proxy_buffering off;      # 不关的话消息会被攒着,实时性直接废掉
}

限流交给网关比手写靠谱:

# 每 IP 并发连接上限,挡掉重连风暴里的异常客户端
limit_conn_zone $binary_remote_addr zone=ws_conn:10m;

location /ws {
    limit_conn ws_conn 10;
    limit_conn_status 429;
    # ... 上面的 proxy 配置
}

真要在 Node 里做,得放在 HTTP server 的 upgrade 事件里,wss.on('headers') 拿到的信息太少,不好判断。

几个容易忽略的边界

多标签页。用户开 5 个标签页就是 5 条长连接、5 份心跳,10 万 DAU 可能对应 30 万连接。要么服务端按 userId 去重(同一用户只保留最新一条,旧的用 4002 关闭),要么上 SharedWorker / BroadcastChannel 做单连接复用。前者成本低,后者能省下真正的 fd 和带宽,但 SharedWorker 在部分移动端浏览器上行为不一致,要实测。

消息幂等。重连后 flush 出来的消息,可能是「上一次已经发到服务端、但响应没回来」那条。服务端必须按 clientMsgId 去重,5 分钟窗口足够:

// 客户端发送时带上
socket.send(JSON.stringify({ clientMsgId: crypto.randomUUID(), type: 'send_msg', payload }));

心跳别用 JSON。{"type":"ping"} 每条都要 JSON.parse,还要走一遍路由和反序列化。固定字符串 'ping' / 'pong' 一个 === 就完事。省下的 CPU 不算多,但这是白送的。

别把连接状态当登录态。心跳只能测出「这条连接死了」,测不出「这个用户该不该在线」。网络断开要重连,用户点了退出必须置 wantOpen = false 并清掉本地 token。我见过用 readyState 判断登录态的代码,退出登录后刷新页面又自动连回去了。

客户端 close() 是尽力而为。iOS Safari 上页面可能被直接回收,beforeunload、pagehide 都不触发。所以服务端的心跳超时清理逻辑不能省,那才是最后一道保障。

心跳和重连加起来不到两百行,出问题的地方几乎全在状态判断和时序上:谁先关、谁后重连、哪个定时器该清。把 wantOpen 和 readyState 拆开,把每个回调里的旧连接判断补上,再说别的。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。