WebSocket 是长连接,但网络波动、服务器超时、代理断开等问题会导致连接 "假死"(看似连接存在,实则无法通信);而直接断开连接也可能导致数据丢失。
一、先理解:为什么需要心跳保持?
WebSocket 长连接的 "痛点":
- 网络静默超时:很多中间节点(Nginx、防火墙、运营商网关)会断开 "长时间无数据传输" 的连接(比如 Nginx 默认 60s 超时);
- 连接假死:网络闪断后,客户端 / 服务器未感知,仍认为连接有效,发送消息时才发现失败;
- 资源浪费:服务器维护大量 "假死连接",占用文件描述符和内存。
心跳保持的核心目的:通过「定时双向交互的轻量帧(PING/PONG)」,确认连接的 "活性"------ 既让中间节点认为连接 "有数据传输",又能快速检测到连接异常,及时重连。
二、WebSocket 心跳机制的核心规则(协议层面)
WebSocket 协议内置了 PING/PONG 帧(Opcode 分别为 0x9、0xA),是专门用于心跳的轻量帧(无业务数据,开销极小):
- PING 帧:可由客户端 / 服务器发起,接收方必须在规定时间内返回 PONG 帧;
- PONG 帧:对 PING 帧的响应,必须携带和 PING 帧相同的「负载数据」(通常为空,仅做确认);
- 超时判定:若发起 PING 后,超过阈值(如 10s)未收到 PONG,则判定连接失效,触发重连。
心跳设计的最佳实践(生产级)
| 角色 | 职责 | 核心参数 |
|---|---|---|
| 客户端 | 主动发送 PING 帧(核心发起方) | 心跳间隔:15~30s(小于中间节点超时时间)超时阈值:10s重连次数:3~5 次(避免无限重连) |
| 服务器 | 被动响应 PONG 帧 + 检测超时 | 超时判定:超过 2 倍心跳间隔未收到 PING → 主动关闭连接 |
| 帧负载 | 建议为空或携带简单标识(如客户端 ID) | 避免大负载,保持轻量 |
三、心跳保持的完整代码实现(基于之前的示例扩展)
我们在原有 WebSocket 示例基础上,添加「心跳检测 + 断线重连 + 超时关闭」逻辑,适配生产环境。
1. 服务端代码(Node.js + ws 库)
javascript
const WebSocket = require('ws');
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('普通 HTTP 请求');
});
const wss = new WebSocket.Server({
server,
pingTimeout: 60000, // 服务器超时:60s 未收到客户端 PING → 关闭连接
pingInterval: 0 // 服务器不主动发 PING(由客户端主导心跳)
});
// 监听新连接
wss.on('connection', (ws, req) => {
console.log('客户端已建立 WebSocket 连接');
const clientIp = req.socket.remoteAddress;
let heartbeatTimer = null;
// 1. 监听客户端 PING 帧 → 自动响应 PONG(ws 库已内置该逻辑,无需手动处理)
// 2. 检测客户端心跳超时:自定义双层超时(兜底)
heartbeatTimer = setInterval(() => {
if (ws.isAlive === false) {
console.log(`客户端 ${clientIp} 心跳超时,关闭连接`);
return ws.terminate(); // 强制关闭
}
ws.isAlive = false; // 标记为未活跃,等待 PONG 重置
}, 40000); // 40s 检测一次(大于客户端 30s 心跳间隔)
// 3. 监听 PONG 帧 → 重置活跃状态
ws.on('pong', () => {
ws.isAlive = true;
console.log(`收到 ${clientIp} 的 PONG 帧,心跳正常`);
});
// 4. 业务消息处理(原有逻辑)
ws.on('message', (data) => {
console.log('收到客户端消息:', data.toString());
ws.send(`服务器回复:${data.toString()}`);
});
// 5. 连接关闭 → 清理定时器
ws.on('close', (code, reason) => {
clearInterval(heartbeatTimer);
console.log(`WebSocket 连接关闭:${code} - ${reason.toString()}`);
});
// 6. 错误处理
ws.on('error', (err) => {
clearInterval(heartbeatTimer);
console.error('WebSocket 错误:', err);
});
});
server.listen(8080, () => {
console.log('服务器运行在 http://localhost:8080');
console.log('WebSocket 地址:ws://localhost:8080');
});
2. 客户端代码(浏览器 HTML)
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket 心跳 + 关闭示例</title>
</head>
<body>
<input type="text" id="msgInput" placeholder="输入消息">
<button onclick="sendMsg()">发送</button>
<button onclick="closeWS()">手动关闭连接</button>
<div id="msgList"></div>
<script>
// 核心配置
const WS_URL = 'ws://localhost:8080';
const HEARTBEAT_INTERVAL = 30000; // 30s 发一次 PING
const HEARTBEAT_TIMEOUT = 10000; // PING 后 10s 未收到 PONG → 重连
let ws = null;
let heartbeatTimer = null; // 心跳定时器
let reconnectTimer = null; // 重连定时器
let reconnectCount = 0; // 重连次数
const MAX_RECONNECT = 5; // 最大重连次数
// 初始化 WebSocket 连接
function initWS() {
// 关闭旧连接(避免重复)
if (ws) {
ws.close(1000, '重新初始化连接');
}
ws = new WebSocket(WS_URL);
appendMsg('开始建立 WebSocket 连接...');
// 1. 连接成功 → 启动心跳
ws.onopen = () => {
appendMsg('连接成功:可以发送消息了');
reconnectCount = 0; // 重置重连次数
startHeartbeat(); // 启动心跳
};
// 2. 接收服务器消息
ws.onmessage = (event) => {
appendMsg(`服务器:${event.data}`);
};
// 3. 连接关闭 → 尝试重连
ws.onclose = (event) => {
appendMsg(`连接关闭:${event.code} - ${event.reason}`);
stopHeartbeat(); // 停止心跳
// 非主动关闭(code!=1000)且未达最大重连次数 → 重连
if (event.code !== 1000 && reconnectCount < MAX_RECONNECT) {
reconnectCount++;
appendMsg(`开始第 ${reconnectCount} 次重连...`);
reconnectTimer = setTimeout(initWS, 5000); // 5s 后重连
}
};
// 4. 错误处理
ws.onerror = (error) => {
appendMsg(`错误:${error.message}`);
stopHeartbeat();
};
// 5. 监听 PONG 帧(确认心跳响应)
ws.onpong = () => {
appendMsg('收到服务器 PONG 帧,心跳正常');
// 清除超时定时器(避免误判重连)
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
}
// 启动心跳:定时发送 PING 帧
function startHeartbeat() {
stopHeartbeat(); // 先停止旧定时器
heartbeatTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
appendMsg('发送 PING 帧...');
// 发送 PING 帧,第二个参数为是否掩码(客户端必须掩码)
ws.ping('', true, (err) => {
if (err) {
appendMsg(`发送 PING 帧失败:${err.message}`);
// PING 发送失败 → 触发超时重连
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.close(1011, 'PING 发送失败');
}
}, HEARTBEAT_TIMEOUT);
}
});
}
}, HEARTBEAT_INTERVAL);
}
// 停止心跳
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
// 发送业务消息(原有逻辑)
function sendMsg() {
const input = document.getElementById('msgInput');
const msg = input.value.trim();
if (!msg || ws.readyState !== WebSocket.OPEN) return;
ws.send(msg);
appendMsg(`客户端:${msg}`);
input.value = '';
}
// 优雅关闭连接
function closeWS() {
if (ws && ws.readyState === WebSocket.OPEN) {
// 1000 是正常关闭码,第二个参数是关闭原因(可选)
ws.close(1000, '用户主动关闭连接');
appendMsg('正在主动关闭连接...');
}
}
// 辅助函数:追加消息
function appendMsg(text) {
const msgList = document.getElementById('msgList');
const p = document.createElement('p');
p.textContent = `[${new Date().toLocaleTimeString()}] ${text}`;
msgList.appendChild(p);
// 滚动到最新消息
msgList.scrollTop = msgList.scrollHeight;
}
// 页面加载时初始化连接
window.onload = initWS;
// 页面关闭时优雅关闭连接
window.onbeforeunload = () => {
closeWS();
};
</script>
</body>
</html>
四、心跳机制核心逻辑拆解
1. 客户端核心动作
- 连接成功启动心跳 :
onopen触发后,启动 30s 定时器,定时调用ws.ping()发送 PING 帧; - PING 发送失败处理 :若
ping()回调报错,判定网络异常,触发关闭 / 重连; - PONG 响应确认 :
onpong触发后,确认连接正常,重置重连状态; - 断线重连 :
onclose触发后,若不是主动关闭且未达最大重连次数,5s 后重新初始化连接; - 页面卸载清理 :
onbeforeunload触发优雅关闭,避免连接残留。
2. 服务器核心动作
- 自动响应 PONG :
ws库内置了 PING → PONG 的响应逻辑,无需手动写; - 双层超时检测 :
- 内置
pingTimeout: 60000:60s 未收到 PING → 自动关闭; - 自定义 40s 定时器:标记
isAlive状态,未收到 PONG 则强制关闭;
- 内置
- 连接关闭清理:清除定时器,避免内存泄漏。
五、WebSocket 会话关闭(优雅关闭 vs 强制关闭)
WebSocket 关闭分为「优雅关闭」和「强制关闭」,协议定义了标准化的关闭码,确保双方明确关闭原因。
1. 关闭的核心规则
- 优雅关闭流程 :
- 发起方发送「关闭帧(Opcode=0x8)」,携带关闭码和原因;
- 接收方收到关闭帧后,回复确认关闭帧;
- 双方完成 TCP 四次挥手,连接断开。
- 强制关闭 :直接终止 TCP 连接(如
ws.terminate()),无关闭帧交互,可能导致数据丢失。
2. 常用标准关闭码(RFC 6455 定义)
| 关闭码 | 含义 | 适用场景 |
|---|---|---|
| 1000 | 正常关闭(Normal Closure) | 用户主动关闭、业务完成 |
| 1001 | 端点离开(Going Away) | 客户端页面关闭、服务器重启 |
| 1002 | 协议错误(Protocol Error) | 收到非法帧、掩码违规 |
| 1003 | 不支持的数据类型(Unsupported Data) | 收到非文本 / 二进制帧 |
| 1006 | 连接异常关闭(Abnormal Closure) | 网络中断、强制关闭(无关闭帧) |
| 1007 | 数据格式错误(Invalid Payload Data) | 文本帧非 UTF-8 编码 |
| 1008 | 策略违反(Policy Violation) | 消息长度超限、业务规则违规 |
| 1011 | 服务器内部错误(Internal Error) | 服务器处理消息异常 |
3. 关闭的代码实现
(1)优雅关闭(推荐)
javascript
// 客户端主动优雅关闭
ws.close(1000, '用户主动退出');
// 服务器主动优雅关闭
ws.close(1001, '服务器维护,即将重启');
(2)强制关闭(仅应急使用)
javascript
// 客户端强制关闭
ws.terminate();
// 服务器强制关闭
ws.terminate();
(3)监听关闭事件(获取原因)
javascript
ws.onclose = (event) => {
console.log(`关闭码:${event.code}`);
console.log(`关闭原因:${event.reason}`);
console.log(`是否干净关闭:${event.wasClean}`); // true=优雅关闭,false=强制/异常关闭
};
六、生产环境注意事项
- 心跳间隔配置:必须小于中间节点的超时时间(如 Nginx 默认 60s,心跳设为 30s);
- 重连策略:添加 "退避算法"(重连间隔逐渐增加:5s→10s→20s),避免频繁重连压垮服务器;
- 关闭码规范:严格使用标准关闭码,便于排查问题(避免自定义 1000 以内的码);
- 数据兜底:关闭前检查是否有未发送的消息,缓存后重连时补发;
- WSS 加密 :生产环境必须用
wss://(WebSocket over TLS),避免心跳帧 / 关闭帧被篡改。
七、核心总结
- 心跳保持 :
- 核心是客户端定时发 PING 帧,服务器自动回 PONG 帧,通过超时检测确认连接活性;
- 解决 "连接假死" 和 "中间节点超时断开" 问题,是长连接稳定运行的必备机制;
- 会话关闭 :
- 优先用优雅关闭(
ws.close(1000, 原因)),明确关闭码和原因; - 强制关闭(
ws.terminate())仅用于应急,可能导致数据丢失;
- 优先用优雅关闭(
- 代码核心:心跳需配合定时器、重连次数限制,关闭需清理资源(定时器、连接),避免内存泄漏。