WebSocket连接总掉线?四套保活方案与完整源码实战
2026/9/8 1:05:35 网站建设 项目流程

做实时消息推送的业务,迟早要碰一个问题:WebSocket连上了,过一阵子就悄悄断了。页面还在,定时器还在,日志里也没报错,但消息就是推不过来了。这背后的核心就是WebSocket的保活问题。我最早是在公司做实时监控大屏时踩到这个坑的,线上跑了不到一周,凌晨开始陆续掉线,用户早上打开页面发现数据停在一小时前,只能刷新恢复。后来排查下来,问题不是出在业务逻辑上,而是连接根本没守住。这篇文章我会从WebSocket断线的根因讲起,把保活这件事彻底拆开,给你四套可落地方案:协议层Ping/Pong、业务层心跳、断线自动重连、服务端清理与网关参数调整,配套完整前端和后端源码。适合所有用WebSocket做实时应用的前端、后端和全栈同学。

1. 问题本质:WebSocket连接为什么会被"悄悄"断开

1.1 网络链路上的闲置回收机制

先建立一个人人都能理解的模型。你在浏览器里创建的WebSocket连接,表面上是从浏览器到你的服务器,实际上数据包要经过的路远不止这两点:家里或公司的路由器、运营商NAT设备、云厂商负载均衡、反向代理Nginx,最后才到业务进程。这条链路上的每一个设备,都会为经过的TCP连接维护一份会话状态,用来记录这个连接是谁、该往哪儿转发。状态存在内存里,设备不可能无限保存,所以几乎所有网络中间件都有一个默认策略:如果一条连接在一段时间内没有任何数据传输,就判定它已经死了,把会话状态回收掉,后续数据包自然也就丢了。

这个"一段时间"非常短。很多云负载均衡设备的空闲超时默认只有60秒,Nginx的proxy_read_timeout默认也是60秒。也就是说,你的WebSocket连接哪怕只是60秒没说过一句话,中间某个节点就可能把连接悄悄掐掉。更要命的是,大部分设备回收连接时不发TCP的FIN包,浏览器根本不知道自己已经失联,页面上一切照旧。等到服务端真的往这个连接推送业务消息时,才发现这条路已经不通了,然后触发onclose回调,用户看到的要么是消息缺失,要么是"连接已断开"的报错。

很多人以为WebSocket是长连接,客户端和服务端只要不主动关闭就能一直保持,这个理解漏掉了中间链路这一层。任何长连接方案,都必须主动对抗链路中的闲置清理机制,最直接的手段就是让连接"一直有话说"。

1.2 保活方案的三个层面

解决保活问题,不是写一段心跳代码那么简单。完整方案应该覆盖三个层面。

第一层是协议层。WebSocket协议本身定义了Ping和Pong控制帧,服务端可以周期性地向客户端发Ping,浏览器收到后会自动回Pong。这是协议级的保活,不占业务逻辑,最规范。

第二层是业务层。前端或者后端按固定间隔发送一段业务心跳消息(比如JSON形式的{"type":"heartbeat"}),让链路持续有数据流动,同时也验证了业务消息互通,是实际生产中使用最广的方案。

第三层是链路层。调整Nginx、负载均衡、网关等中间设备的超时参数,让它们不要那么快回收连接,并且配置好Upgrade头,让WebSocket升级请求顺利穿透。

这三层不是互斥关系。线上项目通常组合使用:业务层心跳负责让连接一直活跃,协议层Ping兜底确认连接是否真的还活着,链路层参数调整给缓冲。下面我把每种方案都展开讲,每一层给你可以直接抄的代码和配置。

2. 方案一:协议级Ping/Pong,用WebSocket自带的保活机制

2.1 WebSocket协议是怎么定义心跳的

WebSocket协议(RFC 6455)里规定了几种控制帧,其中Ping帧和Pong帧就是专门用来做连接健康检查的。规则很简单:一端发送Ping帧,另一端收到后必须回一个Pong帧,Pong帧里要把Ping携带的数据原样带回来。这个交互由协议栈自动完成,不需要业务代码掺和。

协议帧大概长这样:Ping帧是一个操作码为0x9的帧,负载数据长度不超过125字节;Pong帧操作码为0xA。浏览器收到Ping之后,会自动在底层回一个Pong,前端JavaScript里不用写任何处理逻辑,你也感知不到这个交互过程。服务端收到Pong之后,就能确认这条连接“客户端还在、中间链路还通、浏览器还活着”。

2.2 服务端怎么主动发Ping:Node.js实现

Node.js里用ws库做WebSocket服务端,发Ping简单到不像是正事:

const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); // 每30秒对所有客户端执行一次Ping const heartbeatTimer = setInterval(() => { wss.clients.forEach((ws) => { if (ws.readyState !== WebSocket.OPEN) return; ws.ping(); ws.isAlive = true; // 下一轮清理时会被置为false,用来筛出失活连接 }); }, 30000); wss.on('connection', (ws) => { ws.isAlive = true; // 收到任何消息都视为连接活跃,这里顺带清理失活标记 ws.on('pong', () => { ws.isAlive = true; }); ws.on('close', () => { console.log('client closed'); }); }); wss.on('close', () => { clearInterval(heartbeatTimer); });

上面的ws.ping()就是发送协议级Ping帧。浏览器客户端收到后自动回Pong,Node端通过监听pong事件确认这条连接还活着。这段代码还有一个隐藏作用:配合isAlive标记,下一轮定时器里如果没有收到pong,就执行ws.terminate(),把半死连接清理掉,后面讲服务端清理僵尸连接时还会细说。

2.3 Spring Boot服务端的Ping实现

Java生态里,Spring Boot自带WebSocket支持,发送Ping帧也不复杂。核心是利用WebSocketSession.sendMessage()发送一个PingMessage

import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.web.socket.PingMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import java.io.IOException; import java.nio.ByteBuffer; import java.util.concurrent.CopyOnWriteArraySet; @Component public class WebSocketHeartbeatService { // 用个简易集合保存会话,生产环境建议用ConcurrentHashMap管理 private final CopyOnWriteArraySet<WebSocketSession> sessions = new CopyOnWriteArraySet<>(); public void addSession(WebSocketSession session) { sessions.add(session); } public void removeSession(WebSocketSession session) { sessions.remove(session); } @Scheduled(fixedRate = 30000) public void sendPingToAll() { for (WebSocketSession session : sessions) { try { if (session.isOpen()) { session.sendMessage(new PingMessage(() -> ByteBuffer.wrap("heartbeat".getBytes()))); } } catch (IOException e) { try { session.close(); } catch (IOException ignored) { } } } } }

PingMessage构造方法里那个lambda是负载内容的提供者,随便塞一点内容就行。后端定时发Ping,浏览器客户端的协议栈会自动回Pong,服务端不需要额外代码接收Pong——协议层面已经帮你处理好了。

2.4 协议级心跳的局限

协议级心跳最干净,但它不是万能药,有两个明显的限制。

第一个限制:浏览器端没法主动发Ping。虽然WebSocket协议支持客户端发Ping,但浏览器的WebSocketAPI没有暴露发送Ping帧的方法。前端只能被动收Ping、自动回Pong。如果整条链路中服务端主动发起Ping的频率不够,或者服务端是别人家的(比如你只能连接第三方WebSocket服务),前端想做协议级保活就无从下手。

第二个限制:部分网络设备只认实际负载流量。虽然Ping/Pong也是合法的WebSocket帧,但极少数过滤严格的安全设备会把它当成控制帧放行而更新会话状态,但有些老式NAT设备对非数据负载的处理并不可靠。我在实际项目里就遇到过Ping/Pong一直正常、业务消息却断掉的情况,所以协议级心跳适合做兜底,不适合做唯一保活手段。

3. 方案二:业务层自定义心跳,兼容性最好的通用方案

3.1 为什么业务层心跳是主力

业务层心跳的思路非常简单:不要依赖协议帧,在应用里发一条普通消息上去,比如前端每30秒向服务端发送{"type":"heartbeat","timestamp":1700000000000},服务端收到后回一条{"type":"heartbeat_ack","timestamp":...}。只要这条消息能往返,就证明整条链路是通的。

这个方案的优势在于:前端能主动发,不受浏览器API限制;Java、Node、Python等任何后端语言都能处理;WebSocket、小程序、App原生WebSocket都能统一同一套心跳协议;顺带还能在心跳消息里携带客户端时间戳、版本号等辅助信息。这也是目前绝大多数生产项目采用的方案。

3.2 心跳参数怎么定:不是随便选个数字

心跳间隔和失活阈值是保活方案里最核心的两个参数,很多人拍脑袋定个30秒就上了,其实是可以推算的。

先说心跳间隔。链路中闲置回收时间最常见的默认值是60秒,云负载均衡、Nginx、部分防火墙都是这个值。为了让连接在任何中间设备眼里都保持"活跃"状态,你发送心跳的间隔必须小于这个回收时间,而且要留出足够余量,因为网络抖动可能导致心跳包延迟到达。我的经验是:心跳间隔取回收时间的一半,也就是30秒;如果你们的链路设备更敏感(比如某些企业防火墙30秒就回收),就把心跳间隔压到20秒。

再说失活阈值。这里的逻辑是:如果超过一定时间没收到任何消息(包括业务消息和心跳回包),就认为连接已经死了,需要主动断开重连。失活阈值至少要覆盖两个心跳周期,比如心跳间隔30秒,那失活阈值设90秒比较合理。前端以收到任何消息的时间为准刷新"最后活跃时间",而不是只看心跳回包,因为业务流量也可以视为连接活跃。

这里给一个参数参考表:

参数推荐值原因
心跳间隔20~30秒小于最常见的60秒闲置回收值,留足余量
失活阈值60~90秒覆盖2~3个心跳周期,避免误杀
重连初始间隔1秒断线后尽快恢复
重连最大间隔30秒避免无限高频重连打垮服务端
重连随机抖动0~1000ms防止大量客户端同时重连造成雪崩

3.3 前端心跳封装类完整代码

下面这个HeartbeatClient类把业务心跳、失活检测、断线重连整合到了一起,你复制到项目里就能用:

class HeartbeatClient { constructor(options = {}) { this.url = options.url || ''; this.heartbeatInterval = options.heartbeatInterval || 30000; this.deadlineTimeout = options.deadlineTimeout || 90000; this.reconnectBaseDelay = options.reconnectBaseDelay || 1000; this.reconnectMaxDelay = options.reconnectMaxDelay || 30000; this.maxReconnectAttempts = options.maxReconnectAttempts || 10; this.onMessage = options.onMessage || function () {}; this.onStatusChange = options.onStatusChange || function () {}; this.ws = null; this.heartbeatTimer = null; this.deadlineTimer = null; this.reconnectTimer = null; this.reconnectAttempts = 0; this.manualClosed = false; this.lastMessageTime = 0; this.isConnected = false; } connect() { this.manualClosed = false; this._open(); } _open() { try { this.ws = new WebSocket(this.url); } catch (e) { console.error('WebSocket创建失败', e); this._scheduleReconnect(); return; } this._bindEvents(); } _bindEvents() { this.ws.onopen = () => { this.isConnected = true; this.reconnectAttempts = 0; this.lastMessageTime = Date.now(); this._startHeartbeat(); this.onStatusChange('online'); }; this.ws.onmessage = (event) => { // 收到任何消息都视为连接活跃 this.lastMessageTime = Date.now(); // 把原生事件交给业务层处理 this.onMessage(event.data); }; this.ws.onclose = () => { this.isConnected = false; this._stopHeartbeat(); this.onStatusChange('offline'); if (!this.manualClosed) { this._scheduleReconnect(); } }; this.ws.onerror = (err) => { // onerror之后必然触发onclose,所以统一在onclose里处理 console.warn('WebSocket error:', err); }; } _startHeartbeat() { // 心跳定时器:周期性向服务端发送业务心跳消息 this.heartbeatTimer = setInterval(() => { if (this.ws && this.isConnected) { this.ws.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() })); } }, this.heartbeatInterval); // 失活检测:周期检查最后一次消息时间,超时就主动断开 this.deadlineTimer = setInterval(() => { const gap = Date.now() - this.lastMessageTime; if (gap > this.deadlineTimeout) { console.warn('连接超过 ' + this.deadlineTimeout + 'ms 无数据,主动断开重连'); this.ws.close(); } }, 10000); } _stopHeartbeat() { clearInterval(this.heartbeatTimer); clearInterval(this.deadlineTimer); this.heartbeatTimer = null; this.deadlineTimer = null; } _scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.onStatusChange('failed'); return; } const delay = this._getReconnectDelay(); this.reconnectTimer = setTimeout(() => { this.reconnectAttempts++; this._open(); }, delay); } _getReconnectDelay() { // 指数退避 + 随机抖动,避免重连风暴 const base = this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts); const jitter = Math.random() * 1000; return Math.min(base + jitter, this.reconnectMaxDelay); } send(data) { if (this.isConnected && this.ws) { this.ws.send(data); } else { console.warn('WebSocket未连接,消息未发送'); } } close() { this.manualClosed = true; this._stopHeartbeat(); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); if (this.ws) this.ws.close(); } }

这个类里的失活检测是一个容易被忽略但很关键的点:心跳定时器只负责发心跳,真正感知连接是否死亡的,是那个每10秒跑一次的失活检查。因为当中间链路被静默切断时,TCP不会通知你,你发的心跳包可能直接丢进黑洞,只有长时间收不到任何回包,才能确定连接已经断了。这时候调用this.ws.close(),触发onclose,再走重连逻辑,就形成了完整的闭环。

4. 方案三:断线自动重连与网络状态感知

4.1 断线重连策略:指数退避和抖动

不是所有断线都能靠心跳防止,服务端重启、网络切换、电脑休眠苏醒,都会让连接意外断开。所以前端必须要有自动重连机制。这里最大的坑是重连风暴:如果一条线上几千个客户端同时断线,又都用固定的1秒后重连,服务端瞬间会被几千个握手请求打挂。

正确做法是指数退避加随机抖动。我上面代码里的_getReconnectDelay()就是标准实现:

延迟时间 = min(基础延迟 * 2^重连次数, 最大延迟) + 随机抖动

比如第一次1秒、第二次2秒、第三次4秒、第四次8秒,以此类推,最多不超过30秒,每次再加一个0到1000毫秒的随机数。这样即使用户量大,重连请求也能分散开。重连次数需要设上限,比如10次,超过后说明网络可能彻底不可用或服务端长时间下线,这时候应该提示用户手动刷新,而不是无声无息地无限重试。

4.2 页面切后台被冻结:浏览器偷偷停掉你的定时器

WebSocket保活的另一个隐性杀手是浏览器后台标签页限制。Chrome、Safari等浏览器为了省电,会冻结后台页面的JS定时器,setInterval可能被降频甚至完全暂停。你人把页面挂在后台隔一小时切回来,会发现心跳根本没发出去,连接早就断了。

解决方案是监听visibilitychange事件,在页面重新可见时立即处理:

document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { // 页面重新可见,立即发一次心跳并检查连接状态 client.heartbeatClientHandleVisibilityChange(); } });

配合在HeartbeatClient类里增加一个方法:

heartbeatClientHandleVisibilityChange() { if (this.isConnected && this.ws) { // 主动发一条心跳,同时立刻做一次失活检查 this.ws.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() })); this.lastMessageTime = Date.now(); } else { // 连接已经断了,立刻重连 this._scheduleReconnect(); } }

这个细节非常重要,很多团队的WebSocket连接白天好好的,一到晚上就集体掉线,往往就是因为用户把页面挂着不动,定时器被浏览器冻结,连接静默死亡。等你补上visibilitychange处理,这类问题会大幅减少。

4.3 网络切换事件:从WiFi切到流量要主动重连

移动端用户在电梯里从WiFi切到蜂窝网络,或者从办公室走到楼道里,IP变了,底层网络切换了,但TCP连接不知道,你会看到连接还显示着,实际上已经废了。浏览器有个online事件可以感知网络恢复:

window.addEventListener('online', () => { // 网络恢复,无论当前连接状态如何,都强制重建连接 if (client.ws) { client.ws.close(); } client.connect(); });

注意这里不能只调connect(),因为connect()内部会创建一个新的WebSocket,而旧连接可能还残留着,稳妥做法是先关闭旧连接再重建。HeartbeatClientmanualClosed标记此时要小心处理:直接调ws.close()会触发onclose,而manualClosed还是false,可能导致调度一次没有必要的重连。所以更稳妥的写法是:

window.addEventListener('online', () => { // 重置手动关闭标记,强制关闭后由onclose统一走重连 if (client.ws) { client.ws.close(); } });

因为onclose里会判断manualClosed,只要它还是false,关闭后就会自动走_scheduleReconnect()。这样代码逻辑保持单一入口,不容易出混乱。

5. 方案四:服务端与网关侧的保活配合

5.1 Nginx反代配置:别让默认60秒断了你的WebSocket

使用Nginx作为WebSocket反向代理的场景非常普遍。Nginx有一个很反直觉的默认行为:proxy_read_timeoutproxy_send_timeout默认都是60秒,也就是说,如果后端在60秒内不给Nginx发数据,Nginx就认为这个连接超时了,主动断开。放在WebSocket场景下,一条连接哪怕只是空闲了60秒,Nginx就会掐掉它。

正确配置如下:

map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream websocket_backend { server 127.0.0.1:8080; keepalive 64; } server { listen 80; server_name example.com; location /ws { proxy_pass http://websocket_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 关键:把反代超时时间拉长,和业务心跳周期匹配 proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_connect_timeout 75s; } }

这里有几个细节值得展开。proxy_set_header Upgrade $http_upgradeproxy_set_header Connection $connection_upgrade是WebSocket升级请求穿透Nginx的关键,少了它们,浏览器会收到400 Bad Request。用map指令把Connection头动态设置成upgradeclose,是因为普通HTTP请求不能随便带Upgrade头。proxy_read_timeout是要重点调整的参数,建议至少和你的心跳周期匹配,设成3600秒意味着只要连接上还有数据流,就不会被Nginx掐断;如果你的心跳周期是30秒,其实proxy_read_timeout设成300秒也够,但为了保险一般会拉得比较长。

5.2 服务端要主动清理僵尸连接

服务端侧的问题往往容易被忽略:客户端突然断电或断网,服务端的连接对象却还保留着,这些连接占着文件描述符和内存,日积月累就是一次事故。清理僵尸连接的思路是定期给所有客户端发Ping或业务心跳,超过N次没有回包就强制关闭。

Node.js版本的完整实现:

const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); // 每30秒做一次健康检查:先发Ping,下一轮检查Pong是否回来 const checkTimer = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { // 上一次检查时发了ping,但这一次发现还没有收到pong,说明死了 ws.terminate(); return; } ws.isAlive = false; ws.ping(); // 同时记录当前时间,用于业务层观察 ws.lastConnectedAt = Date.now(); }); }, 30000); wss.on('connection', (ws) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); }); wss.on('close', () => { clearInterval(checkTimer); });

这段代码的逻辑是:第一轮定时器给每个连接发Ping并标记isAlive = false,收到Pong的连接会被重置为true,下一轮定时器检查时,如果某个连接还是false,说明上一轮Ping没有回应,直接terminate()。注意要和业务心跳区分:协议Ping是服务端主动发起的,业务心跳是应用层消息,两边都做也能共存。

5.3 云负载均衡和其他网关的超时设置

如果你的WebSocket服务部署在云上,前面通常还挂着一层云厂商的负载均衡器。这类产品同样有闲置超时配置,而且不同厂商的默认值差异很大,常见的是60秒或300秒。文档里通常叫"会话保持时间"或"空闲超时时间",一定要找到WebSocket或TCP监听对应的超时配置,把它调大,或者确认它不会对长时间无数据的连接下手。

这里有个经验顺序:先调Nginx这类自己可控的组件,再看云负载均衡的超时设置,最后才是防火墙、安全组之类的设备。链路上一环没调好,前面所有心跳都是白搭——因为你的心跳包发出去了,但中间某个设备当面把连接断了。

6. 完整可运行源码:前端、后端一整套

6.1 前端完整Demo:一个带保活的WebSocket页面

下面是一个完整的HTML文件,直接保存成ws-heartbeat-demo.html,浏览器打开就能用。它把前面讲的HeartbeatClient类、页面可见性监听、网络状态监听都整合在了一起:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>WebSocket保活Demo</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; padding: 24px; max-width: 600px; margin: 0 auto; } #status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-weight: bold; } .online { background: #e6f4ea; color: #1e7e34; } .offline { background: #fde8e8; color: #c62828; } .log { background: #f5f5f5; padding: 12px; border-radius: 6px; height: 300px; overflow-y: auto; font-size: 13px; } button { padding: 8px 16px; margin-right: 8px; } </style> </head> <body> <h2>WebSocket 保活方案完整Demo</h2> <div> 连接状态:<span id="status" class="offline">离线</span> </div> <p> <button id="connectBtn">建立连接</button> <button id="closeBtn">手动断开</button> </p> <div id="log" class="log"></div> <script> function appendLog(msg) { const logEl = document.getElementById('log'); const line = document.createElement('div'); line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; logEl.appendChild(line); logEl.scrollTop = logEl.scrollHeight; } class HeartbeatClient { // 完整实现见上文的HeartbeatClient类,此处不再重复 } const client = new HeartbeatClient({ url: 'ws://localhost:8080', heartbeatInterval: 30000, deadlineTimeout: 90000, reconnectBaseDelay: 1000, reconnectMaxDelay: 30000, maxReconnectAttempts: 10, onMessage: (data) => { appendLog('收到消息: ' + data); }, onStatusChange: (status) => { const statusEl = document.getElementById('status'); statusEl.className = status === 'online' ? 'online' : 'offline'; statusEl.textContent = status === 'online' ? '在线' : (status === 'failed' ? '重连失败' : '离线'); appendLog('连接状态变化: ' + status); } }); document.getElementById('connectBtn').addEventListener('click', () => { client.connect(); appendLog('发起连接...'); }); document.getElementById('closeBtn').addEventListener('click', () => { client.close(); appendLog('手动断开连接'); }); // 页面切回时主动检查连接 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && client.isConnected) { client.heartbeatClientHandleVisibilityChange(); appendLog('页面恢复可见,立即心跳检测'); } }); // 网络恢复时重建连接 window.addEventListener('online', () => { appendLog('网络恢复,重建连接'); if (client.ws) client.ws.close(); client.connect(); }); </script> </body> </html>

注意HeartbeatClient类正文我在上面已经给过完整代码,这里为了演示页面布局把类的定义省略了,实际使用时把类完整复制进去即可。心跳间隔我建议你开发时先设成5秒,方便观察效果,线上再改成30秒。

6.2 Node.js服务端完整代码

服务端我推荐用ws库,先执行npm init -y && npm install ws安装依赖,然后保存下面的server.js

const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); // 记录所有连接的活跃状态 const clients = new Map(); wss.on('connection', (ws, req) => { console.log('客户端已连接,来源IP:', req.socket.remoteAddress); ws.isAlive = true; ws.lastPong = Date.now(); clients.set(ws, { connectedAt: Date.now() }); // 收到pong帧时更新状态 ws.on('pong', () => { ws.isAlive = true; ws.lastPong = Date.now(); }); // 业务消息处理 ws.on('message', (data, isBinary) => { if (isBinary) { // 二进制消息,这里直接忽略或者按需处理 return; } const text = data.toString(); let msg; try { msg = JSON.parse(text); } catch (e) { console.log('收到非JSON消息:', text); return; } if (msg.type === 'heartbeat') { // 收到业务心跳,立刻回复ack,并且更新活跃时间 ws.send(JSON.stringify({ type: 'heartbeat_ack', ts: Date.now() })); } else { // 其他业务消息,这里简单回显 ws.send(JSON.stringify({ type: 'echo', data: msg, ts: Date.now() })); } }); ws.on('close', () => { console.log('客户端断开'); clients.delete(ws); }); }); // 定时健康检查:协议级Ping + 清理僵尸连接 const interval = setInterval(() => { for (const ws of clients.keys()) { if (!ws.isAlive) { console.log('连接已失活,强制关闭'); ws.terminate(); clients.delete(ws); continue; } ws.isAlive = false; ws.ping(); } }, 30000); wss.on('close', () => { clearInterval(interval); }); console.log('WebSocket服务已启动: ws://localhost:8080');

启动命令:

node server.js

然后打开上面的ws-heartbeat-demo.html页面,点"建立连接",你会看到日志区域定期出现收到消息: {"type":"heartbeat_ack",...},这就说明业务心跳成功了。把服务端停掉再启动,你还能看到前端自动重连的过程,重连间隔呈指数增长。

6.3 Java Spring Boot服务端源码

Java后端用Spring Boot做WebSocket也很常见,贴一个简化但完整的版本。首先加依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

配置WebSocket端点:

import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final WebSocketHandler webSocketHandler; public WebSocketConfig(WebSocketHandler webSocketHandler) { this.webSocketHandler = webSocketHandler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(webSocketHandler, "/ws").setAllowedOrigins("*"); } }

写业务处理器,里面同时处理业务心跳和协议Ping:

import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.PingMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.nio.ByteBuffer; import java.time.LocalDateTime; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Component public class WebSocketHandler extends TextWebSocketHandler { // 管理全部在线会话 private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { sessions.put(session.getId(), session); System.out.println("连接建立: " + session.getId()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); // 业务心跳:前端发 {"type":"heartbeat"},后端回 ack if (payload.contains("\"heartbeat\"")) { session.sendMessage(new TextMessage("{\"type\":\"heartbeat_ack\",\"time\":\"" + LocalDateTime.now() + "\"}")); } else { // 其他消息原样回显,便于测试 session.sendMessage(new TextMessage("echo: " + payload)); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { sessions.remove(session.getId()); System.out.println("连接关闭: " + session.getId()); } // 每30秒协议级Ping,顺便清理失活会话 @Scheduled(fixedRate = 30000) public void heartbeatCheck() { sessions.forEach((id, session) -> { try { if (session.isOpen()) { session.sendMessage(new PingMessage(() -> ByteBuffer.wrap("keepalive".getBytes()))); } else { sessions.remove(id); } } catch (Exception e) { sessions.remove(id); } }); } }

Spring Boot的@Scheduled注解需要开启定时任务,在主类上加上@EnableScheduling就行。这套代码配合前端页面,完成业务心跳和协议Ping双重保活。

6.4 本地联调与验证方法

联调时最直观的工具是Chrome开发者工具的Network面板。打开页面后,按F12切到Network,筛选WS,点击你的WebSocket连接,再切到Messages标签页,就能看到每一帧的发送和接收记录。你会看到心跳包定期出现,服务端Ping对应的Pong帧也会出现在这个面板里。如果连接断开了,面板上会立刻显示Status: Closed,时间线也会标出断开前的最后一条消息时间。

验证保活是否真正生效,可以做两个实验。第一个实验:把服务端停掉,看前端是否在心跳超时之后自动重连。第二个实验:把页面切到后台,等一两分钟再切回来,看前端是否通过visibilitychange处理立即恢复了连接。这两个实验都通过,说明你的保活链路基本是稳的。

7. 常见问题与排查技巧实录

7.1 常见问题速查表

现象可能原因排查方法
定时器还在跑但收不到消息中间链路静默断开,连接废了看Network面板最后一条WS帧时间;检查失活检测逻辑
重连时报400 Bad RequestNginx缺少Upgrade头配置检查proxy_set_header UpgradeConnection "upgrade"配置
页面切后台回来就掉线浏览器冻结JS定时器监听visibilitychange,回来时主动检测和重连
多个客户端同时掉线服务端重启或网络波动检查服务端日志;重连策略必须加指数退避和随机抖动
服务端连接数持续上涨僵尸连接未被清理服务端定期发Ping并清理失活连接;检查客户端是否无限重连
Nginx日志大量upstream timed outproxy_read_timeout默认值太低把超时调大到300秒以上,或配合心跳包

7.2 排查思路:先看链路,再看代码

遇到WebSocket掉线问题,我建议按下面四步排查,效率和准确率都比瞎猜高很多。

第一步,确认掉线的具体表现。是收不到推送,还是连接状态直接变了?在浏览器控制台打印ws.readyStateonclosecode,可以判断是网络层断的还是服务端主动关的。code是1006表示异常关闭,通常意味着链路出问题;code是1000表示正常关闭,通常是业务代码主动调的close()

第二步,看抓包或WS帧时间线。我在实战中发现,很多所谓"掉线"其实是长时间没有业务消息,而客户端根本不知道连接已经死了。在Network面板的WS标签页里,如果长时间没有收发帧,说明心跳机制根本没起作用。这时回头检查心跳定时器有没有被浏览器冻结、服务端有没有回ack。

第三步,查服务端日志。服务端是否有异常断开记录?是否有线程或进程崩溃重启?用ss -tnlp | grep 8080之类的命令看连接数变化,能快速发现僵尸连接数量是否在增长。

第四步,检查链路组件。如果连接经过Nginx,先看error.log里有没有upstream timed out;如果经过云负载均衡,翻一下官方文档确认它支持WebSocket以及默认闲置超时是多少。这一步经常被忽略,但恰恰是生产环境最常出问题的地方。

7.3 从实践里沉淀的几个关键经验

写到这里,顺便把我在不同项目里打磨出来的经验也分享出来。

心跳不是越勤越好。有人图省事把心跳间隔设成5秒,结果服务端和网络设备要处理大量冗余包,高峰期白白浪费资源。一般30秒的心跳足够对抗绝大多数60秒闲置回收,也不至于产生过大开销。如果链路里存在更短的空闲阈值,优先调链路参数,而不是死磕前端心跳频率。

核心逻辑要同时放在前端和后端。前端负责断线检测和重连,后端负责清理僵尸连接和主动Ping,缺一环都可能出问题。前端失活检测会主动断开并重连,后端Ping可以确认连接是否真的还活着,两边各管一摊,配合起来才完整。

把所有配置参数收敛到一处。我在项目中会把心跳间隔、失活阈值、重连最大间隔都放进一个配置对象,方便线上临时调整。你永远不知道哪条链路的设备会出幺蛾子,参数能调、能观察,比写死强一百倍。

最后再分享一个小技巧:正式上线前,把页面挂在后台一整夜,第二天早上看连接是否还活着。这一步能同时验证visibilitychange处理、心跳保活、重连机制三件事,比任何单元测试都管用。我在好几个项目里用这个土办法,提前发现了不少连生产环境都没暴露的问题。WebSocket保活不是一锤子买卖,而是一整套机制的组合,理清链路、选对方案、配好参数,长连接才能真正稳定跑起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询