一个「假在线」的排查: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 拆开,把每个回调里的旧连接判断补上,再说别的。