WebSocket 连接断开处理与心跳机制设计完全指南:优雅关闭、异常检测与自动重连实战

引言

WebSocket 是实现实时双向通信的核心技术,广泛应用于在线游戏、即时通讯、股票行情等场景。然而,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
24
25
26
27
┌─────────────────────────────────────────────────────────────────────┐
│ WebSocket 断开场景分类 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 客户端主动断开 │
│ ├── 正常关闭浏览器(触发 onClose) │
│ ├── 杀掉浏览器进程(触发 onClose + onError) │
│ ├── 关闭页面/刷新(触发 onClose) │
│ └── 调用 ws.close()(触发 onClose) │
│ │
│ 客户端异常断开 │
│ ├── 断电断网(TCP 假死,需心跳检测) │
│ ├── 网络切换(WiFi → 4G) │
│ ├── 网络超时(路由不可达) │
│ └── 防火墙拦截 │
│ │
│ 服务端主动断开 │
│ ├── 正常重启服务(触发客户端 onClose) │
│ ├── 调用 ws.terminate()(触发客户端 onClose + onError) │
│ └── 负载均衡器断开 │
│ │
│ 服务端异常断开 │
│ ├── 进程崩溃(kill -9) │
│ ├── 服务器断电 │
│ └── 网络故障 │
│ │
└─────────────────────────────────────────────────────────────────────┘

客户端断开场景处理

场景一:正常关闭浏览器

当用户正常关闭浏览器或关闭页面时,浏览器会发送 WebSocket 关闭帧,触发优雅的 TCP 四次挥手。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 服务端处理(Node.js + ws 库)
const WebSocket = require('ws');

wss.on('connection', (ws, req) => {
const clientId = generateClientId();
console.log(`客户端 ${clientId} 连接建立`);

// 正常关闭事件
ws.on('close', (code, reason) => {
console.log(`客户端 ${clientId} 连接关闭,代码: ${code},原因: ${reason}`);
// 清理客户端相关资源
cleanupClient(clientId);
});
});
关闭代码 含义 说明
1000 正常关闭 会话完成
1001 终端离开 浏览器导航离开
1005 无状态码 连接异常中断
1006 异常关闭 连接意外终止

场景二:杀掉浏览器进程

当浏览器进程被强制终止(如任务管理器结束进程),操作系统会关闭进程的所有网络端口,触发 TCP RST 包。

1
2
3
4
5
6
7
8
9
10
11
// 服务端需要同时监听 close 和 error 事件
ws.on('close', (code, reason) => {
console.log(`连接关闭: ${code}`);
cleanupClient(clientId);
});

ws.on('error', (error) => {
console.error(`连接错误: ${error.message}`);
// 错误事件后通常会跟随 close 事件
// 无需重复清理
});

处理要点

  • 强制关闭会同时触发 errorclose 事件
  • error 事件处理中不要重复执行清理逻辑
  • 依赖 close 事件进行最终的资源释放

场景三:客户端断电断网

这是最难处理的场景。由于 TCP 连接的断开信息无法到达服务端,服务端会维持一个”僵尸连接”(TCP 假死)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────────────────────────────────────────────────────────────┐
│ TCP 假死问题 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 客户端 网络层 服务端 │
│ │ │ │ │
│ │ ◄──── 断电/断网 ────► │ │ │
│ │ │ │ │
│ × 客户端消失 │ 服务端不知情 │ │
│ │ │ │
│ │ ◄── 服务端继续发送 ──│ │
│ │ 数据包 │ │
│ │ │ │
│ │ ── 无 ACK 响应 ──► │ │
│ │ (多次重试) │ │
│ │ │ │
│ │ 经过超时时间后 │ │
│ │ 服务端才感知断开 │ │
│ │
│ 问题:默认 TCP 超时时间很长(几分钟到几小时) │
│ 解决:应用层心跳机制 │
│ │
└─────────────────────────────────────────────────────────────────────┘

服务端断开场景处理

场景一:正常重启服务

服务端主动关闭连接时,会发送关闭帧,客户端收到后触发 onClose

1
2
3
4
5
6
7
8
9
10
11
// 客户端处理
const ws = new WebSocket('wss://example.com/ws');

ws.onclose = (event) => {
console.log(`连接关闭,代码: ${event.code}`);

if (event.code === 1001) {
console.log('服务器正在重启,5秒后重连');
setTimeout(reconnect, 5000);
}
};

场景二:服务端进程崩溃

服务端进程被 kill -9 强制终止时,与客户端杀掉浏览器类似,会触发异常关闭。

1
2
3
4
5
6
7
8
9
10
11
// 客户端需要同时处理 close 和 error
ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
};

ws.onclose = (event) => {
if (event.code === 1006) {
console.log('异常关闭,可能是服务端崩溃');
reconnect();
}
};

心跳机制设计

为什么需要心跳

问题 原因 心跳解决方案
TCP 假死 物理断网时 TCP 不会立即感知 定期发送心跳包
NAT 超时 路由器会清理不活跃的连接映射 保持连接活跃
代理超时 Nginx/负载均衡器有读超时 防止被中间件断开
及时发现故障 无法区分慢网络和真断开 超时未收到心跳则判定断开

心跳方案设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────────────────────────────────────────────┐
│ WebSocket 心跳机制架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 客户端 服务端 │
│ │ │ │
│ │───────── ping ─────────────────►│ 1. 客户端定期发送 ping │
│ │ │ │
│ │◄──────── pong ──────────────────│ 2. 服务端回复 pong │
│ │ │ │
│ │───────── ping ─────────────────►│ 3. 间隔 heartBeatInterval │
│ │ (未收到响应) │ │
│ │ │ │
│ │ 超时 heartBeatTimeout │ │
│ │ │ │
│ │ 判定连接断开 ──► 触发重连 │ │
│ │
│ 参数建议: │
│ • heartBeatInterval: 10-30 秒(视场景调整) │
│ • heartBeatTimeout: 2-3 倍 interval │
│ │
└─────────────────────────────────────────────────────────────────────┘

客户端心跳实现

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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
class WebSocketClient {
constructor(url) {
this.url = url;
this.ws = null;
this.heartBeatInterval = 30000; // 30 秒发送一次心跳
this.heartBeatTimeout = 90000; // 90 秒未收到响应则断开
this.reconnectInterval = 5000; // 5 秒后重连
this.heartBeatTimer = null;
this.heartBeatTimeoutTimer = null;
this.reconnectTimer = null;
this.isManualClose = false;
}

connect() {
this.ws = new WebSocket(this.url);

this.ws.onopen = () => {
console.log('WebSocket 连接成功');
this.startHeartBeat();
};

this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);

// 处理 pong 响应
if (data.type === 'pong') {
this.resetHeartBeatTimeout();
return;
}

// 处理业务消息
this.handleMessage(data);
};

this.ws.onclose = (event) => {
console.log(`连接关闭: ${event.code}`);
this.stopHeartBeat();

if (!this.isManualClose) {
this.scheduleReconnect();
}
};

this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
};
}

/**
* 启动心跳
*/
startHeartBeat() {
this.heartBeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping', time: Date.now() }));
this.setHeartBeatTimeout();
}
}, this.heartBeatInterval);
}

/**
* 设置心跳超时检测
*/
setHeartBeatTimeout() {
this.heartBeatTimeoutTimer = setTimeout(() => {
console.error('心跳超时,连接可能已断开');
this.ws.close();
}, this.heartBeatTimeout);
}

/**
* 重置心跳超时
*/
resetHeartBeatTimeout() {
if (this.heartBeatTimeoutTimer) {
clearTimeout(this.heartBeatTimeoutTimer);
this.heartBeatTimeoutTimer = null;
}
}

/**
* 停止心跳
*/
stopHeartBeat() {
if (this.heartBeatTimer) {
clearInterval(this.heartBeatTimer);
this.heartBeatTimer = null;
}
this.resetHeartBeatTimeout();
}

/**
* 安排重连
*/
scheduleReconnect() {
console.log(`${this.reconnectInterval}ms 后尝试重连`);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, this.reconnectInterval);
}

/**
* 手动关闭连接
*/
close() {
this.isManualClose = true;
this.stopHeartBeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.ws.close();
}

handleMessage(data) {
console.log('收到消息:', data);
}
}

// 使用
const client = new WebSocketClient('wss://example.com/ws');
client.connect();

服务端心跳实现(Node.js)

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
87
88
89
90
91
92
93
const WebSocket = require('ws');

class WebSocketServer {
constructor(port) {
this.wss = new WebSocket.Server({ port });
this.clients = new Map(); // clientId -> { ws, lastPing }
this.heartBeatInterval = 30000;
this.heartBeatTimeout = 90000;

this.init();
}

init() {
this.wss.on('connection', (ws, req) => {
const clientId = this.generateClientId();
console.log(`客户端 ${clientId} 连接`);

this.clients.set(clientId, {
ws,
lastPing: Date.now()
});

// 监听消息
ws.on('message', (data) => {
try {
const msg = JSON.parse(data);

if (msg.type === 'ping') {
// 回复 pong
ws.send(JSON.stringify({
type: 'pong',
time: msg.time
}));

// 更新最后心跳时间
const client = this.clients.get(clientId);
if (client) {
client.lastPing = Date.now();
}
} else {
this.handleMessage(clientId, msg);
}
} catch (e) {
console.error('消息解析错误:', e);
}
});

// 监听关闭
ws.on('close', () => {
console.log(`客户端 ${clientId} 断开`);
this.clients.delete(clientId);
});

// 监听错误
ws.on('error', (error) => {
console.error(`客户端 ${clientId} 错误:`, error);
});
});

// 启动服务端心跳检测
this.startHeartBeatCheck();
}

/**
* 服务端心跳检测
*/
startHeartBeatCheck() {
setInterval(() => {
const now = Date.now();

for (const [clientId, client] of this.clients) {
const elapsed = now - client.lastPing;

if (elapsed > this.heartBeatTimeout) {
console.log(`客户端 ${clientId} 心跳超时,强制断开`);
client.ws.terminate(); // 强制断开
this.clients.delete(clientId);
}
}
}, this.heartBeatInterval);
}

generateClientId() {
return `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}

handleMessage(clientId, msg) {
console.log(`来自 ${clientId} 的消息:`, msg);
}
}

// 启动服务
new WebSocketServer(8080);

断线重连策略

指数退避重连

简单的固定间隔重连在网络故障时可能给服务器造成压力,推荐使用指数退避策略:

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
class ReconnectStrategy {
constructor() {
this.baseInterval = 1000; // 基础间隔 1 秒
this.maxInterval = 30000; // 最大间隔 30 秒
this.maxRetries = 10; // 最大重试次数
this.retryCount = 0;
}

getNextInterval() {
if (this.retryCount >= this.maxRetries) {
return -1; // 停止重连
}

// 指数退避:1s, 2s, 4s, 8s, 16s, 30s, 30s...
const interval = Math.min(
this.baseInterval * Math.pow(2, this.retryCount),
this.maxInterval
);

this.retryCount++;
return interval;
}

reset() {
this.retryCount = 0;
}
}

完整重连逻辑

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
class ResilientWebSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectStrategy = new ReconnectStrategy();
this.messageQueue = []; // 断线期间的消息队列
this.isReconnecting = false;
}

connect() {
this.ws = new WebSocket(this.url);

this.ws.onopen = () => {
console.log('连接成功');
this.reconnectStrategy.reset();
this.flushMessageQueue(); // 发送离线期间的消息
};

this.ws.onclose = () => {
this.attemptReconnect();
};
}

attemptReconnect() {
if (this.isReconnecting) return;
this.isReconnecting = true;

const interval = this.reconnectStrategy.getNextInterval();

if (interval === -1) {
console.error('重试次数用尽,停止重连');
return;
}

console.log(`${interval}ms 后重连...`);

setTimeout(() => {
this.isReconnecting = false;
this.connect();
}, interval);
}

send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
// 离线时加入队列
this.messageQueue.push(data);
}
}

flushMessageQueue() {
while (this.messageQueue.length > 0) {
const msg = this.messageQueue.shift();
this.ws.send(JSON.stringify(msg));
}
}
}

心跳参数选择建议

场景 心跳间隔 超时时间 说明
即时通讯 10-15s 30-45s 需要快速感知断线
在线游戏 5-10s 15-30s 实时性要求高
股票行情 15-30s 60-90s 平衡实时性和开销
IoT 设备 60-300s 300-600s 节省电量和流量

实验数据参考

心跳间隔 断电后感知时间 网络开销 适用场景
1s ~167s 高频交易
1min ~6min 40s 普通应用
10min ~11-15min 低频场景

注意:以上数据受操作系统 TCP 超时设置影响,不同平台可能有差异。

总结

WebSocket 连接断开处理的核心要点:

  1. 断开场景识别:正常关闭触发 onClose,异常断开同时触发 onError + onClose,断电断网产生 TCP 假死
  2. 心跳机制:客户端定期发送 ping,服务端回复 pong,超时未响应则判定断开
  3. TCP 假死处理:依赖应用层心跳,不能仅靠 TCP 连接状态
  4. 优雅重连:使用指数退避策略,避免对服务端造成冲击
  5. 消息队列:断线期间缓存消息,重连后补发
  6. 参数调优:根据业务场景选择合适的心跳间隔和超时时间
  7. 资源清理:连接断开后及时释放内存、关闭定时器、删除映射

通过完善的心跳机制和断线重连策略,可以构建高可用的实时通信系统,为用户提供稳定的连接体验。