手头的WebSocket项目上线不到一周,用户就开始反馈“消息收不到”。打开聊天界面,连接状态明明是正常的,服务端也显示客户端在线,可消息就是发不过来,用户干着急,后台排查的人也一头雾水。最后抓包一看,连接早就成了一具“假死”的尸体——TCP链路被中间设备静默回收,而WebSocket协议的握手信息还留在内存里,谁也没发现它断了。
这件事之后,我把所有线上长连接项目全部加了一遍心跳检测机制。这篇文章就是我那段时间排查、设计、落地、踩坑的全过程复盘,里面有完整的思路、可抄作业的代码、具体的参数推导,以及几个光是看文档绝对发现不了的坑。如果你是做前端、后端或者终端开发,项目里有WebSocket长连接,这篇文章应该能帮你少走不少弯路。
1. 连接为什么会悄悄“假死”:线上事故排查全记录
1.1 一次典型的“连接未断但消息不发”事故
先说一下当时的现象。用户的聊天页面一直停留在“已连接”的状态,前端DevTools里WebSocket的readyState是1(Open),服务端进程也没有抛任何异常,连接对象还在内存里挂着。但后端往这条连接上推送消息时,数据就像扔进了黑洞,客户端完全收不到。
排查过程大致是这几步:
- 先看前端Network面板,WebSocket帧记录里有收发的ping/pong记录,但时间停在出事前几分钟,之后再无任何帧交互。
- 再看服务端日志,连接对象存活着,没有任何close或error事件触发。
- 在服务端手动调用connection.send(),返回正常,无异常抛出,对端毫无反应。
- 抓包后确认:TCP层早已收到过RST或根本无响应,链路实际已经断开。
这就是典型的半开连接(Half-Open Connection)。A端认为连接还在,B端或中间的设备早已把这条链路丢掉了,但没有任何一方收到FIN包,所以双方都不会主动关闭连接。你发消息过去,数据包在链路上石沉大海,没有ACK,也没有RST,于是发送方永远等不到回应。
1.2 链路老化、休眠与静默丢弃:假死的三大成因
半开连接是怎么产生的?结合线上环境和抓包结果,主要来自三个方面:
| 成因 | 发生场景 | 表现 |
|---|---|---|
| 链路空闲超时 | 运营商或企业网关侧的NAT会话老化机制,长时间无流量则回收连接映射 | 连接对象还在,底层链路已被丢弃,下一条数据即触发RST或超时 |
| 客户端休眠 | 移动端App退到后台,系统冻结定时器与网络栈,心跳停发但连接对象未销毁 | 服务端长时间收不到任何数据,无法判断对端死活 |
| 中间代理空闲清理 | Nginx等反向代理或负载均衡设备设置了空闲超时(比如60秒),超时后主动断开上游连接 | 客户端与服务端都被蒙在鼓里,直到某次收发才暴露 |
这里有个关键认知:TCP是一种“不主动说话就没人知道你在不在”的协议。WebSocket虽然是基于TCP的长连接,但协议本身没有任何机制去周期性的确认“对方还活着”。只要双方不发数据,连接就可以静默保留非常久,直到某个中间设备把它当垃圾回收掉。
所以,想要及时发现这种假死状态,唯一可靠的方案就是——主动制造周期性流量,让连接始终处在“被关注”的状态。这就是心跳检测机制存在的根本原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 心跳检测的底层逻辑:从TCP Keep-Alive到应用层心跳
2.1 系统自带的TCP Keep-Alive为什么不够用
很多人的第一反应是:TCP协议不是自带Keep-Alive机制吗?开启SO_KEEPALIVE不就行了?
理论上确实有这个东西。TCP Keep-Alive默认是2小时探测一次,用来确认对端是否可达。但到WebSocket层面,它有几个天然缺陷:
- 默认时间太长。系统默认的探测间隔是2小时,绝大多数业务场景根本等不了这么久。
- 穿透性差。NAT设备、网关、代理服务器不一定认TCP Keep-Alive的探测包,尤其在某些运营商链路里,这类包可能被静默丢弃,起不到“保活”的作用。
- 探测的是“网络层通不通”,不是“业务层通不通”。TCP能通,不代表应用层消息能正常收发,更不代表服务端进程里的连接对象状态正确。
所以,在实际生产环境里,只依赖操作系统级的Keep-Alive是远远不够的,我们需要在WebSocket的应用层实现一套自己的心跳机制,时间粒度自己控制、消息内容自己定义、超时判定自己把握。
2.2 协议层Ping/Pong帧与应用层自定义心跳的选择
WebSocket协议本身其实提供了两个控制帧:Ping帧和Pong帧。规范里规定,收到Ping的一方必须回一个Pong帧。在浏览器端,WebSocket API提供了ping和pong的底层能力不完整,但服务端基本都支持这两个控制帧。
那是不是直接用协议自带的Ping/Pong就够了?我个人的结论是:能用,但不够稳。
问题出在几个地方:
-
浏览器控制帧的自动响应行为不可控。浏览器收到服务端的Ping帧后会自动回Pong,这个响应是由浏览器内核代劳的,JS代码里拿不到这个事件。也就是说,服务端看到Pong回来了,只能证明“浏览器内核还活着”,但证明不了“你的业务逻辑还正常”。反过来,如果客户端依赖协议层的Ping/Pong去探测服务端,某些网关或代理是不转发控制帧的,Get到的状态同样不准确。
-
自定义消息可以做更多事。应用层心跳本质就是一条普通的业务JSON消息,比如
{"type":"ping","timestamp":...}。它不仅能起到保活作用,还能携带客户端状态、会话标识、时间戳等信息,便于服务端做精细化判断。 -
统计与排查需要。如果采用业务心跳,你可以直接在业务日志里看到“发送了200条心跳,收到199条Pong”,这个数据对诊断问题非常有价值;而协议层的控制帧通常不会打进业务日志。
所以我在项目里的做法是:以应用层自定义心跳为主,协议层Ping/Pong只作为辅助探测手段。二者不冲突,但实际判定连接健康状态时,完全以应用层的心跳应答为准。
2.3 服务端侧的“最近活跃时间”判定法
服务端判断一条连接死没死,最常见的算法是最近活跃时间(Last Active Time)滑动窗口判定。思路很简单:
- 为每条连接维护一个
lastAliveTime字段,每当收到来自客户端的数据(包括心跳消息、普通业务消息)时,更新这个时间戳。 - 开启一个定时器,定期扫描所有连接,当前时间减去
lastAliveTime如果超过阈值,就判定为“心跳超时”,主动断开该连接。 - 断开时触发close事件,走到统一的连接清理逻辑,释放资源,通知业务层。
这个方案最简单,也最稳。它的原理等价于一个滑动窗口:只要在窗口内收到过任何数据,就认为连接活着;一旦窗口内静默,就不再信任它。
需要说明的是:这里用的是“任何数据”,而不只是心跳消息。因为普通业务消息同样能证明连接是通的,没必要等心跳消息才更新。这个细节在压测和排查时能省不少事。
3. 心跳间隔与超时阈值:参数不要拍脑袋,要推导
3.1 先弄清楚中间设备的“闲置超时”边界
设计心跳参数,首先要知道你链路可能经过的中间设备有多久会回收空闲连接。这个数据不是猜出来的,是问出来的。
以最常见的Nginx反向代理为例。Nginx对WebSocket连接支持proxy_read_timeout参数,默认值是60秒。这意味着:如果60秒内Nginx没有从上游读到任何数据,它就会主动断开这条WebSocket连接。
再比如你走了云厂商的负载均衡,LB设备同样存在会话超时时间,常见的有300秒、600秒,也有的默认60秒。
所以心跳间隔的第一个硬约束是:
心跳间隔必须明显小于链路上最小的空闲超时时间。
如果Nginx的proxy_read_timeout配的是60秒,你的心跳间隔就不能等于60秒,必须留足余量。你发出心跳、服务端收到、Nginx看到数据流动,这个周期总共需要两倍心跳间隔的时间(一次请求一次响应)。所以保险的做法是:
心跳间隔 ≤ 链路最小空闲超时 ÷ 2
按60秒算,心跳间隔不要超过30秒。剩下的30秒,分配给你心跳发送后到收到Pong之前的这段空白期。
3.2 超时阈值按“连续N次无响应”计算
光有心跳间隔还不够,客户端发完心跳,不可能立刻判定对方死了。网络抖动、服务端GC暂停、消息排队都会造成延迟。我给的建议是连续N次未收到响应才算真正超时。
假设心跳间隔是15秒,你连续3次没收到Pong响应,那就是45秒没有任何有效数据从服务端回来。这时候再判定连接异常,基本不会误杀。
判断公式是:
总超时时间 = 心跳间隔 × 无响应次数阈值 + 网络最大抖动容忍值
实际参数一般这样设:
| 参数 | 推荐配置 | 依据 |
|---|---|---|
| 心跳间隔 | 15 ~ 30秒 | 取决于链路空闲超时/2 |
| 无响应次数阈值 | 3 ~ 5次 | 覆盖偶发网络抖动 |
| 总判定超时 | 45 ~ 150秒 | 间隔×次数 |
| 服务端闲置清理阈值 | 心跳间隔与判定超时之间的值,如60秒 | 需要服务端比客户端晚下手一点点 |
3.3 脏数据比想象中更擅长伪装
给参数前我还想强调一个容易被忽略的点:即使心跳和Pong都正常收发,也不代表业务消息一定能送达。为什么?因为很多网络中间设备只管链路层流量,它看到有TCP数据包在流动,就不会回收连接。如果你的心跳消息能正常往返,但普通业务消息因为某种原因被丢掉了(比如链路质量变差,丢包率升高),客户端依然会表现为“心跳正常但收不到业务消息”。
这种情况光靠心跳参数是解决不掉的,还需要在业务层加“消息回执”或“定期探测一条业务消息”的兜底。具体的做法放在第四部分讲。
4. 一套可以直接落地的“心跳 + 自动重连”完整实现
4.1 客户端实现:JavaScript版的心跳管理类
先说整体思路。客户端在连接建立后启动一个定时器,每隔interval发送一条{"type":"ping","timestamp":...}消息。同时维护一个pendingPingCount计数器,每发出一个Ping加1,每收到一个Pong或任何业务消息清零。一旦pendingPingCount大于等于阈值,就主动close()当前连接,触发重连逻辑。
这里特意用“任何业务消息都清零计数器”,是因为只要服务端还能推送业务消息,就说明连接是好的,没必要继续累计未响应次数。
下面是一个可以直接拿去用的JavaScript版本:
javascript复制class ReliableWebSocket {
constructor(url, options = {}) {
this.url = url;
this.heartbeatInterval = options.heartbeatInterval || 15000; // 15s
this.maxMissedPings = options.maxMissedPings || 3; // 连续3次无响应判死
this.reconnectBaseDelay = options.reconnectBaseDelay || 1000;
this.reconnectMaxDelay = options.reconnectMaxDelay || 30000;
this.ws = null;
this.missedPings = 0;
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.manualClosed = false;
this.onMessageHandlers = [];
this.onStatusChangeHandlers = [];
}
connect() {
this.manualClosed = false;
this.ws = new WebSocket(this.url);
this.ws.onopen = () => this._onOpen();
this.ws.onmessage = (event) => this._onMessage(event);
this.ws.onclose = () => this._onClose();
this.ws.onerror = () => this._onError();
}
_onOpen() {
this.missedPings = 0;
this._startHeartbeat();
this._emitStatus('connected');
}
_startHeartbeat() {
this._stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.missedPings++;
this.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
if (this.missedPings >= this.maxMissedPings) {
console.warn('[WebSocket] 心跳连续无响应,主动断开');
this.ws.close();
}
}
}, this.heartbeatInterval);
}
_stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
_onMessage(event) {
try {
const data = JSON.parse(event.data);
if (data.type === 'pong') {
this.missedPings = 0;
return;
}
} catch (e) {
// 非JSON数据按普通消息处理
}
// 任何业务消息都视为连接存活
this.missedPings = 0;
this.onMessageHandlers.forEach(handler => handler(event.data));
}
_onClose() {
this._stopHeartbeat();
this._emitStatus('disconnected');
if (this.manualClosed) return;
this._scheduleReconnect();
}
_onError() {
// 触发error后通常紧接着会触发close,重连放在close里处理
}
_scheduleReconnect() {
const delay = this._calculateBackoffDelay();
console.warn(`[WebSocket] ${delay}ms 后尝试重连`);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
}
_calculateBackoffDelay() {
// 指数退避 + 随机抖动,防止全是同一时刻重连
const exp = Math.min(this.reconnectMaxDelay, this.reconnectBaseDelay * Math.pow(2, this.missedPings));
const jitter = Math.random() * 500;
return Math.min(this.reconnectMaxDelay, exp + jitter);
}
_emitStatus(status) {
this.onStatusChangeHandlers.forEach(handler => handler(status));
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
return true;
}
// 支持离线缓存消息的逻辑在外部实现
return false;
}
close() {
this.manualClosed = true;
this._stopHeartbeat();
if (this.ws) this.ws.close();
}
}
这段代码里有一个用心的地方:_onMessage里对非JSON数据做了兜底。有些服务端下发的可能不是JSON格式,直接JSON.parse会抛异常,导致消息处理中断。这种边界情况要提前防住。
4.2 服务端实现:Node.js + ws库的优雅判定
服务端的逻辑是:收到客户端Ping消息时,回复一条Pong;同时更新这条连接的lastAliveTime;启动一个定时任务扫描所有连接,超过阈值没有活跃的,主动关闭。
下面是Node.js + ws库的示例:
javascript复制const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const HEARTBEAT_INTERVAL = 30000; // 每30秒扫一次
const CONNECTION_TIMEOUT = 60000; // 60秒无活跃则断开
function noop() {}
function heartbeat(ws) {
ws.isAlive = true;
ws.lastAliveTime = Date.now();
}
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.lastAliveTime = Date.now();
ws.on('message', (data) => {
let msg;
try {
msg = JSON.parse(data);
} catch (e) {
return;
}
if (msg.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
return;
}
// 其他业务消息继续处理
heartbeat(ws);
handleBusinessMessage(ws, msg);
});
ws.on('close', () => {
// 清理资源、通知业务模块
cleanupConnection(ws);
});
ws.on('error', noop);
});
setInterval(() => {
wss.clients.forEach((ws) => {
// 如果当前时间距上次活跃超过阈值,则主动断开
if (Date.now() - ws.lastAliveTime > CONNECTION_TIMEOUT) {
ws.terminate();
return;
}
});
}, HEARTBEAT_INTERVAL);
这段写法的细节在于:lastAliveTime在任何消息到达时都会更新,不只是Ping。这样即使客户端一段时间不发Ping但一直有业务流量,也不会被误杀。同时ws.terminate()和ws.close()是有区别的:close()走正常的关闭握手,terminate()则直接切断底层TCP连接,不保证发出关闭帧。针对已经判定为“僵尸”的连接,直接terminate()更彻底,也避免了对端的假意应答拖延清理时间。
4.3 服务端反向探测:别只依赖客户端主动心跳
在很多系统里,心跳是由客户端单向发起的——客户端发Ping,服务端回Pong就够了。但我还额外加了一层保险:服务端定期主动给客户端发Ping。
意义在于:如果某条客户端链路只允许“下行数据”触发中间设备刷新,而客户端本身因为卡死或休眠一直不发消息,那么服务端主动发探测包就能及时发现。尤其在长连接承载推送的场景里,服务端单向推送占比很高,反向探测很有价值。
浏览器端处理服务端Ping的方式是自动回Pong,但如果你的服务端给的是自定义消息{"type":"server_heartbeat"},前端需要在消息处理函数里显式回一个{"type":"client_alive"}。注意这个回包要区别于心activeheartbeat,避免和服务端的心跳清理逻辑混淆。
5. 重连不是重连就完事:断线期间的业务状态怎么补
5.1 指数退避和抖动为什么必须做
很多初学项目的人重连逻辑很简单:setTimeout(() => reconnect(), 1000),每秒重试一次,直到成功。这在连接数少的时候问题不大,但当几百上千个客户端同时掉线,每秒钟的固定重连请求会把服务端打爆。
更常见的是“惊群效应”:凌晨网络波动导致一批客户端同时掉线,它们同时重连,服务端连接数瞬间飙升,CPU和内存被打满,进一步引发更多超时,恶性循环。
解法就是指数退避(Exponential Backoff)+ 随机抖动(Jitter)。
逻辑再明确一下:
- 第一次重连等待1秒;
- 第二次失败等待2秒;
- 第三次4秒、8秒、16秒……直到上限30秒;
- 每次重连的实际等待时间再加上一个0到500ms的随机抖动。
抖动的意义在于打破同步性:如果一千个客户端都按同一个退避序列等待,它们在时间轴上仍然会“同时重连”。加上随机量后,重连请求会自然散开。我在生产环境实测,加了抖动后,重连风暴问题基本绝迹。
5.2 断线期间消息缓冲与幂等重放
心跳和重连解决了“连接断没断”的问题,但没解决“断线期间的消息丢了怎么办”的问题。比如你是做即时通讯的,用户在输入框打了三条消息,第一次发出去的瞬间网络恰好断了,消息到底有没有送达,前端不知道。
我的做法是维护一个发送缓冲区(pending queue):
- 每条待发送消息生成唯一的
msgId(UUID或雪花算法)。 - 发送前先入队,标记
status: pending。 - 收到服务端回执(业务层的ack消息)后,把消息标记为
sent并移出队列。 - 重连成功后,把队列里所有
pending消息按序重新发送。
这里有个关键点:重发必须有幂等设计。服务端要能根据msgId判断这条消息是否已经处理过。如果服务端是以“收到消息就落库并推送”的逻辑处理,重发会导致消息重复投递。所以在服务端要维护一张已处理消息ID的缓存表,收到包含重复msgId的消息时直接丢弃,这就叫幂等处理。
5.3 重连成功后的状态同步
不是所有消息都能靠重发补回来,有些是高速变化的实时状态:比如股票价格、库存余量、审批状态。断线期间服务端可能已经推进了好几个状态,前端如果只重发消息,拿回来的是过期的视图。
所以重连成功后,前端应该主动向服务端发起一次“状态同步”请求:比如带上断线前拉取到的lastVersion或者lastMsgId,服务端把该ID之后的变化全部推给客户端。
这个过程的代价很小,但能有效避免“连接恢复了,页面上的数据还是断线之前的样子”这种尴尬局面。
6. 踩坑记录:几条你以为没问题但实际上很致命的情形
6.1 坑一:前端定时器被系统休眠暂停
移动端WebView或小程序里,App切到后台后,浏览器的定时器会被系统冻结,setInterval不再触发。你精心设计好的心跳任务,在用户切后台的那一刻就停了。等用户切回前台时,WebSocket表面看还是Open状态,实际上可能已经断了几分钟甚至更久。
我最终的解决方案是:在页面的visibilitychange事件里监听状态变化。只要页面恢复可见,立即做三件事:
- 检查
readyState,如果不是OPEN就直接重连判断; - 是
OPEN的话主动发一条探测消息,并要求服务端快速回包; - 清空重连计数器的退避状态,优先恢复正常。
javascript复制document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping', reason: 'visibility_restore' }));
} else {
ws.reconnect();
}
}
});
别小看这个细节,很多“线上连接正常但用户一到前台就卡死”的问题,根源就在这。
6.2 坑二:服务器端pending连接堆积,内存泄漏式膨胀
如果服务端没有做好心跳超时清理,假死连接会越积越多。每一分钟有一个客户端断线,连接对象在内存里不释放,日积月累就会把服务器资源吃空。
我的建议是定期体检连接池的健康度,不只靠心跳清理,还要配合定时统计:
- 当前连接总数;
- 近5分钟有过业务消息的连接数;
- 近5分钟仅心跳消息的连接数;
- 心跳超时被清理的连接数。
这些指标配一个简单的日志或监控面板,长连接的健康状态一下就透明了。
6.3 坑三:多标签页多连接重复建连
浏览器同一个源可以开多个标签页,每个标签页都建立一条WebSocket连接,服务端就要维护多份连接对象,消息还会重复推送到每个标签页。如果消息里有“已读回执”“未读数更新”这类高频指令,多连接会导致业务状态互相覆盖。
一个稳妥的简化方案是:用BroadcastChannel或SharedWorker让多个标签页共享一个WebSocket连接,或者至少保证同一时刻只有一个活跃标签页持有连接。不用做到大平台那么复杂,但在设计阶段就要考虑到这个场景。
6.4 坑四:服务端重置连接时没通知业务层
连接被心跳判定超时后主动断开,这没问题。但如果断开逻辑只做了ws.terminate(),而没有通知业务模块“这个用户下线了”,业务层仍然会保留用户的在线状态、会话缓存、推送队列。用户下次重连时会发现一堆重复消息或者状态错乱。
所以在close和terminate的清理链路上,一定记得调用业务层的在线状态管理模块,把用户标记为离线,并把面向该用户的消息队列清理或持久化。
7. 日志与监控:心跳机制上线后,你需要盯住这几个指标
代码都写完了,参数也调好了,但如果监控没跟上,线上出问题你依然抓瞎。心跳机制上线后,我建议至少盯住下面这些指标:
| 指标 | 统计口径 | 告警阈值 |
|---|---|---|
| 心跳成功率 | 周期内收到Pong/发出Ping的次数比例 | 低于95%告警 |
| 重连次数 | 单位时间内的总重连次数 | 连续10分钟内超过3次触发告警 |
| 心跳超时清理数 | 服务端因超时主动断开的连接 | 每分钟超过总连接数的5%告警 |
| 消息送达率 | 业务消息发出后收到ack的比例 | 低于98%告警 |
| 平均重连延迟 | 从断开到重连成功的平均时间 | 超过15秒告警 |
日志方面,我在每次心跳超时主动断开、重连成功、消息补发成功这三个关键节点都会打一条带connectionId和userId的日志。这样用户报障时,可以直接按用户维度拉出整条时间线:哪秒断的线、断之前最后一次心跳是什么时候、重连花了多少秒、重放了几条消息。排查效率提升非常明显。
还有一个实用小技巧:日志里把“断线ID”和“重连带宽”打出来,把一次完整的断线重连事件串起来。实现方式就是每次断线和重连都生成同一个sessionId,打到日志里。这样查看的时候一条链路全部一目了然。
关于这套方案的实际效果,我可以给一个参考数据:在我的项目里,加上了心跳与重连机制后,长连接的消息到达率从97.2%提高到了99.8%左右。剩下的0.2%,主要是极端弱网环境下连TCP握手都完不成的情况。至于那些“连接假死、显示在线但永不收消息”的事故,基本归零了。
最后忍不住提醒一句:网上很多文章教人抄一段心跳代码就跑,但我经历过一次线上事故之后最大的体会是,心跳检测从来不是一个独立的小功能,它跟连接生命周期管理、网络状态感知、消息补偿、服务端清理、监控告警是强耦合的。你单独把Ping/Pong拼上去,能解决一部分问题,但要真正把长连接的可靠性做起来,需要把它当成一整套“连接健康度治理”来对待。
