☰
WebSocket心跳机制实现与长连接实战避坑指南
2026/10/9 3:26:35 网站建设 项目流程

很长一段时间里,只要涉及实时消息,我第一个想到的就是 WebSocket。不是它没有缺点,而是它在浏览器和服务器之间做双向通信时实在太顺手了。直到有一次线上事故,用户明明显示“在线”,客服消息却一直发不出去,最后发现连接还挂在服务端,但客户端网络早就断了。那次之后我才认真去研究 WebSocket 心跳机制实现,也把 WebSocket 使用过程中容易忽略的细节重新梳理了一遍。

这篇文章不打算从 RFC 文档逐条讲,而是按我实际开发时的思考顺序来写:先讲清楚 WebSocket 到底替你解决了什么问题,再拆握手和消息帧这些底层机制,然后给出一套可以直接抄作业的带心跳的完整代码,最后整理成问题排查表。适合已经会用 HTTP 接口、但还没系统做过长连接的后端或前端同学,也适合正在排查“连接老掉”“服务器连接数撑不住”这类问题的从业者。

1. 为什么你需要重新认识 WebSocket

1.1 一次“在线”却失联的事故

互联网上最常见的一种接口形态是请求-响应:客户端发一个 HTTP 请求,服务端返回一个结果,连接就结束。如果我们要做一个聊天室、一个订单状态推送、一个服务器监控面板,都需要服务端主动往客户端推数据。于是很多人会做轮询,每隔几秒请求一次。轮询能解决“看起来会动”的需求,但也会制造大量无效请求。

我遇到过最尴尬的场景是这样的:客服系统里,客户侧网络切到了另一个 Wi-Fi,TCP 连接其实已经断了,但服务端没有立刻感知到。服务端认为用户在线,用户看到的界面也显示“已连接”,可任何消息都发不到对方手里。等到网络超时触发底层断开,服务端才清理连接,整个过程可能持续几十秒甚至更久。

这个事故的核心不是 WebSocket 本身,而是我们没有给它配“心跳”。WebSocket 连接一旦建立,如果没有数据流动,中间的网络设备可能会把空闲连接回收,客户端和服务端又各自以为连接还活着。心跳机制实现,就是让连接定期发出“我还活着”的信号,第一时间发现死链并清理。这个机制不是可选项,而是长连接的基本功。

1.2 WebSocket 到底解决了什么问题

WebSocket 是一个建立在 TCP 之上的应用层协议。它和 HTTP 的关系不是替代,而是互补。HTTP 是“一问一答”,WebSocket 是“建好管道之后,两边随时可以说话”。

用生活化的类比来说,HTTP 像是每次打电话都要先拨号、接通、说事、挂断;WebSocket 更像是加了好友之后直接开视频,只要不挂断,随时可以说话。浏览器创建 WebSocket 连接的入口是一个new WebSocket(url),之后通过send()发消息,通过onmessage收消息。服务端则可以使用 Node.js 的ws库、Java 的 Netty、Go 的 gorilla/websocket 等实现。

它解决的痛点有三个:

  • 实时性:服务端有变化,可以立即推给客户端,不用等客户端来轮询。
  • 双向性:客户端和服务端都能主动发起消息,适合聊天、协作编辑、实时白板。
  • 省流量:一次握手后,后续消息头开销很小,相比高频轮询更省带宽。

但也要泼一盆冷水:WebSocket 不是万能药。如果业务只是“前端定期拉取数据”,轮询或 SSE(Server-Sent Events)可能更简单。SSE 是服务端单向推送,基于 HTTP,遇到断线还能自动重连,很多场景其实够用。只有当你确实需要频繁双向交互时,才值得引入 WebSocket。

1.3 什么时候不该用 WebSocket

我见过不少项目,为了“显得高级”,把本来可以用普通 REST 接口做的事情硬改成 WebSocket。这通常会引入更多问题:连接管理、鉴权、断线重连、多实例消息广播、反向代理配置,全是额外成本。

如果一个功能只需要服务端每 5 秒推一次数据,SSE 就够了;如果只是用户提交表单后查结果,HTTP + 轮询也够;如果只有聊天室这种高频双向场景,才建议用 WebSocket。选型时一定要先问自己:这个功能是不是真的需要服务端主动、频繁地推送消息?回答“不是”的话,别碰长连接。

2. WebSocket 核心机制拆解:从握手到消息帧

2.1 握手不是魔法,是 HTTP 升级

WebSocket 连接开始前,仍然需要先走一次 HTTP 请求,叫“握手”。浏览器发送一个带Upgrade: websocket头的 HTTP 请求,服务端返回101 Switching Protocols,之后这个 TCP 连接就变成了 WebSocket 连接。

服务端在握手阶段要做两件事:一是确认客户端确实想升级到 WebSocket 协议,二是校验身份。比如服务端可以读取 Cookie 或查询参数里的 token,不合法就返回 401,而不是完成 101 升级。这一步是很多人的安全盲区,后面我会单独讲。

为什么不是直接 TCP Socket?因为浏览器不允许网页直接裸连 TCP,WebSocket 是浏览器环境里能拿到的最接近 TCP 的双向通道。它借用 HTTP 的握手流程,也顺势复用了 HTTP 的端口、代理、TLS 体系。所以线上部署时,nginx 或负载均衡器必须正确转发Upgrade头,否则连接会在握手阶段失败。

2.2 消息帧、文本帧与二进制帧

一旦连接建立,后续传输不再使用 HTTP 报文,而是 WebSocket 帧。每一帧有 Opcode,用来区分数据类型:0x1表示文本帧,0x2表示二进制帧,0x8表示关闭帧,0x9和0xA分别是 Ping 帧和 Pong 帧。

这里有个容易误解的点:WebSocket 的“心跳”可以直接用协议层的 Ping/Pong 帧,不需要你手动在消息里塞 JSON。服务端发一个 Ping 帧,客户端如果按协议实现,会自动回 Pong 帧。不过浏览器 WebSocket API 并没有直接暴露发送 Ping 的控制方法,但浏览器收到服务端 Ping 后会自动回 Pong,实测 Chrome、Firefox 都是这样。如果客户端是自研 SDK,就需要确认它是否处理了 Ping/Pong,否则后端用协议层心跳就会失效。

数据帧大小也值得注意。单个帧最大可以到 2^63 字节,但实际使用时,服务端通常会限制单条消息大小,避免内存被打爆。Node.jsws库有maxPayload选项,默认 100 MiB,线上建议按业务调小,比如 1 MiB 或 4 MiB。

2.3 传输格式与库的选择

WebSocket 本身不关心消息是 JSON 还是二进制,它只负责把字节可靠地送到对端。实际业务里,绝大多数人用 JSON 文本帧,方便调试。但是对于实时音视频、游戏帧同步,应该用二进制帧,更省空间。

服务端选库时,我通常看三点:协议完整度、并发表现、社区活跃度。Node.js 生态最常用的是ws,轻量、无依赖、性能不错,配合cluster或PM2也能跑多进程。Java 项目我接触比较多的是 Netty,控制力最强,但学习成本高。Go 的 gorilla/websocket 是老牌选择,nhooyr.io/websocket 是后来更现代的实现。没有绝对最好的库,只要它支持 Ping/Pong、连接关闭、消息切片和超时控制,就能进入备选清单。

3. 实操:从零实现一个带心跳机制的 WebSocket 服务

3.1 先定方案:谁发心跳、用什么格式

在写代码之前,要先回答一个问题:心跳由谁发起?有三种常见方案:

  • 服务端发 Ping,客户端回 Pong:适合浏览器 WebSocket,浏览器自动回 Pong,服务端实现简单。
  • 客户端发 Ping,服务端回 Pong:适合自研客户端,方便客户端自己控制“多久没收到响应就重连”。
  • 双方都发应用层心跳:例如客户端发{"type":"ping"},服务端更新lastSeen并回{"type":"pong"}。

我的建议是:如果两端都是自己控制的 SDK,优先用协议层 Ping/Pong;如果客户端是浏览器,服务端用ws.ping(),浏览器自动 Pong,代码最少。如果业务层需要记录“最后活跃时间”,可以在pong事件里更新。

但要注意,并不是所有 WebSocket 客户端库都会自动响应 Ping。有些嵌入式设备 SDK 只处理文本帧,收到 Ping 不理会,这时候就必须改用应用层心跳。我这里给出一个比较稳的通用方案:应用层心跳为主,服务端同时监听pong也做兜底。

3.2 服务端代码:基于 Node.js 的 ws 库

先安装依赖:

npm install ws

服务端完整示例:

const http = require('http'); const WebSocket = require('ws'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('WebSocket server is running'); }); const wss = new WebSocket.Server({ server, maxPayload: 4 * 1024 * 1024 }); // 连接上来的客户端,如果长时间没发生任何消息往来,视为死链 wss.on('connection', (ws, req) => { ws.isAlive = true; ws.lastSeen = Date.now(); // 协议层 Pong 事件,说明客户端还活着 ws.on('pong', () => { ws.isAlive = true; ws.lastSeen = Date.now(); }); // 业务消息处理 ws.on('message', (data) => { ws.lastSeen = Date.now(); let msg; try { msg = JSON.parse(data); } catch (e) { ws.send(JSON.stringify({ type: 'error', message: 'invalid json' })); return; } if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong', time: Date.now() })); return; } // 这里可以接业务路由 ws.send(JSON.stringify({ type: 'ack', id: msg.id })); }); ws.on('close', () => { clearInterval(ws.heartbeatTimer); }); }); // 每 30 秒检查一次所有连接 const heartbeatInterval = setInterval(() => { wss.clients.forEach((ws) => { // 如果上一次检查后没有任何响应,直接 terminate if (ws.isAlive === false) { ws.terminate(); return; } ws.isAlive = false; ws.lastSeen = Date.now(); ws.ping(); }); }, 30000); wss.on('close', () => { clearInterval(heartbeatInterval); }); server.listen(8088, () => { console.log('WebSocket server listening on 8088'); });

这个实现的判断逻辑是:每 30 秒把isAlive置为false,并向客户端发 Ping。如果客户端在下一个 30 秒周期内回了 Pong,isAlive会变回true。如果连续两个周期都没有任何反应,服务端就terminate()掉这个连接。也就是说,最坏情况下一个死链在 60 秒内被清理。

这里我特意加了ws.lastSeen = Date.now(),是为了给业务层提供“最后活跃时间”,排查问题时很有用。你可以在监控面板上展示每个连接的存活时长和最近消息时间,一旦出问题,能快速判断是没连上还是连上后没数据。

3.3 客户端代码:连接、重连与心跳回应

浏览器端使用原生 WebSocket API,不需要额外库。为了避免服务端把空闲连接回收,客户端也要主动证明自己活着。虽然浏览器会自动回协议层 Pong,但我仍建议客户端定期发应用层心跳,这样服务端能直接更新业务活跃时间。

const wsUrl = 'ws://localhost:8088/ws?token=your-token'; const HEARTBEAT_INTERVAL = 20000; let ws = null; let heartbeatTimer = null; let reconnectAttempt = 0; let manualClose = false; function connect() { ws = new WebSocket(wsUrl); ws.onopen = () => { console.log('connected'); reconnectAttempt = 0; startHeartbeat(); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'pong') { console.log('server alive'); return; } // 其他业务消息 handleMessage(data); }; ws.onclose = () => { stopHeartbeat(); if (!manualClose) { scheduleReconnect(); } }; ws.onerror = () => { // onerror 之后大概率会触发 onclose,不要在 onerror 里重连 console.error('websocket error'); }; } function startHeartbeat() { stopHeartbeat(); heartbeatTimer = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', time: Date.now() })); } }, HEARTBEAT_INTERVAL); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } } function scheduleReconnect() { const delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 30000) + Math.floor(Math.random() * 1000); reconnectAttempt += 1; console.log(`reconnect in ${delay}ms`); setTimeout(connect, delay); } function close() { manualClose = true; ws.close(1000, 'bye'); }

这段代码有几个细节值得注意。

第一,onerror后不要立刻重连。onerror后面通常紧跟着onclose,如果在onerror里重连,会导致同一时间段重复建连。统一在onclose里处理更干净。

第二,重连间隔用“指数退避 + 随机抖动”。第一次失败等 1 到 2 秒,第二次等 2 到 3 秒,第三次等 4 到 5 秒,最多 30 秒左右。直接固定 1 秒重连,客户端多的时候就是自杀式请求风暴。

第三,手动关闭时要设manualClose = true,否则用户退出页面时还会自动重连,白耗资源。

3.4 心跳参数到底怎么算

很多人抄代码时直接照搬 30 秒,但并不知道为什么是 30 秒。心跳间隔和超时时间需要根据网络场景和业务容忍度来定。

  • 内网环境:延迟低,网络稳定,心跳间隔可以拉到 60 秒,超时判定 120 秒。
  • 公网环境:用户跨运营商、Wi-Fi 切换频繁,建议 20 到 30 秒一次心跳。
  • 移动弱网:心跳间隔 15 到 20 秒,重连间隔短一点,但也不能太短。

判断死亡时间的公式是:最长失联时间 ≈ 心跳间隔 × 允许连续丢失的周期数。比如间隔 30 秒,连续 2 次没收到回应,最坏情况下要等 60 秒才能清理。如果你想让最长失联时间控制在 30 秒以内,就把心跳间隔改成 10 秒,允许丢失 2 次。但心跳太频繁会白白消耗流量和电量,尤其是手机端。所以实际项目里我通常先设 30 秒,观察线上连接断开情况再调整。

还有一个细节是:被代理层切断的连接,服务端不一定立刻收到 FIN 包。TCP 连接在中间设备上静默断开时,服务端可能一直不知情。这正是心跳存在的意义——用应用层的探测来发现“网络设备层面已经死掉的连接”。

3.5 部署时最容易踩的代理层坑

本地联调跑通了,一旦上到生产,最常见的问题就是连接不稳定,几秒钟掉一次。十有八九是 nginx 或云负载均衡没有正确配置 WebSocket 升级。

以 nginx 为例,必须把Upgrade和Connection头转发给后端,同时调大proxy_read_timeout。nginx 默认的代理读超时大约是 60 秒,如果服务端和客户端之间没有任何数据,连接会被 nginx 强制断开。我们的心跳间隔是 30 秒,按理说每次都有 Ping/Pong 来往,但如果你只配置了proxy_set_header忘了超时时间,仍然可能出问题。

location /ws { proxy_pass http://127.0.0.1:8088; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_buffering off; }

proxy_buffering off也不是必须,但建议关掉。WebSocket 是双向流式协议,nginx 缓冲层在某些场景会引入额外延迟。另外,如果你的负载均衡器是云厂商提供的 HTTP 监听器,一定要确认它支持 WebSocket 协议,否则在 LB 那一层就被掐断了。我踩过一次:本地直连后端完全正常,走云 LB 之后每秒断一次,查了半天才发现 LB 没有开启 WebSocket 支持。

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

4.1 连接秒断:从“握手失败”和“握手后关闭”入手

连接秒断分两种:一种是 HTTP 握手根本没成功,另一种是 101 握手成功了,但立刻被服务端或客户端关闭。

区分方法很简单:看服务端日志有没有connection事件。如果没有,说明握手失败。这时重点查 nginx 的Upgrade头是否转发正确、端口是否通、鉴权参数是否合法。如果connection事件触发了,但紧接着close事件也触发了,说明握手后校验或业务逻辑抛了异常。我排查时会先输出关闭码,1008 是政策违规,1009 是消息太大,1000 是正常关闭,4000 到 4999 是业务自定义关闭码。根据关闭码能定位不少问题。

4.2 心跳发出去还是被判超时:看回调顺序和事件循环

我见过一个很隐蔽的问题:服务端应用层心跳和协议层心跳同时启用,两套逻辑各自维护lastSeen,结果互相覆盖。比如业务消息里有个ping类型帧,服务端处理完把lastSeen更新了,但pong事件没触发;另一个线程却在检查isAlive,导致明明刚收到消息的连接被误杀。

解决方法是统一心跳判断口径。要么只用协议层 Ping/Pong,要么只用业务层ping/pong消息,不要在同一个连接上混用两套判断。如果必须兼容不同客户端,就要用同一个lastSeen字段,任何消息或 Pong 都更新它。

另外注意 Node.js 的事件循环:如果消息处理函数是异步的,比如进数据库查询后再更新lastSeen,那么期间可能已经过了 timeout。异步回调里不要拿“进入消息处理那一刻的时间”去比较,要在真正完成处理后再更新。

4.3 连接数追不上、内存飙升:反向代理和连接泄漏

连接数上不去,第一种可能是系统文件描述符限制到了。Linux 默认每个进程可以打开的文件数是 1024,跑 WebSocket 服务至少得调大到几万。

ulimit -n 65535

第二种可能是反向代理的worker_connections配置太低。nginx 的worker_connections默认值在配置里,建议按业务并发调大,同时调整系统级网络参数。

内存飙升通常是三个原因:单条消息太大、连接对象没有释放、日志打得太勤。代码里我特意加了maxPayload: 4 * 1024 * 1024,就是防止有人发几百 MB 的帧进来。WS 服务端要定期用wss.clients.size做监控,如果连接数一直涨但不降,就要检查是不是客户端重连逻辑没写对,导致断开后立刻重新建立无数个新连接。

4.4 排查三板斧:日志、抓包、压测脚本

出了问题时,我一般按这个顺序排查。

第一步,看连接生命周期日志。每个连接打上唯一 ID,记录握手时间、最后活跃时间、关闭时间、关闭码。有了这几条,基本能判断“没连上”还是“连后被断”。

第二步,抓包看心跳。用 Wireshark 或 tcpdump 过滤端口,可以直接看到 Ping/Pong 帧是否到达。抓包时注意:如果经过了 TLS,看到的是加密内容,只能看 TCP 包大小和时序,这时候可以临时把 TLS 层卸掉做测试。

第三步,写个简单的压测脚本。用 Node.js 的ws库同时开几百个客户端,每 5 秒发一条消息,观察服务端连接数是否平稳。很多偶现问题在稳定压力下很快暴露。压测时一定要带上心跳,因为心跳本身就是吞掉死链的主要机制。

我整理了一个速查表,现场排查时对着看能省很多时间:

现象常见原因解决办法
握手一直失败,服务端无连接事件代理未转发 Upgrade 头检查 nginx/LB 配置
连接建立后 60 秒左右必断代理层 read timeout调大 proxy_read_timeout
服务端发了 Ping,客户端不回 Pong客户端 SDK 不支持 Ping/Pong改为应用层心跳
心跳正常但连接仍被清理两套心跳逻辑互相覆盖统一更新 lastSeen
连接数不断上涨重连前没有关闭旧连接检查 onclose 重连逻辑
单条消息一大就 OOMmaxPayload 没限制配置最大消息长度

5. 进阶:真实业务里的 WebSocket 避坑与升级

5.1 多实例部署时的消息广播:Redis Pub/Sub

单机 WebSocket 服务很简单,所有连接都在同一个wss.clients集合里,想广播就遍历。但生产环境为了高可用,通常会部署多台实例,前面挂负载均衡。这时候问题来了:一个连接落在 A 机器,另一个连接落在 B 机器,如果 A 要往某个房间广播,B 上的连接根本不知道。

常见解法是引入 Redis Pub/Sub。当任意实例收到业务消息后,先发布到一个 Redis 频道,所有实例订阅这个频道,再各自推送给本机维护的连接。Redis 在这里只做消息分发,不存储消息。这样做的好处是实现简单,坏处是增加一跳延迟,但通常远小于业务处理时间。

更复杂的方案是用 RabbitMQ、Kafka 或 NATS 做发布订阅,支持重投和更多路由规则。但大部分即时消息场景用 Redis Pub/Sub 就足够了。关键点是实例在订阅频道时要做全局唯一标识,防止同一条消息被自己订阅的通道又触发一次重复转发。

5.2 移动端弱网环境下的重连策略

移动端和 PC 端的最大不同在于网络状态会频繁切换:进电梯、出地铁、Wi-Fi 切流量,都会导致连接断开。重连策略“简单粗暴”的话,用户体验会非常差。

我的经验是分级处理。第一级,应用退到后台时,主动关闭 WebSocket,回到前台时重新连接,而不是让后台一直保活。Android 和 iOS 后台对长连接都不友好,保活反而耗电。第二级,重连指数退避,设置最大间隔。第三级,重连成功后,要把“断线期间错过的事件”补齐。比如聊天场景,服务端需要提供增量拉取接口,客户端重连后先拉未读消息,再走实时通道。

代码里还可以监听navigator.onLine事件。通过 Wi-Fi 切的瞬间,浏览器会触发online和offline,这时候主动触发重连,比傻等退避计时器更快。

5.3 鉴权、跨域和消息协议设计

WebSocket 鉴权是很多人忽略的事情。浏览器的new WebSocket(url)无法像 HTTP 请求那样设置自定义 Header,所以常见的做法是:

  • 把 token 放到 URL query 参数里,比如ws://example.com/ws?token=xxx。简单,但 token 会暴露在 nginx 访问日志里。
  • 把 token 放到Sec-WebSocket-Protocol子协议字段里,服务端在握手阶段解析。能躲开访问日志,但实现稍复杂。
  • 使用 Cookie 鉴权,前提是 WebSocket 握手请求会携带同域 Cookie。

服务端最终应该在upgrade事件或业务层校验 token,校验失败就返回 401 并关闭连接,不要进入connection事件。

跨域方面,WebSocket 也受同源策略影响,但握手时通过Origin头判断来源。Node.jsws库默认不校验 Origin,需要自己加:

const wss = new WebSocket.Server({ noServer: true, verifyClient: (info) => { const validOrigins = ['https://example.com']; return validOrigins.includes(info.origin); }, });

注意verifyClient在新版 ws 里不推荐,更好的做法是自己监听upgrade事件,在handleUpgrade之前做校验。不过思路一致:只允许你信任的页面来源建立连接。

消息协议设计上,我习惯每种消息带type、id、time三个字段。type决定路由,id用于请求响应配对,time用来排查延迟和时钟问题。这比只发一段裸字符串好维护得多。自定义业务关闭码也要从 4000 开始,避免和协议保留码冲突。

最后再分享一个小技巧:WebSocket 服务上线前,可以写一个自动化测试脚本,模拟“客户端连上后突然拔网线”的场景。直接拔网线不会触发close事件,这时候心跳机制如果生效,服务端就能在几十秒内把死链清掉。很多项目跑起来表面正常,恰恰就是死链清理没做好,等到高峰时才发现连接数已经爆了。这个测试花不了十分钟,但能帮你提前发现绝大多数长连接问题。

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

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

立即咨询