WebSocket 连接断开处理实战经验

做实时游戏的时候,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
// Node.js + ws 库示例
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()) {
// 启动托管 AI
GameRoom.enableAI(clientId);

// 启动重连倒计时
ReconnectManager.startCountdown(clientId, 120000); // 2分钟
}
}

场景 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; // 30秒
this.heartbeatTimeout = 60000; // 60秒
}

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); // 每5秒检查一次
}

sendPing(clientId) {
const conn = this.connections.get(clientId);
if (conn && conn.ws.readyState === WebSocket.OPEN) {
conn.ws.ping(); // 发送 WebSocket ping frame
}
}

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;

// 1. 停止接受新连接
this.server.close(() => {
console.log('Server closed, no new connections accepted');
});

// 2. 通知所有客户端
const closePromises = [];
for (const [id, conn] of this.connections) {
closePromises.push(this.closeConnection(conn));
}

// 3. 等待连接关闭(最多30秒)
await Promise.race([
Promise.all(closePromises),
new Promise(resolve => setTimeout(resolve, 30000))
]);

// 4. 强制关闭剩余连接
for (const [id, conn] of this.connections) {
conn.ws.terminate();
}

// 5. 清理资源
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'); // 1012 = Service Restart
}

// 5秒后强制关闭
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() {
// 发送 ping
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);

// 监听 pong
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 连接断开处理的一些经验:

  1. 区分断开类型

    • 优雅关闭:触发 onClose,可正常清理
    • 异常断开:可能触发 onError,需要心跳检测
  2. 心跳机制

    • 短周期(5-10秒):实时游戏场景
    • 中周期(30秒):普通应用场景
    • 长周期(5分钟):低频应用场景
  3. TCP 假死处理

    • 仅靠 TCP 无法检测物理层断开
    • 必须通过应用层心跳确认连接状态
  4. 重连策略

    • 指数退避避免服务器压力
    • 最大重试次数防止无限重连
    • 随机抖动避免惊群效应

有问题欢迎留言交流。