声明:本文部分内容使用AI辅助生成,经人工编辑、审核和补充个人经验。
更新说明:技术栈版本信息基于 Node.js 18.x / WebSocket (ws) 8.x。
做实时游戏的时候,WebSocket 连接断开是个头疼的问题。用户关浏览器、网络断掉、服务器重启,各种情况都要处理。这篇记录一下实际项目中遇到的断开场景和解决方案。
断开场景分类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| ┌─────────────────────────────────────────────────────────────────────┐ │ WebSocket 断开场景分类 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ 正常断开(优雅关闭) │ ┌─────────────────────────────────────────────────────────────┐ │ │ • 浏览器正常关闭页面 │ │ │ • 客户端主动调用 close() │ │ │ • 服务器主动关闭连接 │ │ │ • 完成通信后正常结束 │ │ └─────────────────────────────────────────────────────────────┘ │ │ 异常断开(非优雅关闭) │ ┌─────────────────────────────────────────────────────────────┐ │ │ • 浏览器崩溃/进程被杀 │ │ │ • 客户端断电 │ │ │ • 网络中断(断网/切换网络) │ │ │ • 服务器崩溃/重启 │ │ │ • 长时间无数据传输(TCP 超时) │ │ │ • 防火墙切断连接 │ │ └─────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘
|
客户端断开场景分析
场景 1:浏览器正常关闭
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| ┌─────────────────────────────────────────────────────────────┐ │ 浏览器正常关闭流程 │ ├─────────────────────────────────────────────────────────────┤ │ │ Browser Nginx/Server │ │ │ │ │ 1. 用户关闭页面 │ │ │ 或跳转页面 │ │ │ │ │ │ 2. 浏览器发送 │ │ │ Close Frame │ │ │──────────────────────► │ │ │ │ │ │ 3. 触发 onClose 回调 │ │ │ │ │ 4. 回复 Close Frame │ │ │◄────────────────────── │ │ │ │ │ │ 5. 执行清理逻辑 │ │ │ • 移除会话 │ │ │ • 广播离开消息 │ │ │ │ │ 6. TCP 四次挥手 │ │ │◄══════════════════════► │ │ 特点: │ • 触发 onClose,不触发 onError │ • 完整的 TCP 四次挥手 │ • 应用层可以及时感知 │ └─────────────────────────────────────────────────────────────┘
|
服务端处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws, req) => { const clientId = generateClientId(); const clientIp = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
console.log(`Client ${clientId} connected from ${clientIp}`);
SessionManager.add(clientId, ws);
ws.on('message', (data) => { handleMessage(clientId, data); });
ws.on('close', (code, reason) => { console.log(`Client ${clientId} disconnected gracefully`); console.log(`Code: ${code}, Reason: ${reason}`);
SessionManager.remove(clientId);
broadcast('user_left', { clientId });
logDisconnection(clientId, 'graceful', code, reason); }); });
|
关闭状态码说明:
| 状态码 |
名称 |
含义 |
| 1000 |
Normal Closure |
正常关闭 |
| 1001 |
Going Away |
浏览器关闭/离开页面 |
| 1002 |
Protocol Error |
协议错误 |
| 1003 |
Unsupported Data |
收到不支持的数据类型 |
| 1005 |
No Status |
没有状态码 |
| 1006 |
Abnormal Closure |
异常关闭(连接意外断开) |
| 1008 |
Policy Violation |
违反策略 |
| 1009 |
Message Too Big |
消息太大 |
| 1011 |
Server Error |
服务器错误 |
| 1012 |
Service Restart |
服务器重启 |
| 1013 |
Try Again Later |
稍后重试 |
场景 2:浏览器崩溃/进程被杀
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| ┌─────────────────────────────────────────────────────────────┐ │ 浏览器崩溃/进程被杀流程 │ ├─────────────────────────────────────────────────────────────┤ │ │ Browser OS Server │ │ │ │ │ │ Crash/Kill │ │ │ │──────────────────►│ │ │ │ │ │ │ │ │ 1. 进程终止 │ │ │ │ 关闭所有 FD │ │ │ │ │ │ │ │ 2. 发送 FIN │ │ │ │─────────────────────► │ │ │ │ │ │ │ │ 3. 触发 onClose │ │ │ │ onError │ │ │ │ │ │ │ 4. TCP 挥手可能 │ │ │ │ 不完整 │ │ │ │◄═══════════════════►│ │ │ 特点: │ • 可能同时触发 onClose 和 onError │ • TCP 挥手可能不完整 │ • 服务端通常能快速感知(几秒到几十秒) │ └─────────────────────────────────────────────────────────────┘
|
服务端处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| ws.on('close', (code, reason) => { if (code === 1006) { console.log('Abnormal closure detected'); handleAbnormalDisconnect(clientId); } });
ws.on('error', (error) => { console.error('WebSocket error:', error.message); handleSocketError(clientId, error); });
function handleAbnormalDisconnect(clientId) { const player = GameRoom.getPlayer(clientId); if (player && player.isInGame()) { GameRoom.enableAI(clientId);
ReconnectManager.startCountdown(clientId, 120000); } }
|
场景 3:客户端断网/断电
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| ┌─────────────────────────────────────────────────────────────┐ │ 客户端断网/断电流程 │ ├─────────────────────────────────────────────────────────────┤ │ │ Client Network Server │ │ │ │ │ │ 断网/断电 │ │ │ │────────────────────►│ │ │ │ │ │ │ │ X │ 1. 无数据传输 │ │ │ │ │ │ │ │ 2. TCP 连接保持 │ │ │ │ (假死状态) │ │ │ │ │ │ │ │ │ 3. 服务端发送 │ │ │ │ 数据无响应 │ │ │ │ │ │ │ │ 4. 重试多次后 │ │ │ │ 触发超时 │ │ │ │ │ │ │ │ 5. 服务端判定 │ │ │ │ 连接断开 │ │ │ │ │ 时间线: │ ───────────────────────────────────────────────────────── │ 断网瞬间 │ │ │ ▼ │ TCP 假死期(可能持续几分钟到几小时) │ │ │ ▼ │ 服务端检测到超时(取决于心跳/发送超时设置) │ └─────────────────────────────────────────────────────────────┘
|
关键问题:TCP 假死(Zombie Connection)
TCP 连接在以下情况可能保持”假死”状态:
- 物理网络断开(拔网线、断电)
- 客户端网络切换(WiFi ↔ 4G)
- 客户端进入深度休眠
服务端解决方案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| class ConnectionManager { constructor() { this.connections = new Map(); this.heartbeatInterval = 30000; this.heartbeatTimeout = 60000; }
addConnection(clientId, ws) { const conn = { ws, clientId, lastHeartbeat: Date.now(), heartbeatMissed: 0 }; this.connections.set(clientId, conn);
this.startHeartbeatCheck(clientId); }
startHeartbeatCheck(clientId) { const interval = setInterval(() => { const conn = this.connections.get(clientId); if (!conn) { clearInterval(interval); return; }
const elapsed = Date.now() - conn.lastHeartbeat;
if (elapsed > this.heartbeatTimeout) { console.log(`Heartbeat timeout for client ${clientId}`); this.handleDeadConnection(clientId); clearInterval(interval); } else if (elapsed > this.heartbeatInterval) { this.sendPing(clientId); } }, 5000); }
sendPing(clientId) { const conn = this.connections.get(clientId); if (conn && conn.ws.readyState === WebSocket.OPEN) { conn.ws.ping(); } }
updateHeartbeat(clientId) { const conn = this.connections.get(clientId); if (conn) { conn.lastHeartbeat = Date.now(); conn.heartbeatMissed = 0; } }
handleDeadConnection(clientId) { const conn = this.connections.get(clientId); if (conn) { conn.ws.terminate(); this.connections.delete(clientId);
this.handleDisconnect(clientId, 'heartbeat_timeout'); } } }
|
服务端断开场景分析
场景 4:服务器正常重启
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
| class GracefulShutdown { constructor(server) { this.server = server; this.connections = new Map(); this.isShuttingDown = false; }
init() { this.server.on('connection', (ws, req) => { const id = generateId(); this.connections.set(id, { ws, id });
ws.on('close', () => { this.connections.delete(id); }); });
process.on('SIGTERM', () => this.shutdown()); process.on('SIGINT', () => this.shutdown()); }
async shutdown() { console.log('Starting graceful shutdown...'); this.isShuttingDown = true;
this.server.close(() => { console.log('Server closed, no new connections accepted'); });
const closePromises = []; for (const [id, conn] of this.connections) { closePromises.push(this.closeConnection(conn)); }
await Promise.race([ Promise.all(closePromises), new Promise(resolve => setTimeout(resolve, 30000)) ]);
for (const [id, conn] of this.connections) { conn.ws.terminate(); }
await this.cleanup();
console.log('Graceful shutdown completed'); process.exit(0); }
closeConnection(conn) { return new Promise((resolve) => { if (conn.ws.readyState === WebSocket.OPEN) { conn.ws.send(JSON.stringify({ type: 'server_shutdown', message: 'Server is restarting, please reconnect later' }));
conn.ws.close(1012, 'Service Restart'); }
setTimeout(() => { if (conn.ws.readyState !== WebSocket.CLOSED) { conn.ws.terminate(); } resolve(); }, 5000); }); } }
|
场景 5:服务器崩溃/断电
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| ┌─────────────────────────────────────────────────────────────┐ │ 服务器崩溃/断电场景 │ ├─────────────────────────────────────────────────────────────┤ │ │ Server Crash Client Impact │ │ 1. 进程突然终止 1. onClose 触发(如果连接被 │ 无通知发送 操作系统关闭) │ 2. 更可能是 onError 触发 │ 2. TCP 连接由 OS 3. 连接状态变为 CLOSED │ 强制关闭 │ │ 客户端检测时间: │ ┌────────────────────────────────────────────────────────┐ │ │ 心跳周期 检测时间 │ │ ├────────────────────────────────────────────────────────┤ │ │ 1秒/次 ~167秒 (实测) │ │ │ 1分钟/次 ~6分40秒 │ │ │ 10分钟/次 ~11-16分钟 │ │ └────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘
|
心跳机制最佳实践
心跳策略对比
| 策略 |
心跳间隔 |
检测时间 |
适用场景 |
| 短周期 |
5-10秒 |
15-30秒 |
实时游戏、高频交易 |
| 中周期 |
30秒 |
60-90秒 |
普通应用、聊天室 |
| 长周期 |
5分钟 |
10-15分钟 |
低频应用、通知推送 |
完整心跳实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| class HeartbeatManager { constructor(ws, options = {}) { this.ws = ws; this.interval = options.interval || 30000; this.timeout = options.timeout || 60000; this.onTimeout = options.onTimeout || (() => {});
this.lastPong = Date.now(); this.pingTimer = null; this.checkTimer = null; }
start() { this.pingTimer = setInterval(() => { if (this.ws.readyState === WebSocket.OPEN) { this.ws.ping(); } }, this.interval);
this.checkTimer = setInterval(() => { if (Date.now() - this.lastPong > this.timeout) { this.onTimeout(); } }, 5000);
this.ws.on('pong', () => { this.lastPong = Date.now(); });
this.ws.on('message', () => { this.lastPong = Date.now(); }); }
stop() { clearInterval(this.pingTimer); clearInterval(this.checkTimer); } }
class ClientHeartbeat { constructor(ws, options = {}) { this.ws = ws; this.interval = options.interval || 30000; this.timeout = options.timeout || 60000; this.onDisconnect = options.onDisconnect || (() => {});
this.lastPong = Date.now(); }
start() { this.timer = setInterval(() => { if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() })); } }, this.interval);
this.ws.addEventListener('message', () => { this.lastPong = Date.now(); });
this.checkTimer = setInterval(() => { if (Date.now() - this.lastPong > this.timeout) { console.error('Heartbeat timeout, connection may be dead'); this.onDisconnect(); } }, 5000); }
stop() { clearInterval(this.timer); clearInterval(this.checkTimer); } }
|
重连策略
指数退避重连
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| class ReconnectManager { constructor(options = {}) { this.maxRetries = options.maxRetries || 10; this.baseDelay = options.baseDelay || 1000; this.maxDelay = options.maxDelay || 30000; this.retryCount = 0; }
async connect(url) { try { this.ws = new WebSocket(url); await this.waitForConnection(); this.retryCount = 0; this.onConnect(); } catch (error) { this.handleDisconnect(); } }
handleDisconnect() { if (this.retryCount >= this.maxRetries) { this.onMaxRetriesReached(); return; }
const delay = Math.min( this.baseDelay * Math.pow(2, this.retryCount), this.maxDelay );
const jitter = Math.random() * 1000; const finalDelay = delay + jitter;
console.log(`Reconnecting in ${finalDelay}ms (attempt ${this.retryCount + 1}/${this.maxRetries})`);
setTimeout(() => { this.retryCount++; this.connect(this.url); }, finalDelay); }
waitForConnection() { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Connection timeout')); }, 10000);
this.ws.onopen = () => { clearTimeout(timeout); resolve(); };
this.ws.onerror = (error) => { clearTimeout(timeout); reject(error); }; }); } }
|
写在最后
WebSocket 连接断开处理的一些经验:
区分断开类型:
- 优雅关闭:触发 onClose,可正常清理
- 异常断开:可能触发 onError,需要心跳检测
心跳机制:
- 短周期(5-10秒):实时游戏场景
- 中周期(30秒):普通应用场景
- 长周期(5分钟):低频应用场景
TCP 假死处理:
- 仅靠 TCP 无法检测物理层断开
- 必须通过应用层心跳确认连接状态
重连策略:
- 指数退避避免服务器压力
- 最大重试次数防止无限重连
- 随机抖动避免惊群效应