上周排查一个线上问题,客户端 WebSocket 连接显示正常,但服务端已经超过两分钟没收到任何数据。用户侧的表现是消息发不出去、也不报错,整个会话就像“假死”了一样。这种问题在实时通信项目里太典型了——裸用原生 WebSocket 上线,到了生产环境必然被各种网络环境教做人。
项目早期我也干过直接用 new WebSocket(url) 怼到生产的事,结果后来每一次断线都让用户刷新页面,半夜被拉起来排查“连接为什么自己断了”是常态。直到我把连接层彻底重写了一遍:心跳检测、智能重连、二进制协议编解码,才真正睡上安稳觉。这篇就把这套企业级 WebSocket 封装的完整设计思路、核心源码(已脱敏)和经验坑位分享出来,适合正在做实时推送、在线客服、消息 IM、设备指令下发这类业务的开发者参考。
1. WebSocket 封装前的需求拆解:企业级场景的三个致命痛点
1.1 痛点一:连接假死,断网了应用却完全不知道
原生 WebSocket 的 onclose 回调,到底什么时候会触发?答案是:当 TCP 层感知到连接异常时。但现实里有大量场景 TCP 层面根本感知不到,比如拔网线、Wi-Fi 信号消失、手机从 WiFi 切到 4G/5G、路由器空闲回收映射、云厂商 NAT 网关超时回收。
简单说,你的客户端以为连接还在,服务端也以为客户端还在,但中间的网络设备早就把这条“空闲连接”当垃圾清掉了。这种状态业内叫“半开连接”(half-open connection),客户端没有主动发包,就永远发现不了问题。
还有个容易被忽略的点:TCP 协议本身有个 keepalive 机制,但默认关闭且探测周期通常是 2 小时,在企业实时通信场景下基本等于没有。所以必须在业务层自己做“心跳”,用应用层的数据包去验证链路真的活着。
1.2 痛点二:固定间隔重连会引发连接风暴
很多项目第一次升级时的写法是:onclose 里 setTimeout(() => connect(), 3000)。看起来没问题,但一旦服务端发布重启或者网络抖动,几百上千个客户端会同时掉线、同时开始计时、3 秒后同时发起重连。
这就像下班高峰期所有人同时涌向同一个地铁口——服务端瞬间被打爆。而服务端被打爆又会导致新一轮连接失败,客户端再次同时重试,形成恶性循环。我见过一个 QA 环境只有 50 个客户端,固定 1 秒重连就把服务端 CPU 打到 100% 的情况。
1.3 痛点三:协议格式只有 JSON,性能和扩展性都踩线
JSON 做业务消息协议,开发时确实方便,但企业级多端通信(Web、小程序、App、硬件设备)跑一段时间就会发现问题:
- 字段名重复传输,浪费带宽,高频场景下开销很明显
- 明文内容容易在中间环节被篡改(除非上 HTTPS/WSS)
- 消息类型靠字符串约定,拼写错误只能在运行期暴露
- 没有版本概念,协议升级时兼容性全靠自觉
所以企业级封装里,我强烈建议至少留一个“二进制消息”的扩展位。不是说所有消息都二进制,而是消息系统要支持二进制帧,业务按需选择。后面第 4 节详细讲帧结构设计。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 心跳检测:从“连接存在”到“确认连接真的活着”
2.1 为什么 TCP 层保活不够,业务层心跳必须有
TCP keepalive 的问题不只是默认关闭,它的探测机制也不可靠。TCP 保活是在连接空闲一段时间后才开始发探测包,而且底层探测失败到应用感知,往往已经过去了十几分钟。对实时通信来说,这个时间窗口太长,用户体验就是“消息发不出去,但界面一切正常”。
另外,中间网络设备(NAT 网关、云负载均衡)对空闲 TCP 连接有回收策略。比如很多云厂商 NAT 网关默认 300 秒左右回收空闲映射,如果客户端在这个时间内没有数据包,网关就会悄悄把映射表项删掉。之后客户端再发数据,网关找不到映射,直接丢弃或回 RST,连接才“被动”断开。
业务层心跳的作用,就是主动、高频地确认连接通路是通的。它的核心逻辑:客户端定时发送一个 ping 消息,服务端收到后回一个 pong;客户端如果在规定时间内没收到 pong,就认为连接已经假死,主动关闭并触发重连。
2.2 心跳参数设计:间隔、超时、连续失败次数
心跳参数是这套封装里最值得花心思的,直接定死一个值上线,早晚出事。我给出的设计参考:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 心跳发送间隔 | 服务端空闲回收时间的 1/3 左右 | 比如 Nginx 代理层 read timeout 是 60s,心跳间隔就设 20~25s |
| 单次 pong 超时 | 心跳间隔 × 2 左右 | 网络波动时给一点余量,但不至于等太久 |
| 连续失败判定次数 | 2~3 次 | 连续 2 次未收到 pong 就判定假死,触发重连 |
| 心跳消息类型 | 业务层自定义 ping/pong | 浏览器原生 WebSocket 不支持发送协议层 Ping 帧,用业务消息最通用 |
有朋友问,为什么心跳间隔是服务端回收时间的 1/3,不是 1/2?因为中间可能还有运营商 NAT、防火墙等设备,回收时间可能比应用层配置的更短。取 1/3 是保证至少在一个回收周期内能发出 3 个心跳包,形成足够冗余。
还有个细节:心跳超时的判定不能只看“有没有收到 pong”,要看“有没有收到任何数据”。有时候服务端在推送业务消息,说明连接还活着,这时候哪怕 pong 丢了也不该判定假死。所以实现时要把“最近收到任何数据”的时间戳作为重要参考。
2.3 代码实现:Ping/Pong 状态机与定时器管理
心跳逻辑我单独抽了一个 HeartbeatManager 类,不跟连接层写在一起。这样职责清晰,测试也方便。核心实现如下(TypeScript 脱敏版):
typescript复制export class HeartbeatManager {
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
private missCount = 0;
private lastReceivedTime = 0;
constructor(
private readonly intervalMs = 25000,
private readonly timeoutMs = 50000,
private readonly maxMissCount = 2,
private readonly sendPing: () => void,
private readonly onDead: () => void
) {}
start() {
this.lastReceivedTime = Date.now();
this.heartbeatTimer = setInterval(() => this.check(), this.intervalMs);
}
/** 收到任何数据时调用,包括 pong 和业务消息 */
onAnyData() {
this.lastReceivedTime = Date.now();
this.missCount = 0;
this.clearTimeoutTimer();
}
private check() {
const idleTime = Date.now() - this.lastReceivedTime;
if (idleTime < this.intervalMs) {
return;
}
this.sendPing();
this.clearTimeoutTimer();
this.timeoutTimer = setTimeout(() => {
this.missCount++;
if (this.missCount >= this.maxMissCount) {
this.onDead();
}
}, this.timeoutMs);
}
private clearTimeoutTimer() {
if (this.timeoutTimer) {
clearTimeout(this.timeoutTimer);
this.timeoutTimer = null;
}
}
stop() {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
if (this.timeoutTimer) clearTimeout(this.timeoutTimer);
this.heartbeatTimer = null;
this.timeoutTimer = null;
this.missCount = 0;
}
}
这里最关键的是 onAnyData():只要收到任何数据就认为连接活着,重置计数。否则会出现“pong 丢了但业务消息正常推送,却被误判假死”的情况。
服务端也要配合做心跳。以 Java Spring Boot 的 @ServerEndpoint 为例,收到 ping 类型的消息时回 pong,同时服务端自己做 idle 检测,超时主动关闭连接,回收僵尸 socket:
java复制@OnMessage
public void onMessage(ByteBuffer message, Session session) {
Frame frame = FrameCodec.decode(message);
if (frame.getType() == FrameType.PING) {
session.getBasicRemote().sendBinary(FrameCodec.encode(FrameType.PONG, new byte[0]));
return;
}
// 其他业务消息处理...
}
3. 智能重连:从“定时重试”到“有策略的恢复”
3.1 固定间隔重连的危害与重连策略目标
看一个真实场景:服务端凌晨发布重启,连接全部断开。如果客户端固定 3 秒重连,服务端启动后瞬间收到几百个连接请求,每个请求还要做鉴权、初始化资源。服务端刚起来本来就脆弱,这一波冲击很容易直接打垮。
智能重连的核心目标有三个:
- 错峰:让客户端的重连时间点分散,不产生“同时冲锋”
- 退避:连续失败时拉长间隔,给服务端恢复时间
- 可观测:每次重连的原因、次数、下次重连时间都要能通过日志或事件看到
3.2 指数退避 + 抖动:公式与完整实现
业界通用的方案是指数退避(Exponential Backoff)加抖动(Jitter)。公式如下:
code复制nextDelay = min(maxDelay, baseDelay × 2^attempt)
finalDelay = nextDelay + random(-jitterRange, +jitterRange)
其中 attempt 是连续失败次数,jitterRange 通常取 nextDelay 的 20%~50%。注意抖动要加在最终值上,而不是对指数结果做乘法,否则高峰期的错峰效果不好。
我用一段 TypeScript 实现重连策略:
typescript复制export class ReconnectStrategy {
private attempt = 0;
constructor(
private readonly baseDelay = 1000,
private readonly maxDelay = 30000,
private readonly jitterRatio = 0.3
) {}
nextDelay(): number {
const expDelay = Math.min(
this.maxDelay,
this.baseDelay * Math.pow(2, this.attempt)
);
const jitter = expDelay * this.jitterRatio;
const finalDelay = expDelay - jitter + Math.random() * jitter * 2;
this.attempt++;
return Math.floor(finalDelay);
}
reset() {
this.attempt = 0;
}
getAttempt(): number {
return this.attempt;
}
}
用这个策略,前几次的重连间隔大概是:
| 失败次数 | 基础延迟 | 加上 ±30% 抖动后的范围 |
|---|---|---|
| 1 | 1s | 0.7s ~ 1.3s |
| 2 | 2s | 1.4s ~ 2.6s |
| 3 | 4s | 2.8s ~ 5.2s |
| 4 | 8s | 5.6s ~ 10.4s |
| 5 | 16s | 11.2s ~ 20.8s |
| 6+ | 30s | 21s ~ 39s |
看到没,到第 5、6 次之后延迟已经比较大了。这样做的好处是:如果是网络瞬断,重连能快速恢复;如果是服务端故障,客户端会逐渐“冷静”下来,而不是疯狂打请求。
3.3 重连状态机:区分首次连接、被动断开、主动销毁
重连逻辑最怕的是状态混乱。比如用户主动退出登录,连接应该直接关闭,不再重连。但如果代码里没有明确的状态区分,onclose 一旦触发就会走重连逻辑,用户退出后还在后台疯狂重连。
我把连接生命周期抽象成几个状态:IDLE(初始)、CONNECTING(连接中)、CONNECTED(已连接)、RECONNECTING(重连中)、CLOSED(已销毁)。核心连接管理类维护当前状态,每次状态变更对外抛出事件:
typescript复制export enum ConnectionState {
IDLE = 'idle',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
CLOSED = 'closed',
}
关键点:onclose 事件里,如果当前是 CLOSED 状态(用户主动关闭),直接返回,不触发重连。只有 CONNECTED 或 RECONNECTING 状态下断开的才进入重连流程。同时,每次主动调用 close() 时,要把 userInitiated 标志置为 true。
3.4 网络监听与前后台切换
浏览器端和移动端还要处理两类场景:
- 断网恢复:浏览器
navigator.onLine变化时,立即触发一次重连尝试,不用等退避计时器 - 切后台:App 切后台后,系统可能挂起定时器,甚至直接断开网络。此时应该暂停重连计时器,等回前台时再立即恢复
页面 visibilitychange 事件检测到切回前台时,主动检查连接状态,如果断开就重置重连策略并立即重连,而不是傻等退避计时器。
重连成功后的状态补偿也很重要。如果之前有订阅的 topic、有发送中的消息队列、有服务端的消息游标 offset,都需要在重连成功后重新处理。具体做法:重连成功回调里做一次 resubscribe(),把未确认的消息重新发一次(携带幂等 ID)。
4. 二进制数据处理:协议设计、编解码与内存防护
4.1 什么时候该上二进制协议
先给结论:如果只是给一个后台管理页面做通知推送,JSON 完全够用;如果要做多端、高频、长连接的实时通信,二进制协议是早晚要上的。
| 对比项 | JSON 文本协议 | 二进制协议 |
|---|---|---|
| 消息体积 | 大,字段名重复传输 | 小,只有结构化数据 |
| 解析性能 | 需要字符串解析 | 直接按字节读取 |
| 可读性 | 好 | 差,需要工具辅助 |
| 扩展性 | 依赖字段加字段 | 依赖版本号 + 类型号 |
| 安全性 | 明文,易篡改 | 有魔数/长度校验,防篡改 |
体积差距举个例子:一个简单的 { "type": "heartbeat", "ts": 1690000000000 } 消息,JSON 是 47 字节;二进制帧设计好之后心跳包能做到 10 字节左右。
4.2 自研二进制帧格式设计
我用的帧格式是 13 字节定长头 + 变长 payload:
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | 魔数 | 2B | 固定 0xABCD,用于快速校验和识别 |
| 2 | 版本号 | 1B | 协议版本,未来兼容升级 |
| 3 | 消息类型 | 1B | 0x01 PING / 0x02 PONG / 0x03 业务消息 / 0x04 鉴权 |
| 4 | 序列化方式 | 1B | 0=JSON / 1=Protobuf / 2=MsgPack |
| 5 | 消息 ID | 4B | 用于请求响应关联、链路追踪、乱序检测 |
| 9 | payload 长度 | 4B | 无符号整数,限定 payload 字节数 |
| 13 | payload | 变长 | 具体业务数据 |
为什么消息 ID 要 4 字节?因为 2 字节的 65535 在长连接场景很容易溢出,4 字节能容纳 42 亿个,即使每秒发 1 万条也能跑很久。消息 ID 在排查乱序、重复问题时非常关键。
为什么用大端序(Big-Endian)?因为网络字节序标准就是大端,所有语言解析起来都一致,避免小端平台之间的兼容性问题。
4.3 编解码代码实现与边界校验
Node.js 端的解码实现:
javascript复制const FRAME_HEADER_LEN = 13;
const MAGIC = 0xabcd;
const MAX_PAYLOAD = 4 * 1024 * 1024; // 4MB 上限
function encodeFrame(type, payload, msgId) {
const payloadBuf = Buffer.isBuffer(payload)
? payload
: Buffer.from(payload, 'utf-8');
if (payloadBuf.length > MAX_PAYLOAD) {
throw new Error('payload too large: ' + payloadBuf.length);
}
const header = Buffer.alloc(FRAME_HEADER_LEN);
header.writeUInt16BE(MAGIC, 0);
header.writeUInt8(1, 2); // version
header.writeUInt8(type, 3);
header.writeUInt8(0, 4); // serialize type = JSON
header.writeUInt32BE(msgId >>> 0, 5);
header.writeUInt32BE(payloadBuf.length, 9);
return Buffer.concat([header, payloadBuf]);
}
function decodeFrame(buffer) {
if (buffer.length < FRAME_HEADER_LEN) {
return { incomplete: true, need: FRAME_HEADER_LEN - buffer.length };
}
const magic = buffer.readUInt16BE(0);
if (magic !== MAGIC) {
throw new Error('invalid magic: 0x' + magic.toString(16));
}
const version = buffer.readUInt8(2);
const type = buffer.readUInt8(3);
const serializeType = buffer.readUInt8(4);
const msgId = buffer.readUInt32BE(5);
const payloadLen = buffer.readUInt32BE(9);
if (payloadLen > MAX_PAYLOAD) {
throw new Error('frame payload too large: ' + payloadLen);
}
if (buffer.length < FRAME_HEADER_LEN + payloadLen) {
return { incomplete: true, need: FRAME_HEADER_LEN + payloadLen - buffer.length };
}
const payload = buffer.slice(FRAME_HEADER_LEN, FRAME_HEADER_LEN + payloadLen);
return {
incomplete: false,
header: { version, type, serializeType, msgId, payloadLen },
payload,
consumed: FRAME_HEADER_LEN + payloadLen,
};
}
解码时必须做三件事:校验魔数、校验 payload 长度、确认 buffer 足够长。魔数校验能过滤掉不少非法连接发来的垃圾数据;长度校验防止有人发一个声称 4GB payload 的恶意包把内存打爆;长度不足时返回 incomplete,等待下一个数据块拼接。
Java 端的解码思路一致,用 ByteBuffer 即可:
java复制public static Frame decode(ByteBuffer buffer) throws ProtocolException {
if (buffer.remaining() < 13) {
throw new ProtocolException("frame header incomplete");
}
int magic = buffer.getShort() & 0xFFFF;
if (magic != 0xABCD) {
throw new ProtocolException("invalid magic: " + Integer.toHexString(magic));
}
byte version = buffer.get();
byte type = buffer.get();
byte serializeType = buffer.get();
long msgId = buffer.getInt() & 0xFFFFFFFFL;
int payloadLen = buffer.getInt();
// 这里必须限制 payloadLen 的最大值
byte[] payload = new byte[payloadLen];
buffer.get(payload);
return new Frame(version, type, serializeType, msgId, payload);
}
4.4 大消息、分片与背压:防止内存被打爆
WebSocket 协议本身支持消息分片,但框架层有时会自动组包,导致你收到一个超大消息时内存瞬间涨一大截。所以:
- 服务端要设置最大帧大小,比如 Spring 的
maxBinaryMessageBufferSize,超过直接拒绝 - 客户端解码时同样要限制 payload 长度,超过就丢弃并告警
- 收款端处理消息时不要同步阻塞在 IO 线程里,丢进队列异步处理,防止消息洪峰把事件循环卡死
一句话:协议层负责格式,传输层负责限流,应用层负责背压,三层都守住才不会出事。
5. 脱敏源码结构与核心实现解读
5.1 整体模块划分
这套封装我按职责拆成了四个模块,目录结构如下:
- core:连接管理器
ReconnectingWebSocket,负责建连、断开、状态流转 - heartbeat:心跳管理器
HeartbeatManager,负责 ping/pong 和假死判定 - reconnect:重连策略
ReconnectStrategy,负责退避延迟计算 - codec:二进制编解码
BinaryFrameCodec,负责帧解析和封装
核心连接管理器的思路是:内部持有 WebSocket 实例、心跳管理器、重连策略,对外暴露 connect()、close()、send() 三个方法,以及状态变更事件。上层业务完全不感知连接细节。
5.2 连接管理器:核心状态流转实现
typescript复制import { HeartbeatManager } from './heartbeat';
import { ReconnectStrategy } from './reconnect';
import { ConnectionState } from './types';
export class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private state: ConnectionState = ConnectionState.IDLE;
private reconnectStrategy = new ReconnectStrategy();
private heartbeat: HeartbeatManager;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private userInitiatedClose = false;
constructor(private url: string) {
this.heartbeat = new HeartbeatManager(
25000,
50000,
2,
() => this.sendPing(),
() => this.handleDead()
);
}
connect() {
this.userInitiatedClose = false;
this.transit(ConnectionState.CONNECTING);
this.ws = new WebSocket(this.url);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.reconnectStrategy.reset();
this.transit(ConnectionState.CONNECTED);
this.heartbeat.start();
};
this.ws.onmessage = (event) => {
this.heartbeat.onAnyData();
// 二进制帧解析后转发给上层 handler
const frame = BinaryFrameCodec.decode(event.data);
if (frame.type === FrameType.PONG) return;
this.dispatch(frame);
};
this.ws.onclose = (event) => {
this.heartbeat.stop();
if (this.userInitiatedClose) {
this.transit(ConnectionState.CLOSED);
return;
}
this.scheduleReconnect();
};
this.ws.onerror = () => {
// error 之后通常紧跟着 close,这里只记录日志
console.warn('ws error occurred, waiting for close event');
};
}
private handleDead() {
console.warn('heartbeat dead, closing connection...');
this.ws?.close();
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.transit(ConnectionState.RECONNECTING);
const delay = this.reconnectStrategy.nextDelay();
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
private sendPing() {
this.sendFrame(FrameType.PING, Buffer.alloc(0));
}
private sendFrame(type: number, payload: Buffer) {
if (this.ws?.readyState !== WebSocket.OPEN) {
console.warn('send failed, connection not open', { state: this.state });
return;
}
const buf = BinaryFrameCodec.encodeFrame(type, payload, Date.now() % 0xffffffff);
this.ws.send(buf);
}
private transit(next: ConnectionState) {
this.state = next;
// 对外广播状态变更,上层可以监听做 UI 展示或埋点
this.emit('statusChanged', next);
}
}
几个容易踩的坑,我在代码里已经做了处理:
onerror里不直接重连,而是等onclose,因为 WebSocket 规范里 error 后一定会跟一个 close,在 error 里重连会重复触发readyState不是OPEN时,send直接失败并打日志,不抛异常- 重连定时器防止重复创建,
reconnectTimer非空时直接返回
5.3 心跳与重连模块的协作方式
心跳管理器发现假死后调用 handleDead(),触发 ws.close(),然后进入 scheduleReconnect()。这里的关键是:不要在心跳模块里直接调用重连,而是通过关闭连接走统一的重连入口。这样保证状态机只有一个入口,不会出现在重连过程中又收到心跳超时导致状态错乱的情况。
HeartbeatManager.onDead 是在心跳超时回调里触发的。如果连接已经断开,ws.close() 调用不会有什么副作用,因为 readyState 已经变了。但如果 close() 被重复调用会抛异常,所以生产代码里我会加一个判断,只有 readyState 是 CONNECTING 或 OPEN 时才调用。
5.4 完整源码的引用说明
上面给出的代码已经覆盖了核心模块,是完整可运行的脱敏版本。真实项目里还有一些业务相关的东西:登录鉴权后从服务端获取的 token 刷新逻辑、应用层心跳消息的序列化方式、消息队列的持久化重发逻辑,这些因为涉及业务密钥和内部中间件,已经替换成占位符。核心的连接生命周期管理、心跳判定、重连策略、二进制编解码逻辑全部是生产级可复用的。
6. 常见问题与排查实录:那些线上踩过的坑
6.1 “stream disconnected before completion” 到底是谁断的
很多 Node.js 项目里会看到这个报错:stream disconnected before completion: websocket closed by server before res。我排查过几次,这类报错的本质是:客户端发起的 WebSocket 请求在完成握手前,服务端就已经把连接关闭了。
常见的触发原因有三个:
- 服务端空闲回收时间太短,比如
proxy_read_timeout只有 30s,而心跳间隔是 60s,连接必然被掐 - 服务端
onOpen回调里抛了异常,连接刚建立就被关闭 - 鉴权失败,服务端直接返回关闭帧,但客户端的错误处理不够完善
排查步骤我建议按这个顺序:先看 onclose 事件里的 code 和 reason,比如 4001 一般是鉴权失败、1006 是非正常关闭;再看服务端日志里有没有连接初始化异常;最后用 tcpdump 抓包看 FIN 包是谁发的,一抓一个准。
6.2 Nginx 反向代理下的 WebSocket 配置
WebSocket 升级依赖 HTTP 的 Upgrade 和 Connection 头,Nginx 默认不会转发这两个头,必须在 location 配置里显式声明:
nginx复制location /ws {
proxy_pass http://backend_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_buffering off;
}
几个关键点:
proxy_read_timeout必须大于心跳间隔,否则连接空闲超过这个时间就会被 Nginx 掐断proxy_buffering off是关掉缓冲,保证消息能即时推送下去,尤其是二进制帧,等缓冲满再推会导致明显延迟- 如果服务端用了 HTTPS/WSS,Nginx 这边要配好 SSL 证书,客户端不用关心这层
6.3 内存泄漏与连接泄漏排查
WebSocket 项目最常见的泄漏不是连接本身,而是事件监听器泄漏。比如每次 connect() 都往 window 上挂一个 online 监听器,重连 100 次就挂 100 个,内存涨上去就不掉下来。
自查清单:
- 定时器是否在断开时清理:
setInterval/setTimeout没有 clear 是头号问题 - 事件监听器是否重复绑定:用
AbortController或者在建连时统一解绑再绑定 - 连接数是否只增不减:用
ss -tnp | grep <port> | wc -l看系统连接数,配合服务端监控看谁没关连接 - 服务端有没有主动回收僵尸连接:只靠客户端心跳不够,服务端也要做 idle 检测,超过 N 秒没数据就主动 close
6.4 心跳与重连打架:恢复后没有重置定时器
这个坑最隐蔽,我花了大半个晚上才定位到。现象是:网络恢复后客户端重连成功了,但没几秒又断,然后再重连,陷入死循环。
查到最后的原因:重连成功进入 onopen 后,心跳管理器确实是重新 start() 了,但旧的 missCount 没有重置,而且心跳定时器里如果上一次心跳超时的 timeoutTimer 还在,到期后又会触发 handleDead(),把刚建立的连接又掐断。
修复方法很简单:每次 start() 时把心跳管理器的所有状态清零,包括 missCount、lastReceivedTime、所有计时器。我在 HeartbeatManager.start() 里加了完整的重置逻辑,这个细节一定要有。
写在最后
做这套 WebSocket 封装最值钱的不是代码本身,而是把“连接”这件事从隐性的变成了可观测的。以前排查连接问题全靠猜,现在每个状态迁移都有日志,每次重连都有原因和延迟时间,配合服务端连接数监控,基本不会再被“连接断了”这种反馈搞得半夜爬起来。
最后分享一个小技巧:客户端日志里一定要打上 attempt 序号和 nextDelay,这样看到日志就知道重连到第几次了、下次什么时候重试。再配合服务端的连接数曲线,就能快速判断是客户端问题还是服务端问题。这套方案在多个项目里实跑下来很稳,你可以直接抄走改改接入自己的系统。
