WebSocket心跳检测机制的实战踩坑与优化方案
项目初期的技术选型
最近做了一个实时监控系统,需要保持客户端和服务端的长连接状态。之前都是用轮询的方式,但项目数据量比较大,轮询的性能消耗实在太大了。产品经理还一直在催着要实时性更好的方案,没办法,只能研究心跳检测了。
说白了就是客户端定期向服务端发送一个很小的数据包,确认连接是否正常。如果服务端没收到心跳,或者客户端收不到回应,那就说明连接断了,需要重连。听起来挺简单的,但实际做的时候发现坑不少。
基本实现思路
刚开始的想法很简单,用WebSocket建立长连接,然后定时发送心跳包就行了。代码大概长这样:
class HeartbeatClient {
constructor(url) {
this.url = url;
this.ws = null;
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.isManualClose = false;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('连接已建立');
this.startHeartbeat();
this.resetReconnectTimer();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// 收到服务端的心跳回应
if (data.type === 'pong') {
this.lastPongTime = Date.now();
}
};
this.ws.onerror = (error) => {
console.error('WebSocket错误:', error);
this.handleDisconnect();
};
this.ws.onclose = () => {
console.log('连接已关闭');
this.stopHeartbeat();
if (!this.isManualClose) {
this.scheduleReconnect();
}
};
}
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // 30秒发一次心跳
this.lastPongTime = Date.now();
}
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
handleDisconnect() {
this.stopHeartbeat();
if (!this.isManualClose) {
this.scheduleReconnect();
}
}
scheduleReconnect() {
this.reconnectTimer = setTimeout(() => {
console.log('尝试重连...');
this.connect();
}, 5000); // 5秒后重连
}
resetReconnectTimer() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
看起来挺完美的,本地测试也没问题。但是部署到生产环境后就开始出各种问题了。
最大坑:网络不稳定导致的误判
最头疼的问题是网络不稳定会导致频繁断线重连。有一次服务器日志显示,明明客户端连接正常,但就是收不到心跳回应,结果客户端自己把连接断了重新连。查了半天才发现,某些网络环境下心跳包会有延迟甚至丢失。
开始没想到这个问题会这么严重,后来加了一些监控发现,心跳超时的次数远比预期多。用户那边经常看到连接状态在”连接中”和”重连中”之间频繁切换,体验特别差。
后来调整了方案,加了超时判断和重试机制:
class ImprovedHeartbeatClient extends HeartbeatClient {
constructor(url) {
super(url);
this.heartbeatTimeout = null;
this.pingInterval = 30000; // 心跳间隔
this.timeoutThreshold = 60000; // 超时阈值
this.maxRetryTimes = 3; // 最大重试次数
this.retryCount = 0;
}
startHeartbeat() {
this.stopHeartbeat(); // 防止重复启动
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.sendPing();
}
}, this.pingInterval);
this.lastPongTime = Date.now();
}
sendPing() {
// 先检查上次pong的时间,避免重复发送
const now = Date.now();
if (now - this.lastPongTime > this.timeoutThreshold) {
// 已经超时了,直接断开重连
this.handleTimeout();
return;
}
try {
this.ws.send(JSON.stringify({
type: 'ping',
timestamp: Date.now()
}));
// 设置心跳超时检测
this.heartbeatTimeout = setTimeout(() => {
this.handlePongTimeout();
}, 5000); // 5秒内没收到pong就认为超时
} catch (error) {
console.error('发送心跳失败:', error);
this.handleDisconnect();
}
}
handlePongTimeout() {
console.warn('心跳超时,进行重试');
if (this.retryCount < this.maxRetryTimes) {
this.retryCount++;
console.log(重试第${this.retryCount}次);
// 延迟一下再重试,避免频繁重试
setTimeout(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.sendPing();
}
}, 2000);
} else {
// 重试次数用完了,直接断开重连
console.log('心跳超时次数过多,断开重连');
this.handleTimeout();
}
}
handleTimeout() {
this.retryCount = 0;
if (this.ws) {
this.isManualClose = true;
this.ws.close();
}
}
onMessage(event) {
const data = JSON.parse(event.data);
if (data.type === 'pong') {
this.lastPongTime = Date.now();
this.retryCount = 0; // 重置重试次数
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = null;
}
} else {
// 处理其他消息
this.handleOtherMessage(data);
}
}
stopHeartbeat() {
super.stopHeartbeat();
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = null;
}
this.retryCount = 0;
}
}
服务端的配合
光客户端改还不够,服务端也得配合。服务端需要响应客户端的心跳,并且也要检测客户端是否还在连接:
// Node.js WebSocket服务端示例
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
let isAlive = true;
// 监听客户端心跳
ws.on('message', (data) => {
try {
const message = JSON.parse(data);
if (message.type === 'ping') {
// 回复pong
ws.send(JSON.stringify({
type: 'pong',
timestamp: Date.now()
}));
isAlive = true; // 收到心跳,标记为活跃
}
} catch (error) {
console.error('解析消息失败:', error);
}
});
// 定期检测客户端是否还在线
const interval = setInterval(() => {
if (!isAlive) {
console.log('客户端无响应,关闭连接');
ws.terminate();
return;
}
isAlive = false; // 等待下次心跳
}, 45000); // 比客户端心跳间隔长一点
ws.on('close', () => {
clearInterval(interval);
});
});
移动端兼容性问题
还有一个问题差点把我搞疯,就是移动端浏览器的休眠机制。手机锁屏一段时间后,JavaScript会被暂停执行,心跳就停了。等用户解锁时,连接早就断了,但客户端还不知道,要等很久才会触发onclose事件。
最后加了个页面可见性检测:
// 页面可见性变化处理
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
// 页面重新可见,检查连接状态
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
console.log('页面恢复可见,尝试重连');
this.connect();
}
}
});
// 页面即将隐藏时的处理
window.addEventListener('pagehide', () => {
this.isManualClose = true;
if (this.ws) {
this.ws.close();
}
});
最终效果评估
经过这些调整,线上稳定多了。心跳检测现在能比较准确地判断连接状态,误判率降低了90%以上。重连机制也比较可靠,大部分情况下都能自动恢复连接。
不过还是有一些小问题没完全解决。比如极端网络环境下偶尔还会出现连接状态不一致的情况,但概率已经很小了,影响不大。还有就是心跳检测本身也会消耗一些网络流量,虽然不多,但也是个考虑因素。
性能方面倒是没问题,心跳包都很小,一般只有几十个字节,对整体性能影响可以忽略不计。内存占用也控制得很好,几个定时器不会造成内存泄漏。
一些优化建议
根据这次的经验,给后面做类似功能的朋友一些建议:
- 心跳间隔不能设置得太短,否则会增加服务器压力,也不能太长,否则故障发现不够及时
- 超时时间要合理设置,要考虑网络延迟和重试机制
- 移动端要考虑页面生命周期的影响
- 最好加上详细的日志记录,方便排查问题
另外,如果项目对实时性要求不是特别高的话,其实轮询也不是不能用。心跳检测确实复杂一些,但实时性确实比轮询好太多了。
以上是我踩坑后的总结,希望对你有帮助。这个功能看起来简单,但要做稳定真的不容易。有更优的实现方式欢迎评论区交流。

暂无评论