在实时协作、行情推送、在线客服这类场景里,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() 函数?因为长连接是有状态的:当前处于第几次重连、有没有挂心跳定时器、是不是主动关闭,这些状态必须被持有。函数式写法每次重连都丢失上下文,定时器也容易泄漏。类用实例字段 attempt、intentional、_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 要加 Upgrade 与 Connection 头,并把 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 | 反代未放行 Upgrade | Nginx 加 Upgrade/Connection 头,HTTP/1.1 |
| 生产偶发无法连 | 未鉴权被限流 | 握手带 token,参考 前端安全 XSS/CSRF 的 wss 鉴权 |
九、小结
稳定的长连接 = 指数退避重连 + 双向心跳 + 部署层超时对齐。记住三条铁律:重连要退避不要猛锤、心跳周期必须小于代理超时、主动关闭要打标记避免自杀式重连。把这套封装沉淀成团队基础库,实时功能的稳定性就先赢了一半。
十、上线前如何压测你的重连逻辑
重连逻辑最怕”平时好好的,故障一来就雪崩”,所以要在上线前主动制造故障来验证。最简单的方法是用 Nginx 临时返回 502、或用 iptables 丢掉某个端口的包,观察客户端是否在退避窗口内干净重连、是否出现了多于一条的物理连接。还要验证”假死”场景:在客户端不发心跳但保持 TCP 通畅的情况下,确认服务端能在超时后 terminate() 并释放 fd。
指标上建议关注三个:重连成功率(故障恢复后 30s 内恢复连接的比例,目标 100%)、平均重连耗时(应接近退避封顶而非更久)、服务端半开连接数峰值。把这三个指标接进监控面,长连接的健康度就可视、可告警,而不是等到用户投诉才发现”推送不灵了”。




