ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Node.js WebSocket服务端实战:从握手到心跳保活

Node.js WebSocket服务端实战:从握手到心跳保活 做WebSocket服务端我几乎都会选Node.js。这个选择不是因为跟风而是Node.js的事件驱动模型天生就是为长连接场景准备的。今天这篇教程不谈花哨的概念直接把WebSocket从协议到服务端实现、客户端接入、心跳保活、常见坑位都过一遍你照着敲一遍就能跑通跑通之后就能直接往业务里搬。WebSocket解决的是HTTP协议那个历史遗留问题请求必须由客户端发起服务端想主动推送数据非常别扭。早期靠轮询模拟效率低到难以接受。WebSocket通过一次HTTP握手建立TCP长连接之后双向通信服务端可以随时把数据推给客户端。这件事在实时推送、在线协作、聊天室、状态同步这些场景里是刚需。这篇教程适合谁适合已经会写基础JavaScript、想在自己的Node.js项目里加入实时通信能力的开发者。我不会默认你用过Socket.IO也不会默认你懂TCP握手细节但我会把关键的原理和坑讲透让你知其然也知其所以然。1. 为什么是WebSocket从轮询到长连接的思路转变1.1 HTTP轮询到底哪里不行很多刚接触实时通信的人会问一句HTTP轮询不是也能实现吗前端每2秒发一个请求问后端有没有新数据。实际做过的人都知道这个方案的代价非常夸张。假设一个在线协作文档项目100个客户端处于激活状态轮询间隔2秒那么服务端每秒就要处理50个HTTP请求。这些请求绝大部分是无意义的空轮询服务器大量的CPU和带宽都消耗在解析HTTP头、创建连接、返回空数据这些环节上。而且HTTP请求自带一堆头部信息Cookie、User-Agent、Accept这些加起来可能几百个字节100个客户端一小时的无效流量是百万字节级别。长轮询确实优化了一些问题服务端把请求挂起有数据才返回没有数据就一直挂着客户端收到响应后再发起下一个请求。但长轮询的连接是伪造的服务端挂起请求会占用宝贵的文件描述符和内存而且消息顺序、重连逻辑都要自己处理复杂度并没有比WebSocket低多少。WebSocket的做法完全不同。它借用了HTTP的101状态码完成一次握手TCP连接建立后双方在同一个连接上进行双向帧传输。这个连接一旦建立服务端推送的每一帧数据都不再带HTTP头只有几字节的帧头流量开销低到可忽略在线人数几千时依然能维持很低的延迟。实时性从轮询的“秒级延迟”直接变成“毫秒级”。1.2 Node.js凭什么适合扛长连接选Node.js实现WebSocket核心原因是它的I/O模型和长连接场景高度契合。Node.js基于事件循环和单线程异步非阻塞I/O一个进程可以同时维护几十万个TCP连接而每个连接只占用很小的内存。这就像一个接线员同时接待大量电话他不用为每个电话雇一个专人而是谁说话就接谁说完就切到下一位。换成传统的多线程模型每个连接至少要占用一个线程线程的栈空间动辄几MB维护1万个连接就是10GB内存这还没算线程切换的CPU开销。Node.js配合ws库1万个连接的内存占用往往只有几百MB差距非常明显。还有一种误解需要澄清Node.js不是只能写写接口。WebSocket服务端的工作本质就是维护连接状态、收发消息、做广播这些操作基本都是I/O密集型很少有纯CPU密集计算。Node.js在这个领域不但不弱反而是最合适的选项之一。如果你之后还要对接Redis、消息队列做多机扩展Node.js生态里的异步驱动也非常成熟扩展路径很顺畅。1.3 ws库 vs Socket.IO不要一上来就选重的谈到Node.js的WebSocket方案绕不开ws和Socket.IO这两个名字。我的建议很简单如果你的业务就是需要标准WebSocket协议能和浏览器原生WebSocket、小程序、App端直接互通那老老实实用ws库。Socket.IO确实提供了很多开箱即用的能力自动重连、事件广播、房间管理、降级轮询、ack回调。但如果你的服务端和客户端都是自己团队维护这些能力很多时候是用不上的反而会引入额外的协议层。Socket.IO不是标准WebSocket它的握手流程、数据封装都有自己的一套这意味着如果你想接一个用标准WebSocket写的第三方客户端或者想对接到其他语言的服务端就很容易踩协议不兼容的坑。ws库的目标简单纯粹实现标准RFC 6455协议暴露一个干净的事件接口。它的性能在开源社区里是公认的第一梯队而且零依赖、文档清晰。下面的示例我全部基于ws库实现这足以覆盖绝大多数实时场景。真到了需要自动重连、分布式房间这样的高级需求也可以在ws库之上自己封装这比一开始被Socket.IO的复杂API绑架要舒服得多。2. 环境准备Node.js安装与工程初始化2.1 怎么确认自己有没有装Node.js开始写代码之前先确认装好环境。在命令行窗口里执行这条命令node -v如果显示了v20.x.x或v22.x.x这样的版本号说明Node.js已经安装好了。如果提示node: command not found那就是没装或者没把可执行文件加进系统PATH。还有一个细节经常被忽略检查npm是否可用。npm -vnpm是Node.js自带的包管理器装依赖、启动脚本都要靠它。如果node能跑但npm报错通常是因为安装包损坏或者环境变量配置异常这种情况建议直接修复安装别自己手动折腾。2.2 版本选择与安装报错避坑Node.js版本有个特别容易踩的坑。网上不少教程会给你一串下载链接告诉你最新版多好多好很多人兴致勃勃装了最新版结果第二天某个老项目跑不起来。原因很简单Node.js版本迭代非常快一些依赖原生的模块比如node-gyp编译的包需要匹配Node.js的ABI版本跨大版本兼容往往有问题。我个人建议是下载官网的LTS版本偶数版本比如当前主流的v20 LTS或者v22 LTS。LTS全称Long Term Support意味着这个版本会持续接收安全补丁稳定周期很长。生产环境里稳定比新特性值钱得多。再讲一个版本安装的经典报错。我在用nvmNode Version Manager用它可以在同一台机器上按项目切换Node.js版本安装新版本时遇到过这样的提示error installing 24.21.0: node.js v24.21.0 is not yet released or is not available for arm64这个报错看起来吓人其实就是说你想装的这个版本号官方压根还没发布或者你的CPU架构没对应的二进制包。nvm的版本列表是从远程源同步的如果你本地缓存的版本列表太旧或者手动拼了一个未来版本号就会触发这类提示。解决方案很简单# 先更新nvm的版本列表 nvm ls-remote # 再安装你需要的版本 nvm install v22.14.0 nvm use v22.14.0这里特别提醒安装Node.js的时候别用那种“全家桶”包装工具也别在系统目录里乱改权限。最干净的方案就是nvmmacOS/Linux或者nvm-windows。用系统自带的包管理器装虽然方便但后续想切换版本、想在不同项目里用不同Node版本时会非常痛苦。2.3 初始化项目并安装ws依赖环境OK之后建一个工作目录初始化项目。我习惯先用以下命令创建目录mkdir ws-tutorial cd ws-tutorial npm init -ynpm init -y会快速生成一个默认的package.json省的在一堆交互提示里按回车。接着安装ws库npm install ws安装完成后package.json的dependencies字段里会出现ws: ^8.x.x。ws库到现在已经到8.x大版本API很稳定。顺便说一下如果你想在TypeScript项目里用ws还需要装一下类型定义npm install -D types/ws这样编辑器就能给你完整的类型提示写起来舒服很多。我再说个检查安装是否成功的小技巧。node_modules目录要是太大了想确认某个包装没装、装的是什么版本可以执行npm ls ws这条命令只显示ws包的信息如果出现ws8.18.0之类的输出说明依赖树里确实有ws。这个习惯能帮你省下很多排查依赖问题的工夫。3. 服务端核心实现从握手到消息广播3.1 最小可运行的WebSocket服务端新建一个server.js先写一个基础服务端const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); wss.on(connection, (ws, req) { console.log(新连接建立来源: ${req.socket.remoteAddress}); ws.on(message, (data, isBinary) { const message isBinary ? data : data.toString(); console.log(收到消息:, message); // 回声把收到的消息原样返回给客户端 ws.send(服务器收到: ${message}); }); ws.on(close, () { console.log(连接已关闭); }); ws.on(error, (err) { console.error(连接出错:, err.message); }); // 连接建立后立刻发一条欢迎消息 ws.send(欢迎连接到WebSocket服务器); }); console.log(WebSocket服务端运行在 ws://localhost:8080);启动服务node server.js这段代码做了几件事在8080端口创建WebSocket服务器、监听连接、处理消息、发送回声。注意req.socket.remoteAddress可以拿到客户端IP这在做日志和限流时比较有用。这里要解释一个重要机制WebSocket不是凭空出现的长连接它先通过普通HTTP请求进行一次“升级握手”。服务端看到请求头里有Upgrade: websocket就知道客户端想升级协议。如果你的端口既跑HTTP接口又跑WebSocket要注意这个细节不能让中间件把握手请求劫持了。3.2 连接生命周期管理上线、下线、断线做真正的业务绝不能只在connection回调里发发消息。你需要管理每个连接的状态至少要知道当前在线多少人连接断开时能不能正确清理资源。ws库提供了一个简单好用的数据结构在连接对象上挂载自定义属性。const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); // 用一个Map维护当前在线客户端key可以是用户ID或自定义连接ID const clients new Map(); function generateClientId() { return Math.random().toString(36).slice(2, 10); } wss.on(connection, (ws, req) { const clientId generateClientId(); clients.set(clientId, ws); console.log(新连接 ${clientId}当前在线数: ${clients.size}); ws.on(message, (data) { const message data.toString(); console.log([${clientId}] 发送消息: ${message}); try { const parsed JSON.parse(message); if (parsed.type ping) { ws.send(JSON.stringify({ type: pong, time: Date.now() })); } } catch { // 不是JSON消息按原始文本处理 ws.send(服务器收到: ${message}); } }); ws.on(close, () { clients.delete(clientId); console.log(连接 ${clientId} 关闭当前在线数: ${clients.size}); }); ws.on(error, (err) { console.error(连接 ${clientId} 出错:, err.message); clients.delete(clientId); }); ws.send(JSON.stringify({ type: welcome, clientId })); });在message处理里我用了一个非常常用的模式消息约定为JSON结构通过type字段区分业务类型。这个约定要尽早定下来因为后续每一个业务动作——登录、发消息、心跳、加入房间——都需要一个明确的消息协议。你可能注意到了error回调里我也做了清理。实际线上环境有个教训如果只监听close不监听error某些异常断开时close事件可能不触发连接对象就一直残留在Map里越积越多最后变成内存泄漏。所以error和close两个回调里都要清理资源。3.3 心跳机制让假连接无所遁形这是WebSocket实践里最值得讲透的一个点。TCP连接有一层超时机制叫keepalive但它默认是关闭的而且超时时间很长不能完全依赖它。WebSocket协议本身也有ping/pong帧ws库直接暴露了这两个方法。为什么要心跳因为网络环境复杂移动端切换WiFi、上班路上进了地铁隧道、笔记本盒盖再打开这些场景下TCP连接往往已经断了但两端都不知道。如果你不做任何保活服务端的连接对象会一直占用资源客户端的WebSocket也很长时间感知不到异常。用一句话概括心跳机制不是给活人做体检是给“尸体”开死亡证明。在ws库中服务端需要主动做两件事定时发送ping帧检测客户端是否还活着对连续多次没响应的客户端执行closeconst WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); // 默认心跳间隔与超时阈值单位都是毫秒 const HEARTBEAT_INTERVAL 30000; const HEARTBEAT_TIMEOUT 60000; const clients new Map(); wss.on(connection, (ws) { ws.isAlive true; ws.on(pong, () { ws.isAlive true; }); ws.on(message, (data) { ws.isAlive true; }); ws.on(close, () { clients.delete(ws); }); clients.set(ws, Date.now()); }); const heartbeatTimer setInterval(() { wss.clients.forEach((ws) { if (ws.isAlive false) { ws.terminate(); return; } ws.isAlive false; ws.ping(); }); }, HEARTBEAT_INTERVAL); wss.on(close, () { clearInterval(heartbeatTimer); });这段代码的运作逻辑是这样的每30秒服务端把所有连接标记为“怀疑死亡”然后逐个发送ping帧。如果客户端是活着的它必须回一个pong帧pong回调里把isAlive重新置true。到了下一个30秒周期如果某个连接的isAlive还是false说明它上一轮没回应ping服务端就调用terminate()直接杀掉这个僵死连接。注意选择terminate()还是close()。close()是四次握手式的优雅关闭会等待缓冲区里的数据发完terminate()是立刻销毁底层socket。对付已经疑似死亡的连接用terminate()更果断因为优雅关闭可能因为对端无响应而卡住。浏览器原生WebSocket会自动响应ping帧所以你不需要在浏览器端写额外的pong处理逻辑。但如果你自己写客户端比如Node.js脚本、小程序端、IoT设备端一定要记得实现自动pong否则服务端会误杀。很多自研客户端踩过这个坑服务端一直ping客户端不回应30秒后连接被强制断开。3.4 消息广播和简易房间很多实时业务不只是点对点通信还需要群发。最简单的广播就是把消息发给所有在线连接function broadcast(message) { const data JSON.stringify(message); wss.clients.forEach((client) { if (client.readyState WebSocket.OPEN) { client.send(data); } }); }这里的readyState WebSocket.OPEN检查很重要。ws库的客户端会有几种状态CONNECTING、OPEN、CLOSING、CLOSED。如果你试图对一个CLOSING或CLOSED状态的连接send会抛异常。写库代码时任何send之前都应该先检查状态。这个习惯救过我好几次。要做房间隔离也很简单。在连接上记录房间号广播时按房间过滤const rooms new Map(); // roomId - Set(ws) function joinRoom(ws, roomId) { if (!rooms.has(roomId)) { rooms.set(roomId, new Set()); } rooms.get(roomId).add(ws); ws.roomId roomId; } function broadcastToRoom(roomId, message) { const roomClients rooms.get(roomId); if (!roomClients) return; const data JSON.stringify(message); roomClients.forEach((client) { if (client.readyState WebSocket.OPEN) { client.send(data); } }); }房间模式在聊天室、游戏房间、客服分配场景里都很实用。再往后业务规模变大多台服务器做集群本地Map存房间就不行了得把房间成员状态挪到Redis里这个我后面讲扩展时会提到。4. 客户端接入浏览器到React实战场景4.1 浏览器原生WebSocket客户端服务端写好了客户端反而更简单。浏览器原生构造WebSocket的语法非常直白const socket new WebSocket(ws://localhost:8080); socket.addEventListener(open, () { console.log(连接已建立); socket.send(JSON.stringify({ type: chat, content: Hello })); }); socket.addEventListener(message, (event) { console.log(收到消息:, event.data); }); socket.addEventListener(close, () { console.log(连接已关闭); }); socket.addEventListener(error, (error) { console.error(连接出错, error); });注意message事件里的event.data有可能是Blob类型默认情况下ws库发送的字符串消息在浏览器端通常是文本。处理时可以先判断类型再转换稳妥一些socket.addEventListener(message, async (event) { if (typeof event.data string) { console.log(event.data); } else { const text await event.data.text(); console.log(text); } });一个高频坑WebSocket的send方法支持字符串和ArrayBuffer但不支持直接传JSON对象。很多新手把对象直接丢进去结果客户端报错。正确的做法永远是JSON.stringify后再send。4.2 React场景实时推送文件变化和SSE的取舍React项目里接入WebSocket常见的诉求是感知服务端文件或数据变化并实时刷新页面。开发场景里最常见的例子是服务端生成了一份报表、上传了一个文件、ESLint扫描完一堆代码React前端要立刻弹出一条通知。如果只在开发调试阶段用Vite和WebpackDevServer其实内部就是用WebSocket实现热更新推送的。但如果你在自己的业务代码里要对“文件或数据变化”做实时响应有两个技术方向SSEServer-Sent Events和WebSocket。SSE是HTTP协议上的单向推送只能服务端到客户端实现非常简单甚至不用额外引入库。但SSE有一个先天的局限性它是基于HTTP的流式响应连接会自动重连但客户端无法向服务端主动发信息。如果只是服务端单方向推送“文件A更新了”“数据B变了”SSE是更轻的选择。反过来如果你的界面需要同时上报操作状态比如文件解析进度、用户编辑光标位置又或者需要双向频繁交互那就用WebSocket。我平时判断标准很简单需求是单向通知就SSE需要对话式的你来我往就WebSocket不要把简单的事情搞复杂。下面给个React里封装WebSocketHook的简单示例import { useEffect, useRef, useState } from react; function useWebSocket(url) { const socketRef useRef(null); const [message, setMessage] useState(null); const [connected, setConnected] useState(false); useEffect(() { const socket new WebSocket(url); socketRef.current socket; socket.addEventListener(open, () setConnected(true)); socket.addEventListener(close, () setConnected(false)); socket.addEventListener(message, (event) { setMessage(event.data); }); return () { socket.close(); }; }, [url]); const send (data) { if (socketRef.current?.readyState WebSocket.OPEN) { socketRef.current.send(JSON.stringify(data)); } }; return { connected, message, send }; }需要注意的问题在React 18的StrictMode下useEffect会执行两次。如果直接用上面的代码开发环境会出现“连上又断开又连上”的现象。这是因为严格模式下组件挂载→卸载→重新挂载WebSocket连接也被创建→关闭→创建。解决方法是把create通道放进ref或者干脆用带清理的useEffect确保每次重新挂载时旧连接一定被close掉。上面的代码里cleanup函数已经做了这件事可以放心用。4.3 轮询与WebSocket的混用实践有一种被问到很多次的做法Reac SSE/WebSocket轮询文件变化。有些团队为了省事依然用定时器去轮询接口比如每5秒fetch一次看文件更新了没有。说实话如果只是监控一个低频变化的小文件轮询确实够用实现成本极低。但轮询的浪费是肉眼可见的。比如你每3秒请求一次状态接口在线用户一多服务端压力马上上来。更合理的方式是用WebSocket建立长连接服务端在文件或数据真正变化时才推送事件给你前端收到事件后再去拉取具体数据。这就把“主动问”变成了“被动听”。我把这套逻辑拆开写一下客户端建立WebSocket连接向服务端注册一个主题比如watch: file-report-2025-01服务端维护文件或数据的指纹比如mtime size或者内容的hash服务端用fs.watch或者轮询文件系统拿到变更事件一旦检测到变化服务端通过WebSocket推送{ type: file-changed, path }客户端收到推送后调用普通HTTP接口拉取最新内容这种混合模式的好处是不需要每隔几秒打一次HTTP请求同时客户端拿到最新数据的逻辑依然简单。如果你现在维护的老项目里已经有大量轮询代码也可以渐进式改造不必一刀切。5. 常见问题排查连接不上、收不到消息、版本报错5.1 连接却不接受信息多半是这三个原因“WebSocket连接建立成功了但消息发不过去”或者“连接建立了服务端就是不回消息”这类问题非常常见而且原因高度集中在三个地方。第一个原因消息格式不对。客户端发送的是JSON对象服务端拿到的是字符串如果服务端强行JSON.parse一旦字符串不符合JSON语法解析就会抛异常。我在服务端示例里特意用了try/catch包住JSON.parse就是为了避免单个连接的消息格式错误把整个服务进程炸掉。第二个原因发送时机不对。很多场景是客户端代码先执行了socket.send(...)但这时候WebSocket的readyState还是CONNECTING消息根本没发出去。浏览器端的send对CONNECTING状态的调用会直接抛异常。正确写法是等open事件触发后再发送或者像上面的React Hook一样先检查readyState WebSocket.OPEN。第三个原因消息拥堵导致背压。服务端发了大量消息网络传输跟不上ws库内部会出现缓冲。如果还继续疯狂向同一个连接send内存会不断上涨连接Eventually表现异常。表面现象就是“消息发不过去”。解决办法是监听缓冲区的状态或者发送时控制频率。ws库提供ws.bufferedAmount属性客户端和服务端都有。这三个可以做成一个排查顺序表供你应急现象优先排查方向验证方法客户端能连上服务端console没有日志消息根本没有到达服务端客户端检查readyState看看是否在open之前就send了服务端有日志客户端收不到回复服务端send后没有判断readyState在服务端send前打印readyStateCLOSING/CLOSED时不要send连接时断时续且不定时心跳机制误杀或网络不稳定在客户端加日志打印close事件和code特别是1006异常码消息偶尔丢失业务层没有实现ack确认给每条消息加唯一id客户端收到回复后主动告知服务端5.2 安装Node.js和ws依赖时的经典报错网上热词里有一个非常典型的报错我前面已经讲过一半error installing 24.21.0: node.js v24.21.0 is not yet released or is not available for arm64这个报错不只出现在nvm的场景里有时你在一些官方下载页或者包管理器里输入一个“听说很新”的版本号也会遇到类似提示。原因是版本号对应版本的二进制包还没有构建完成或者没有适配你的CPU架构。这种情况不要硬装往下选一个已经发布的最高稳定版本就好了。再有一个高频报错是安装ws时碰到的可能不太常见但我见过不少npm ERR! code ETARGET npm ERR! No matching version found for ws^9.0.0这个报错说明你在某个依赖里要求了ws库并不存在的版本范围。ws库目前主要是8.x版本某些老项目的锁文件或者传递依赖想装一个9.x自然找不到。遇到ETARGET、ERESOLVE这类npm报错先查一下依赖树的冲突来源别急着清缓存。顺便说一下官方下载安装的另一个容易踩的坑官网下载的Windows安装包装完以后经常出现“node命令找不到”的情况。这不是没装好是安装的时候没有勾选“Add to PATH”。解决方法是打开系统环境变量手动把Node.js的安装目录默认是C:\Program Files\nodejs\加到Path里。装完记得重启一个新的终端窗口不要用之前已经开着的那个否则环境变量不会刷新。5.3 高并发下的优化方向最后聊一下WebSocket服务端上生产前要做的几件事。第一不要用单进程硬扛。Node.js默认是单线程的多核优势完全没利用。用cluster模块或者PM2的cluster模式按CPU核心数拉起多个进程每个进程维护一部分连接。但这会带来一个问题一个进程里的WebSocket消息另一个进程里的客户端收不到。解决办法是引入Redis的pub/sub所有进程订阅同一个频道任意进程收到消息就publish到频道其他进程再推送给各自维护的连接。这样整个集群就是一个逻辑上的整体。第二注意WebSocket代理层的配置。如果你用Nginx做了反向代理一定要设置对升级请求的转发location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; }这里的关键就是Connection: upgrade少了这个头Nginx会把WebSocket握手请求当成普通HTTP请求处理不会做协议升级客户端就会一直握手失败。第三给业务消息加一层ack机制。WebSocket协议本身没有确认机制消息发出去了你不知道对方有没有收到。对关键消息比如支付结果、订单状态一定要在业务层做确认号客户端收到消息后回一个ack服务端超时未收到ack再做重推。很多线上事故都是“消息丢了但没人知道”造成的WebSocket虽然快也不等于可靠。6. 写在最后的实践经验这篇教程从协议原理一直写到了高并发扩展基本覆盖了WebSocket在Node.js生态里的完整落地路径。最后分享几个我在实际项目中反复验证过的经验。第一连接管理一定要从第一天就做规范。哪怕是写Demo也要把连接的唯一ID、上线时间、来源IP记下来因为这些东西上线后会成为排查问题的基础数据。第二所有服务端send之前必须检查readyState这个习惯能帮你挡掉一大半偶发报错。第三不要盲目追求最新版本的Node.js和ws库。生产环境里LTS版本加稳定的ws 8.x比什么都踏实。如果你准备把WebSocket接入现有业务我的建议是先跑通最小Demo确认连接、消息、心跳三条链路都正常再逐步加入鉴权、广播和房间等复杂逻辑。WebSocket本身不复杂复杂的是你围绕它建立的业务规则。把协议层和业务层分开你的代码会好维护很多。
返回列表