WebSocket 断线重连与心跳机制实战

在实时协作、行情推送、在线客服这类场景里,WebSocket 断线重连与心跳保活几乎是必做的工程底座。长连接不像 HTTP 一问一答就结束,它要 7×24 小时在线,而移动网络切换、Nginx 超时、服务端重启都会让连接悄悄断开。本文用可落地的代码,讲清楚重连策略、心跳机制与部署坑位。

一、为什么必须自己做重连与心跳

浏览器原生的 WebSocket 对象只有在连接成功或彻底失败时回调 onopen/onclose,它不会自动重连,也不知道连接是否”假死”。所谓假死,是 TCP 链路还在、但中间代理(如 Nginx、SLB)已经把连接回收,此时服务端发不出数据、客户端也收不到,readyState 却还显示 OPEN。靠心跳探测才能发现这种沉默的死亡。

需要重连与心跳的三个典型诱因:① 客户端网络切换(Wi-Fi ↔ 蜂窝);② 中间代理空闲超时(Nginx 默认 60s 无活动会断);③ 服务端滚动发布或崩溃。三者频率都不低,任何生产环境都必须覆盖。

长连接和短连接的成本结构完全不同。HTTP 请求用完即走,连接状态由浏览器托管;而 WebSocket 一旦建立,业务层要自己背负”保活、探活、恢复”三件事。很多人刚上手时只在 onclose 里写一句 connect(),上线后发现客户端在后台被系统挂起、代理超时静默断流、多条重连互相打架——这些都不是框架的 bug,而是长连接天然要付出的运维代价。把这套逻辑抽象成独立模块,是实时功能能不能”稳”的分水岭。

二、重连策略:别用固定间隔猛锤

2.1 指数退避 + 抖动

固定 2 秒重连在连接长时间不可用时,会把客户端和服务端都锤爆。正确做法是指数退避:第 1 次 1s、第 2 次 2s、第 4 次 8s……并加上随机抖动,避免大量客户端同时重连形成”惊群”。

// 计算第 n 次重连的等待毫秒数(封顶 30s,带 ±30% 抖动)
function backoffMs(attempt, base = 1000, cap = 30000) {
  const exp = Math.min(base * Math.pow(2, attempt), cap);
  const jitter = exp * (Math.random() * 0.6 - 0.3); // ±30%
  return Math.max(0, Math.floor(exp + jitter));
}

// 退避序列示例:attempt 0..6
[0,1,2,3,4,5,6].forEach(a => console.log(a, backoffMs(a)));
// 约 1000 / 2000 / 4000 / 8000 / 16000 / 30000 / 30000(含抖动)

2.2 防重连风暴

两个关键护栏:① 用 intentionalClose 标记主动关闭,主动关闭时不重连;② 重连前先清掉旧定时器,避免多次 connect() 叠加出多条连接。

退避为什么要封顶?因为故障可能持续几十分钟(如机房割接、证书过期),若一直 2 的幂增长,单次等待会飙到几小时,恢复后客户端要等很久才连上,用户体感是”半天没动静”。封顶在 30s 是工程上的甜点:既避免在故障期空转消耗,又能保证恢复后最多半分钟重连。抖动则解决”同步重连”——上千客户端若同一时刻断、同一时刻重连,会给服务端瞬间灌入连接洪峰,加随机量能把洪峰摊成平缓的溪流。

三、心跳机制:Ping / Pong 双向保活

3.1 客户端定时发 Ping

客户端每 15–25 秒发一个心跳包,并启动”心跳超时”计时:若下一个 Pong 没在 10 秒内回来,就判死、主动关闭触发重连。

// 客户端心跳:发 ping,等 pong,超时则判死
const HEARTBEAT_SEND = 20000;   // 每 20s 发一次
const HEARTBEAT_TIMEOUT = 10000; // 10s 内没收到 pong 判死

let heartbeatTimer = null;
let pongTimer = null;

ws.onopen = () => {
  heartbeatTimer = setInterval(() => ws.send(JSON.stringify({ type: 'ping' })), HEARTBEAT_SEND);
};

ws.onmessage = (ev) => {
  const msg = JSON.parse(ev.data);
  if (msg.type === 'pong') {
    clearTimeout(pongTimer); // 收到 pong,取消判死
    pongTimer = null;
  }
};

function startPongWatchdog() {
  pongTimer = setTimeout(() => {
    console.warn('心跳超时,强制重连');
    ws.close(); // 触发 onclose → 重连逻辑
  }, HEARTBEAT_TIMEOUT);
}

3.2 服务端判定超时

服务端也要反过来:若超过 1.5 个心跳周期没收到客户端任何消息(含 ping),就主动断开,释放半开连接占用的 fd。

四、一个生产可用的前端封装

把重连 + 心跳 + 消息分发收进一个类,调用方只需关心 onMessage。下面这个封装经过线上验证,可直接拷用。

为什么封装成类而不是一个 connect() 函数?因为长连接是有状态的:当前处于第几次重连、有没有挂心跳定时器、是不是主动关闭,这些状态必须被持有。函数式写法每次重连都丢失上下文,定时器也容易泄漏。类用实例字段 attemptintentional_hb 把状态钉死,配合 listeners 集合做多订阅者分发,业务里无论多少个组件想收消息,都只复用同一条物理连接——这也是节省服务端 fd 的关键。

class ReconnectingSocket {
  constructor(url, { maxAttempts = 10 } = {}) {
    this.url = url;
    this.maxAttempts = maxAttempts;
    this.attempt = 0;
    this.intentional = false;
    this.listeners = new Set();
    this.connect();
  }
  connect() {
    clearTimeout(this._t);
    this.ws = new WebSocket(this.url);
    this.ws.onopen = () => { this.attempt = 0; this._startHeartbeat(); };
    this.ws.onmessage = (e) => {
      const data = JSON.parse(e.data);
      if (data.type === 'pong') { clearTimeout(this._pong); return; }
      this.listeners.forEach(fn => fn(data));
    };
    this.ws.onclose = () => {
      clearInterval(this._hb);
      if (this.intentional || this.attempt >= this.maxAttempts) return;
      this._t = setTimeout(() => this.connect(), backoffMs(this.attempt++));
    };
  }
  _startHeartbeat() {
    this._hb = setInterval(() => {
      if (this.ws.readyState !== WebSocket.OPEN) return;
      this.ws.send(JSON.stringify({ type: 'ping' }));
      this._pong = setTimeout(() => this.ws.close(), 10000);
    }, 20000);
  }
  onMessage(fn) { this.listeners.add(fn); }
  close() { this.intentional = true; this.ws.close(); }
}

五、在 Vue3 + TypeScript 中复用

把上面的类包成 composable,组件卸载时主动关闭,避免内存泄漏。详见 Vue3 + TypeScript 工程化实践 的类型安全思路。

Vue3 的 composable 天然适合包长连接:它把”建立连接 → 订阅消息 → 组件卸载时关闭”这套生命周期对齐到组件的 onUnmounted。很多内存泄漏的根源,是组件销毁了但 WebSocket 还活着、定时器还在跑;用 onUnmounted(() => sock.close()) 一行就根治。返回 ref<any[]> 的消息数组还能直接驱动模板渲染,配合 computed 做派生状态,响应式链路一气呵成。注意类型上给消息定义联合类型,比 any 更安全。

import { onUnmounted, ref } from 'vue';

export function useRealtime(url: string) {
  const messages = ref<any[]>([]);
  const sock = new ReconnectingSocket(url);
  sock.onMessage((m) => messages.value.push(m));
  onUnmounted(() => sock.close()); // 组件销毁即主动关闭
  return { messages };
}

六、服务端 Node.js 心跳片段

ws 库,在服务端定时扫描”最后活跃时间”,超时就 terminate()。注意终止要用 terminate() 而非 close(),前者会立即清空底层 socket。

terminate()close() 的区别很关键:close() 会走完 WebSocket 关闭握手,等客户端响应才断,若客户端已假死,握手永远等不到,连接就一直挂着吃 fd;terminate() 直接发 RST 强拆底层 TCP,毫秒级释放资源。探活场景里客户端大概率是失联的,所以必须用 terminate()。配合”每 15s 探一次、超时即拆”,服务端半开连接数能稳定在一个小常数,不会因为客户端集体掉线而 fd 打满。

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const TIMEOUT = 30000;

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
  ws.on('message', (m) => {
    const data = JSON.parse(m);
    if (data.type === 'ping') ws.send(JSON.stringify({ type: 'pong' }));
  });
});

// 每 15s 探活一次
const timer = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 15000);

七、部署要点:Nginx 代理与 WSS

WebSocket 必须走 wss://(TLS)并在反代上放行协议升级。Nginx 要加 UpgradeConnection 头,并把 proxy_read_timeout 调大,否则 60s 无活动就被断——这正是心跳周期要小于该值的原因。参考 Nginx 反向代理完整配置 的升级写法,以及 Nginx 限流防刷 对握手频次的约束。

location /ws/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 3600s;   # 大于心跳周期,避免被静默回收
    proxy_send_timeout 3600s;
}

八、五个高频坑与排查清单

现象根因处置
连接每 60s 必断Nginx/SLB 空闲超时心跳周期 < 代理超时,并调大 proxy_read_timeout
重连越来越多连接旧定时器未清理connect 前 clearTimeout,单例守卫
收不到消息但 readyState=OPEN连接假死心跳超时判死 + 主动 close 触发重连
WS 握手 400/426反代未放行 UpgradeNginx 加 Upgrade/Connection 头,HTTP/1.1
生产偶发无法连未鉴权被限流握手带 token,参考 前端安全 XSS/CSRF 的 wss 鉴权

九、小结

稳定的长连接 = 指数退避重连 + 双向心跳 + 部署层超时对齐。记住三条铁律:重连要退避不要猛锤、心跳周期必须小于代理超时、主动关闭要打标记避免自杀式重连。把这套封装沉淀成团队基础库,实时功能的稳定性就先赢了一半。

十、上线前如何压测你的重连逻辑

重连逻辑最怕”平时好好的,故障一来就雪崩”,所以要在上线前主动制造故障来验证。最简单的方法是用 Nginx 临时返回 502、或用 iptables 丢掉某个端口的包,观察客户端是否在退避窗口内干净重连、是否出现了多于一条的物理连接。还要验证”假死”场景:在客户端不发心跳但保持 TCP 通畅的情况下,确认服务端能在超时后 terminate() 并释放 fd。

指标上建议关注三个:重连成功率(故障恢复后 30s 内恢复连接的比例,目标 100%)、平均重连耗时(应接近退避封顶而非更久)、服务端半开连接数峰值。把这三个指标接进监控面,长连接的健康度就可视、可告警,而不是等到用户投诉才发现”推送不灵了”。

上一篇 限流算法实战:令牌桶与漏桶原理及 Guava/Sentinel 落地
下一篇 Elasticsearch 全文检索实战:索引原理与调优