WebSocket 这玩意儿,平时我们在聊天、行情推送、协作白板里都用得很爽,感觉它就是一条永不挂断的电话线。但一旦线上出了故障,比如连接突然断了、状态一会儿连一会儿断、服务端莫名其妙推了个关闭码过来,很多兄弟就开始抓瞎了。今天咱们不聊那些晦涩的协议规范,纯用大白话,从客户端断开这个视角,把连接状态怎么变、异常关闭码代表什么意思、到底是哪一端搞的鬼,以及出了事怎么快速止血,掰开揉碎地讲清楚。
一、WebSocket连接状态迁移:从握手到断开的生命周期
1.1 状态机:readyState
WebSocket 在客户端天生自带一个状态属性,叫 readyState。它不是随便变的,有自己的一套规矩。你可以把它想象成一个人的心路历程,只有这么几个阶段:正在连接、已经连上、正在关闭、已经关闭。咱用代码说话,先把这四种状态用最简单的方式摆出来。
// 技术栈:JavaScript(浏览器环境,无需额外依赖)
// WebSocket.readyState 是只读的,我们只需要读懂它的数值
const ws = new WebSocket('wss://example.com/socket');
// 每隔一秒打印一次当前状态,让你直观看到变化
const timer = setInterval(() => {
console.log('当前 readyState:', ws.readyState);
// 0 表示 CONNECTING(正在连接)
// 1 表示 OPEN(已经连通,可以收发数据)
// 2 表示 CLOSING(正在关闭握手)
// 3 表示 CLOSED(连接已彻底断开)
}, 1000);
// 连接成功时会触发 open 事件
ws.onopen = () => {
console.log('连接打开了!');
};
// 连接彻底关闭后触发 close 事件,这里能拿到关闭码和原因
ws.onclose = (event) => {
console.log('连接关闭,关闭码:', event.code, '原因:', event.reason);
// 关闭定时器,避免无谓的循环
clearInterval(timer);
};
你看,就是这四个数字,串起了 WebSocket 从出生到死亡的全过程。状态不是跳变的,每一步都有触发条件。
1.2 状态迁移的触发条件
咱们把状态迁移看成一条清晰的路径,它只有两条主线:
- 一条是正常线:
CONNECTING -> OPEN -> CLOSING -> CLOSED - 另一条是突发线:
CONNECTING -> CLOSED或者OPEN -> CLOSED
为什么会直接跳到 CLOSED?很简单,因为连接在建立过程中就失败了,或者连接建立后发生了底层网络异常,根本没机会走优雅的关闭流程。就像你正打着电话,手机突然没电,根本来不及说“拜拜”,电话就断了。
那么,什么情况下会走正常线?客户端主动调用 ws.close(),或者服务端发来关闭帧时,客户端会进入 CLOSING,然后双方完成挥手,最后 CLOSED。这里有个容易忽略的细节:ws.close() 可以传关闭码和原因字符串,但如果你乱传,底层会在控制台报警告。
// 技术栈:JavaScript(浏览器环境)
// 正常关闭连接,并携带一个业务自定义的原因
const ws = new WebSocket('wss://example.com/socket');
ws.onopen = () => {
// 业务上想结束连接,用 1000 表示正常关闭,原因写清楚
// 注意:reason 最长只能 123 字节,写太长会被截断或抛异常
ws.close(1000, 'Client wants to leave');
};
ws.onclose = (event) => {
console.log(`状态码: ${event.code}, 原因: ${event.reason}`);
};
你要记住,状态迁移只是表象,真正导致迁移的是那些断开行为。接下来我们看看,异常关闭码到底在说什么。
二、异常关闭码:服务端与客户端的暗号
2.1 关闭码的含义
关闭码是 WebSocket 在关闭帧里带的一个 16 位整数。它不是乱编的,有一套约定。但你不需要背全,只需要把几个最常见的记熟。我拿生活场景来类比:挂断电话时,如果对方说“我在开会,不方便”,这是正常挂断;如果对方直接摔电话,那就是异常挂断。关闭码就是双方约定好的“摔电话原因”。
2.2 客户端常见的异常关闭码
咱们从客户端视角出发,最容易遇到的关闭码有这么几个:
1000:正常关闭,大家握手言和。1001:一端要走了,比如页面要跳转或刷新了,相当于“我要去别的地方了”。1002:协议错误,比如收到了不是 WebSocket 格式的数据,相当于“你说的话我听不懂”。1003:收到了不能接受的数据类型,比如客户端只收文本,结果服务端发来二进制,相当于“菜里都是大蒜,我不吃”。1006:这个最神秘,也是最让人头疼的。它表示连接发生了非正常的关闭,而且底层没有给出任何关闭码。只要看到 1006,基本可以确定是网络层面断掉,或者中间被掐了,而不是某一端主动发送关闭帧导致的。1009:消息太大超过限制了,比如服务端只接受 64KB,你偏发 10MB。1011:服务端遇到了意外情况,比如崩溃了,相当于“服务器自己都懵了”。
这几个码,你每次排查故障都会用到。尤其是 1006,它简直就是网络黑洞的代名词。
// 技术栈:JavaScript(浏览器环境)
// 监听 close 事件,把关闭码映射成人类能读懂的语言
const ws = new WebSocket('wss://example.com/socket');
const closeCodeMap = {
1000: '正常关闭',
1001: '页面跳转或设备主动离开',
1002: '协议错误',
1003: '数据类型不受支持',
1006: '异常断开,多半是网络问题',
1009: '消息过大',
1011: '服务端内部错误'
};
ws.onclose = (event) => {
// 如果关闭码不在映射表里,就返回兜底文案
const description = closeCodeMap[event.code] || `未知关闭码 (${event.code})`;
console.log(`关闭原因:${description}`);
};
光知道码不行,我们得从客户端断开行为里找到根源。
三、客户端断开行为全剖析:谁动了我的连接?
3.1 主动断开与被动断开
客户端断开连接,表面上看起来都是“连接没了”,但本质完全不同。
主动断开,是客户端自己的代码调用了 close(),或者用户关闭了浏览器页面。你可以在 close 事件里看到关闭码是 1000 或者 1001。这是符合预期的行为,不用排查。
被动断开,是客户端什么都没干,连接却掉了。这种掉线往往伴随三种典型场景。咱们一个个分析。
3.2 断网、网络切换
这是最普遍的原因。你手机从 WiFi 切到 4G,或者在电梯里失去了信号,底层 TCP 连接悄悄就断了。但 WebSocket 的客户端不会立刻感知到,因为没有任何消息触发它。只有当你尝试发一条消息,发现发不出去,或者过了一段时间,操作系统告诉 TCP 连接已经超时,WebSocket 才会被置为 CLOSED,此时关闭码大多是 1006。
// 技术栈:JavaScript(浏览器环境)
// 监听浏览器 online/offline 事件,辅助判断网络状态
const ws = new WebSocket('wss://example.com/socket');
// 浏览器判断“离线”时,大概率是断网了
window.addEventListener('offline', () => {
console.log('网络已断开,WebSocket 可能即将关闭');
// 这里不要直接调 ws.close(),因为网络已经断了,让系统自动清理
// 你可以记录一个标记,等重连时机成熟再连
});
// 浏览器判断“在线”时,可以尝试重连
window.addEventListener('online', () => {
console.log('网络恢复,尝试重连 WebSocket');
// 具体重连逻辑后面会讲
});
注意,offline 事件不能完全代表 WebSocket 一定断开,因为应用层和物理层之间还有时间差。但这是个不错的辅助信号。
3.3 服务端主动关闭
很多时候,客户端没动,但服务端却在管理连接时把客户端给关了。比如服务端做了一次无间断部署,需要重启进程。如果服务端没有优雅关闭,就会直接断掉底层连接,客户端就收到 1006。如果服务端足够优雅,它会在关闭前发送一个关闭帧,带上 1001 或 1000,客户端就能感知到正常的关闭。
服务端为什么会在运行中放弃一条连接?最常见的是超时踢人。比如心跳超时,服务端认为客户端已经死了,主动发起关闭。这时候客户端会收到 1006,因为服务端直接断开了 TCP。
还有一种情况,服务端开启了连接数限制,当活跃连接超过阈值,会把最老的连接挤掉。这种“挤掉”通常不是特别优雅。作为客户端,你要做的就是不能把连接当成永久的,必须时刻准备着重连。
3.4 心跳机制失效
有没有发现,一个 WebSocket 连接如果长时间没有数据流动,很多中间层(比如 Nginx、路由器)会认为这个连接已经没用了,把它悄悄回收掉。这就是常说的“空闲超时”。客户端感觉连接还在,实际上代理层早就把它切了。所以客户端必须自己发声,定期发“心跳”来证明自己活着。
这里有一个反直觉的点:心跳本身不是为了服务端,而是为了维护中间链路的活性。如果心跳间隔太长,照样会被踢;太短,又会浪费流量和 CPU。经验值是 30 秒左右。
// 技术栈:JavaScript(浏览器环境)
// 简单的心跳保活 + 异常检测
const ws = new WebSocket('wss://example.com/socket');
let heartbeatTimer = null;
let alive = true;
// 每当收到任何数据,都认为连接是活的
ws.onmessage = (event) => {
// 这里可以判断是不是应答消息,简单起见,收到即活
alive = true;
console.log('收到消息,心跳视为存活');
};
// 连接打开后,开启心跳定时器
ws.onopen = () => {
console.log('连接已打开,启动心跳');
// 每 10 秒发一次 ping(用业务消息模拟)
heartbeatTimer = setInterval(() => {
// 发送前,先把 alive 置为 false,等待服务端回应
alive = false;
// 发送一个带有固定字段的业务心跳
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
// 如果发送后 5 秒内没有收到任何消息,就认为连接挂了
setTimeout(() => {
if (!alive) {
console.warn('心跳超时,连接疑似断开,准备重连');
ws.close(); // 主动关闭,触发重连机制
}
}, 5000);
}, 10000);
};
ws.onclose = (event) => {
console.log('连接关闭,开启后续处理');
clearInterval(heartbeatTimer); // 清理定时器,避免泄漏
};
心跳失效是客户端断开的隐形推手。很多线上故障,第一波排查都找不到问题,最后发现就是心跳间隔太长导致连接被中间层静默回收。
四、配套应对策略与故障修复指南
面对以上种种断开行为,我们不能坐以待毙。下面给出三套组合拳:重连策略、心跳优化、关闭码上报。
4.1 重连策略
重连不是简单地 new WebSocket() 就完事,要讲究节奏。最怕的就是“断线风暴”,成千上万个客户端同时重连,把服务端压垮。所以我们要引入指数退避和随机抖动。
// 技术栈:JavaScript(浏览器环境)
// 实现带指数退避的重连机制
class ReconnectWebSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.attempt = 0; // 当前第几次重连
this.maxAttempt = 10;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('连接成功,重置重连次数');
this.attempt = 0;
};
this.ws.onclose = (event) => {
console.log('连接关闭,关闭码', event.code);
// 如果是不想重连的场景,比如主动关闭且业务合法,就不重连
if (event.code === 1000) {
console.log('正常关闭,放弃重连');
return;
}
this.scheduleReconnect();
};
this.ws.onerror = () => {
// onerror 之后通常紧接着 onclose,为了避免重复重连,这里不做处理
console.log('连接报错,等待 close 后处理');
};
}
scheduleReconnect() {
if (this.attempt >= this.maxAttempt) {
console.error('重连次数过多,请人工介入');
return;
}
const delay = Math.min(1000 * 2 ** this.attempt, 30000);
const jitter = Math.random() * 1000;
const totalDelay = delay + jitter;
console.log(`第 ${this.attempt + 1} 次重连,延迟 ${totalDelay} ms`);
setTimeout(() => {
this.attempt++;
this.connect();
}, totalDelay);
}
}
const client = new ReconnectWebSocket('wss://example.com/socket');
client.connect();
这段代码里有几个关键点:每次成功连上就重置尝试次数;重连延迟按 2 的指数次幂增长,并加上随机抖动,避免所有客户端在同一时刻发起连接;超过最大次数就放弃,等待更高层的人工干预。
4.2 心跳优化
前面示例里的心跳还不够严谨,实际生产环境需要与服务端约定好 ping/pong 机制。如果服务端是 Node.js 的 ws 库,它原生支持 ping/pong 帧,我们可以直接用它。
// 技术栈:JavaScript(Node.js 环境,使用 ws 模块)
// 服务端实现:监听 ping 并自动回 pong,同时检测客户端是否存活
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
// 记下所有连接的存活时间
const aliveMap = new Map();
wss.on('connection', (ws) => {
console.log('客户端连接进来');
aliveMap.set(ws, Date.now());
// 每 30 秒对每个连接执行一次检查
const interval = setInterval(() => {
const lastActive = aliveMap.get(ws);
// 如果距离上次活跃超过 45 秒,就判定死亡
if (Date.now() - lastActive > 45000) {
console.log('客户端超过 45 秒没活跃,终止连接');
ws.terminate(); // 直接断开底层连接
clearInterval(interval);
aliveMap.delete(ws);
}
}, 30000);
// 收到任何消息,刷新活跃时间
ws.on('message', (data) => {
aliveMap.set(ws, Date.now());
console.log('收到消息:', data.toString());
});
// 连接关闭时清理定时器
ws.on('close', () => {
console.log('连接关闭');
clearInterval(interval);
aliveMap.delete(ws);
});
});
这个示例展示了服务端如何主动收拾“死连接”。配合客户端的业务心跳,双管齐下,连接的健康度就能大幅提升。
4.3 关闭码上报与监控
光有重连还不够,故障修复的关键在于“定位”。你要把客户端的断开行为、关闭码、网络状态、时间点都上报到日志系统或监控平台。这样一出问题,就能知道是哪个区域、哪些用户、哪种关闭码占比最高。
// 技术栈:JavaScript(浏览器环境)
// 在 close 事件中上报关键信息
const ws = new WebSocket('wss://example.com/socket');
ws.onclose = (event) => {
const reportPayload = {
code: event.code,
reason: event.reason,
wasClean: event.wasClean, // true 表示收到关闭帧,false 表示网络直接断
pageUrl: window.location.href,
userAgent: navigator.userAgent,
readyStateAtClose: ws.readyState,
timestamp: new Date().toISOString()
};
// 发送到监控接口,用 sendBeacon 确保页面关闭时也能发出去
const blob = new Blob([JSON.stringify(reportPayload)], { type: 'application/json' });
navigator.sendBeacon('/api/log-ws-close', blob);
};
这段代码里最值得关注的是 wasClean。如果它是 false,那么关闭码大概率是 1006,这就告诉我们连接不是正常握手的,是直接断的。结合网络信息,就能判断是移动网络切换还是代理超时。
4.4 服务端优雅关闭
客户端修复只是治标,服务端如果能在关闭连接时给出明确信号,整个生态就会健康很多。比如在部署或维护时,服务端应该主动给所有客户端发送关闭帧,让客户端知道“我要关门了,你们早点另谋他路”。
// 技术栈:JavaScript(Node.js 环境,使用 ws 模块)
// 服务端优雅关闭所有连接
const wss = new WebSocketServer({ port: 8080 });
// 假设这里保存了所有当前连接
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('close', () => clients.delete(ws));
});
// 当应用要关闭时,比如收到 SIGTERM 信号
process.on('SIGTERM', () => {
console.log('收到关闭信号,开始优雅关闭');
// 给所有客户端发送关闭帧,并给出关闭码 1001(表示服务端即将离开)
for (const client of clients) {
client.close(1001, 'Server is shutting down');
}
// 等所有连接都关闭后,再退出进程
setTimeout(() => {
console.log('所有连接已关闭,进程退出');
process.exit(0);
}, 5000);
});
服务端如果都这么干,客户端收到 1001 就可以走一套“服务端维护中”的提示流程,而不是抓瞎重连。
五、应用场景、技术优缺点与注意事项
5.1 应用场景
WebSocket 连接状态管理的质量,直接决定了一个实时应用的体验。典型场景包括:
- 金融行情推送:价格跳动不能有摩尔纹式的延迟,断了必须秒回。
- 协作白板:多人同时画一笔,断线意味着临时只能看不能画。
- 在线教育:课堂互动、举手、点赞,断线会直接影响课堂纪律。
- 客服系统:用户正打字,突然断线,可能消息就丢了。
这些场景的共同特点是:低延迟、双向通信、长连接。一旦断开行为处理不好,轻则消息卡顿,重则数据丢失。
5.2 技术优缺点
WebSocket 的好处不用多说:全双工、实时性高、头部开销小。但它也有几个不容忽视的缺点。
首先是状态缺乏原生持久化。服务端重启,连接就没了,客户端必须重新连,且要自己处理应用层的状态同步。其次是网络穿透难,在复杂的网络环境下,中间代理和防火墙可能随意切断长连接,而且切的时候不打招呼,导致出现大量 1006 谜团。再次是心跳是“不得不加的负担”,不用心跳又容易断,用了心跳又消耗流量,这是个拧巴的平衡。
换个角度想,这些缺点其实都是可以靠工程手段弥补的,比如我们前面讲的重连和心跳。真正怕的是对缺点没有认知,把 WebSocket 当成了永久的“TCP 替代品”,结果雪山崩塌时无能为力。
5.3 注意事项
结合日常踩坑,这里列几条要特别注意的事项:
第一,不要盲目修改关闭码。WebSocket.close() 只能传 3000-4999 之间的业务自定义码,不能传 1000 以外的保留码,否则会抛异常。
第二,重连时不要用旧连接的消息接收回调。每次 new WebSocket 都要重新绑定 onmessage、onclose,否则会出现“幽灵回调”导致消息重复处理。
第三,注意内存泄漏。定时器、闭包、事件监听器都要在连接关闭时清理干净。
第四,关闭码 1006 不能靠自己伪造,也不能通过 close() 主动发出来。它只会由底层网络异常产生。如果大量出现 1006,重点排查网络层,而不是改代码。
第五,在浏览器端,页面可见性变化会触发资源回收。如果切换标签页超过一定时间,浏览器可能会自动断开 WebSocket。这时要利用 visibilitychange 事件主动重连。
// 技术栈:JavaScript(浏览器环境)
// 页面从隐藏状态回到可见时,检查连接是否还活着,必要时重连
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
console.log('页面恢复可见,检查连接状态');
// 假设 ws 是全局已创建的连接对象,这里只检查状态
if (ws.readyState === WebSocket.CLOSED) {
console.log('连接已断开,触发重连逻辑');
// 这里调用你封装的重连方法
reconnect();
}
}
});
第六,一定要区分业务上的“服务器正在维护”和网络“链路断开”。前者应该走 1001 或 4000 等业务码,后者是 1006。不要用同一种策略去处理。
六、总结
WebSocket 的连接状态与关闭码,是整个长连接体系中最基础但也最容易出问题的一环。从状态迁移入手,我们能看清一条连接从建立到断开的所有路径;从关闭码入手,我们能快速判定断开类型;从客户端断开行为入手,我们能定位到是主动断开、网络异常、服务端踢人还是心跳失效。每一类问题都有对应的策略:指数退避重连服务网络恢复;合理的心跳保活对抗空闲超时;关闭码上报保障可观测性;服务端优雅关闭减少突然死亡。
很多故障之所以难以修复,并不是因为技术太复杂,而是因为我们对连接本身缺乏敬畏。这份敬畏体现为:不假设连接永远存在,不忽略任何一次 close,不放过每一个关闭码。当你把断开视为常态,把重连视为本能,WebSocket 服务就能在波动中保持稳定。希望这篇有点啰嗦但足够生活化的解读,能让你下次面对 1006 的时候,心里不慌,手里有活。
评论
围绕“深入解读WebSocket连接状态迁移与异常关闭码,从客户端断开行为入手细致剖析根源所在与配套应对策略,助力故障快速修复”参与讨论