ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebSocket实战:从HTTP轮询到心跳机制的连接管理全解析

WebSocket实战:从HTTP轮询到心跳机制的连接管理全解析 1. 为什么要用WebSocket从一次HTTP轮询事故说起先讲我自己的经历。几年前做过一个物联网状态看板项目设备每隔几秒上报一次温度、湿度、电压数据前端要实时刷新曲线。当年图省事直接用了HTTP短轮询前端定时器每3秒打一次接口拉数据。开发阶段跑得很欢因为本地就几台设备。结果一上线接了200多个设备服务器瞬间被打懵了——每3秒一次光查询请求就占掉了数据库连接的70%CPU占用率飙到85%页面还经常因为请求堆积出现数据断层。当时我才真正意识到这种客户端反复问、服务端反复答的模式在实时通信场景下有多浪费。你想想客户端每个轮询请求都要走完整的HTTP流程建立连接、带一堆Header、服务端响应、断开连接。哪怕你发的只是一句有没有新数据也要支付同等开销。更尴尬的是HTTP是无状态的服务端根本不知道客户端想要持续接收所以哪怕有新数据也要等客户端下一次主动来问。数据晚3秒到那就得接受3秒延迟要想延迟更低就得增加轮询频率服务器压力又成倍上涨。长轮询Long Polling算是对短轮询的一种妥协服务端收到请求后先挂起等有新数据才返回或者超时后返回空。这样能减少请求次数但本质上依然是客户端发起-服务端响应这种一问一答模型。而且长轮询在代理层、网关层容易积累悬空连接连接一多Nginx或者负载均衡器就开始拖慢老玩家应该都摸到过这层底。这时候WebSocket才是正面答案。它通过一次HTTP握手完成协议升级之后就变成一条客户端和服务端之间的全双工TCP通道。服务端有数据了直接往通道里推客户端不用轮询客户端想发消息顺着同一条通道直接发不需要每次都重新握一次手。这个设计解决的痛点非常明确延迟降到毫秒级单连接承载双向消息服务端压力从N个客户端 × 每秒N次轮询变成连接数 × 实际业务消息量。所以做实时消息推送、多人协作、在线聊天、游戏同步、行情刷新这类场景WebSocket差不多是绕不开的基础设施。这篇文章是系列第一篇我会把WebSocket从协议原理到心跳机制完整拆开讲一遍配合可落地的代码示例争取让没接触过的人看完能自己搭一个稳定的连接服务。2. WebSocket连接从无到有的底层细节2.1 握手过程怎么从HTTP优雅升级成WebSocketWebSocket的起点不是凭空捏造一条TCP连接而是借道HTTP完成一次协议升级。所以第一个包依然是HTTP请求长相大概是这样GET /ws HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13关键的几行在于Upgrade: websocket和Connection: Upgrade这一对Header告诉服务端我想把当前这条HTTP连接升级成WebSocket。Sec-WebSocket-Key是一段随机Base64字符串服务端拿到后会拼接一个固定的GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11再做SHA-1哈希最后Base64编码返回HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo服务端返回101状态码意味着协议切换成功。这个设计有一个很实际的意义WebSocket完全兼容HTTP的握手流程能跑在80/443端口上也能被Web服务器、负载均衡器识别和转发。换句话说它在出门的时候穿着HTTP的外衣过了安检之后才换成自己的协议。我以前刚接触WebSocket的时候其实有个误区以为握手之后就和HTTP彻底没关系了。后来抓包才发现握手完成之后TCP底层的那条连接直接被WebSocket接管HTTP的Keep-Alive、Chunked那些机制不再适用。这也带来一个实际后果如果中间有代理服务器不理解WebSocket协议仅支持普通HTTP转发那么握手请求会被当作一个普通GET请求处理返回200而不是101客户端就会直接报连接失败。2.2 数据帧结构四个bit决定了消息的命运WebSocket握手完成后双方发送的就都是WebSocket帧了。帧的结构不算复杂但是每一块都有讲究。首字节拆开来看FINbit 0表示当前帧是不是消息的最后一帧。一条WebSocket消息可能被拆成多个帧发送FIN为1表示消息到此结束。RSV1到RSV3bit 1-3正常情况下都是0只有扩展协商过才可能非0。opcodebit 4-7表示帧类型。0x1是文本帧0x2是二进制帧0x8是关闭帧0x9是ping帧0xA是pong帧。第二字节里最高位是MASK表示是否使用掩码。这里有一个WebSocket独有的规则客户端发给服务端的帧必须设置掩码服务端发客户端的帧可以不设。当初协议这么定主要是防缓存污染攻击——浏览器和客户端设备可能被诱导发一些恶意数据到脆弱的中间缓存节点带掩码可以降低这类风险。服务端在解析客户端消息时如果发现MASK没置位按规范是应该直接报协议错误关连接的我在实际落地时也遇到过某些第三方客户端SDK忘了掩码导致服务端强行断开的情况。接下来是扩展长度。当原始长度小于126时直接用帧头第2字节的7位表示126到65535之间用126标记并追加两个字节的16位无符号长度超过65535用127标记并追加8字节的64位长度。这个变长设计是为了让短消息尽可能省开销长消息又能放得下。正文数据被掩码处理过的话紧随其后还有4字节的Masking Key之后才是真正的Payload Data。写WebSocket服务的底层解析器时这几个边界条件最容易翻车尤其是一个TCP包里正好粘了多条WebSocket帧、或者半条帧的情况处理不好就会出现消息拼接错乱。实际写业务代码通常不需要自己解析到这一层但理解帧结构对排查问题非常重要。2.3 连接生命周期从握手、数据传输到关闭一条WebSocket连接的完整生命周期分三阶段握手、数据传输、关闭。握手阶段就是前面说的HTTP Upgrade过程服务端确认客户端支持WebSocket协议后返回101。这里业务上经常忽略的一件事是握手时服务端可以通过Cookie、Token或自定义Header来做身份认证。因为握手本质上还是一个HTTP请求所以你可以顺手把登录态校验在这一个环节做掉鉴权失败直接返回401/403根本不给它升级到WebSocket通道的机会。数据传输阶段客户端和服务端可以随时向对端发消息。帧的发送方向是双向互相独立的这也是全双工的含义。但要注意WebSocket本身并不保证消息到达顺序之外的事务语义比如客户端发A消息、服务端回B消息之间没有天然的请求-响应关联。如果你需要类似HTTP那种发一个请求、等一个对应响应的机制就得自己在业务层设计消息ID去配对。关闭阶段任何一方想断开连接时会主动发送一个关闭帧opcode为0x8里面可以带一个状态码和原因字符串。常见状态码有1000正常关闭、1001客户端离线、1006非正常关闭通常底层TCP断开了、1011服务端异常。比较坑的地方在于如果对端因为网络原因直接消失关闭帧根本发不出来连接会卡在半开状态。这个状态靠常规的读/写操作很难立刻感知所以才需要后面专门说的心跳机制来兜底。3. 从零搭建一个WebSocket服务端与客户端3.1 技术选型为什么我两次选库都踩了坑WebSocket服务端实现的语言选择很多Node.js生态里最常用的是wsJava阵营有Netty或者Spring WebSocketGo有gorilla/websocket。这篇文章我用Node.js的ws库做示例原因很简单ws是npm上WebSocket实现的元老级选择API稳定底层是纯JS实现几乎零依赖而且同时支持服务端和客户端调试起来非常顺。我第一次做WebSocket选型时图方便直接在某个框架自带的路由方法里写了WebSocket接口结果踩了两个坑第一框架内部对升级握手有自己的时间窗口限制一旦业务代码在握手回调里做了长时间数据库查询握手就超时断开第二框架封装的消息对象和原生WebSocket消息格式有出入后来排查消息粘包时发现自己不得不去翻框架源码才能确认底层处理逻辑。所以后来我养成了习惯WebSocket服务尽量用单独的库或单独的服务实例承载不要和普通HTTP业务混在一个端口上解决所有事情。ws库的安装一句话npm install ws我在实际项目里推荐用node原生环境跑不需要Webpack这类构建工具调试起来少一层干扰。3.2 服务端连接管理与消息转发先写一个最原始的服务端const { WebSocketServer } require(ws); const wss new WebSocketServer({ port: 8080 }); wss.on(connection, (ws, req) { const clientIp req.socket.remoteAddress; console.log([连接] 客户端接入: ${clientIp}); ws.on(message, (data, isBinary) { const text data.toString(); console.log([收到] ${text}); // 原样回显方便联调 ws.send(服务端已收到: ${text}); }); ws.on(close, () { console.log([断开] 客户端离开: ${clientIp}); }); ws.on(error, (err) { console.error([异常] ${err.message}); }); });这段代码最核心的部分是connection事件里的ws对象。它是服务端视角下的单条WebSocket连接支持send()方法向客户端推消息支持监听message、close、error事件。message事件里data可能有两种形态如果客户端发的是二进制数据isBinary会为true这里统一转成字符串展示。真实业务场景里你大概率不会只做回显而是要管理一群连接。这里介绍一个我比较惯用的做法用一个Map保存当前在线连接key是用户IDvalue是连接实例。const clients new Map(); wss.on(connection, (ws, req) { // 解析URL上的userId实际项目建议换成Token鉴权 const userId new URL(req.url, http://localhost).searchParams.get(userId); clients.set(userId, ws); ws.on(message, (data) { // 业务上可以按消息类型路由处理 const msg JSON.parse(data.toString()); handleMessage(userId, msg); }); ws.on(close, () { clients.delete(userId); }); ws.on(error, () { clients.delete(userId); }); }); function sendToUser(targetUserId, payload) { const target clients.get(targetUserId); if (target target.readyState 1) { target.send(JSON.stringify(payload)); } }这里有个容易出问题的细节监听close事件时回调里不能直接依赖ws.on(message)闭包里的某些变量做复杂异步操作。因为close事件触发时连接可能已经处于销毁状态如果此时再判断readyState甚至调用send()会直接抛错或静默失败。所以我在删除连接时会把userId提前在connection外层存好避免去读已经销毁的上下文。关于readyStateWebSocket实例有几种状态0代表正在连接1代表已打开可传输数据2代表正在关闭3代表已关闭。往一个非1状态的连接调send()会抛异常所以代码里判断readyState 1是必要的这也是很多线上cannot send message报错的根源。3.3 浏览器客户端一行代码建立连接浏览器端的WebSocket API是原生内置的不需要额外引入库。建立连接并收发消息的代码大概这样const socket new WebSocket(ws://localhost:8080); socket.addEventListener(open, () { console.log(连接已建立); socket.send(JSON.stringify({ type: login, userId: 10086 })); }); socket.addEventListener(message, (event) { const data JSON.parse(event.data); console.log(收到服务端消息:, data); renderMessage(data); }); socket.addEventListener(close, (event) { console.log(连接关闭码值:, event.code); // 在这里可以做重连逻辑 }); socket.addEventListener(error, (error) { console.error(连接异常:, error); });new WebSocket(url)之后连接是异步建立的所以要通过open事件确认通道可用。message事件拿到的是MessageEvent真正的数据在event.data里。data通常是字符串但也可以拿到Blob这取决于服务端发来的二进制帧怎么被浏览器解析。浏览器端最大的坑就是没有拿到WebSocket握手响应时的错误处理。比如你在前端配了个wss://地址但服务端其实只是普通的ws://浏览器会直接报一个混合内容错误或者握手失败这个错误经常出现在error事件里但不会带很详细的说明。我建议在开发阶段用Chrome DevTools的Network面板去看WebSocket帧的收发情况比纯靠控制台日志高效得多。另外提一下心跳机制必然要考虑的一个点浏览器端的WebSocket如果是纯依赖TCP底层检测对端掉线那是等不来的。TCP探活默认要等很长一段时间而且中间一旦有NAT或者代理底层连接可能早就被中间设备清了。所以无论客户端还是服务端都必须主动加心跳。3.4 消息协议设计别等上线了再后悔很多入门教程只教你WebSocket怎么发字符串不讲消息协议设计但我必须说这点非常重要。一个实时系统上线后前端和后端各自维护一套消息格式如果没有一个明确的规范很快会变成一场灾难。我在项目里通常的消息结构是这样的{ type: message, id: uuid-123, timestamp: 1699999999999, data: { } }type消息类型比如login、heartbeat、chat、ack决定了业务怎么路由。id消息唯一标识用来做请求-响应配对和去重。timestamp客户端或服务端时间戳排问题的时候能省很多力气。data真正的业务数据。为什么要加id因为WebSocket没有天然的请求-响应匹配如果你发了一条查询指令服务端在处理完后要把结果推回来客户端怎么知道这条结果对应哪条指令最靠谱的方式就是带一个唯一id。我在做设备控制时用户点一下开灯客户端生成一个id服务端回复时带上同一个id客户端就可以在这一瞬间判定这条回复是对应开灯的从而结束等待状态否则用户连续点了五次开灯回来三条结果界面就乱了。消息解析还有一个意想不到的坑文本消息可能不是一个完整的JSON。比如某些业务在发送长文本时会被WebSocket自动分帧虽然ws库在message事件聚合帧后一般会把完整内容给你但如果你用的是更底层的库就可能有分片问题。稳妥做法是服务端拿到字符串后先try/catch一遍JSON.parse解析失败不能直接崩掉要记录日志并忽略。4. 心跳机制设计让死连接现出原形4.1 先说清楚谁断了谁知道麻烦就麻烦在谁都没感知心跳机制之所以是WebSocket实践里的高频词是因为网络环境里存在大量半开连接状态。什么叫半开就是TCP连接表面上还存在实际上中间某一层的设备早就把这条链路丢了。最常见的情况是手机切换WiFi、路由器重启、NAT超时、运营商清理空闲会话等原因导致客户端和服务端之间再也收不到任何数据包但两边都以为连接还活着。服务端如果不知道一条连接已经死了就会一直保留这个连接对象占着内存、占着文件描述符未来当服务端想往这条连接推送消息时会一直把数据写到早已失效的TCP通道里触发超时重传浪费带宽。客户端如果不知道服务端已经死了就会一直等待服务端消息用户看到的界面是一切正常实际上早就收不到任何更新了。解决半开连接的标准答案就是设计一层应用层探活定期在WebSocket通道上发送一个心跳包对端如果在指定时间内没有响应就判定连接已死主动关闭并清理资源。4.2 心跳方案对比ping/pong和自定义业务心跳WebSocket协议原生支持ping帧和pong帧。服务端可以主动发ping客户端收到后自动回一个pong。浏览器端的WebSocketAPI并没有暴露手动发ping的方法但浏览器会自动响应服务端发来的ping帧。在Node.js的ws库中你会发现服务端发ping是原生支持ws.ping();ws库内部收到pong后会自动触发ws.on(pong)事件所以很多方案就是基于这个机制做的服务端定时给所有连接发ping统计哪些连接在超时时间内没回pong然后清理掉。除协议层心跳外另一套常用方案是应用层心跳双方约定一种消息结构比如{type: heartbeat}客户端定期发送服务端收到后回复或记录时间。应用层心跳的好处是消息内容可控能在同一个通道里附带其他状态信息坏处是这部分逻辑完全自己实现容易跟业务消息混在一起处理不当会影响业务。这两套方案我都用过。协议层ping/pong更省心因为ws库已经把帧处理和回包处理都做完了。但要注意的是如果客户端是基于原生浏览器API当服务端发来ping时浏览器会自动回pong这个逻辑开发者不需要写但如果你的客户端是一个自研的轻量SDK且用的不是ws库你得确保它实现了协议层pong帧的回包逻辑。我在实际项目中通常采用双保险服务端用协议层ping/pong做粗粒度连接存活检测同时业务层保留一两个自定义心跳消息用来感知应用是否还活着。所谓应用是否还活着就是区分TCP链路通不通和你的应用逻辑卡没卡死之间的差别。有些场景服务端事件循环被一个死循环卡住TCP层和WebSocket层的ping/pong还能正常回包但你发业务消息它就不处理了。这属于业务健康检查范畴看需求取舍。4.3 完整落地代码服务端连接超时驱逐下面给出一份我整理过多次的服务端心跳代码基于ws库实现。核心思路是每个连接维护两个时间戳一个是最近一次收到pong的时间一个是最近一次收到任何消息的时间。定时器每30秒扫一遍连接发现超时就用ws.terminate()暴力断开。const { WebSocketServer } require(ws); const wss new WebSocketServer({ port: 8080 }); // 心跳检测间隔与超时阈值 const HEARTBEAT_INTERVAL 30000; const HEARTBEAT_TIMEOUT 10000; wss.on(connection, (ws, req) { ws.isAlive true; ws.lastPongAt Date.now(); ws.on(pong, () { ws.isAlive true; ws.lastPongAt Date.now(); }); ws.on(message, (data) { // 收到业务消息也更新一下存活时间避免心跳没回来但业务正常却被误杀 ws.lastPongAt Date.now(); // ...业务处理 }); ws.on(close, () { clearInterval(ws.heartbeatTimer); }); ws.send(欢迎连接); }); const heartbeatTimer setInterval(() { const now Date.now(); wss.clients.forEach((ws) { if (!ws.isAlive) { console.log([心跳] 连接 ${ws._userId || unknown} 已超时强制断开); ws.terminate(); return; } ws.isAlive false; ws.ping(); }); }, HEARTBEAT_INTERVAL); wss.on(close, () { clearInterval(heartbeatTimer); });这份代码的核心逻辑是定时器每30秒把所有连接标记为isAlive false然后逐个发ping。如果对端响应了pongpong事件里会把它改回true。下一次定时器再扫的时候如果isAlive还是false说明上一轮发的ping没收到回包连接大概率已经死了就直接terminate()。terminate()和close()在我这里的区别是close()会尝试发送关闭帧走正常关闭流程terminate()则是直接把底层TCP连接拆掉。在判定连接已死的情况下调用close()经常发不出去关闭帧因为对端已经不可达所以要terminate()才能确保资源释放。这份代码里我特意加了lastPongAt时间戳并且message事件里也刷新它。为什么因为有些客户端可能屏蔽了协议层ping/pong的自动回复但业务消息还在正常发如果只认pong就可能误杀活连接。加了业务消息时间戳之后容错性会高很多。当然这个取舍看你的场景如果需求就是只允许ping/pong来保活那可以更严格。4.4 客户端定时心跳与自动重连服务端主动心跳是保活的一半客户端主动心跳同样重要。特别是一些NAT环境下中间设备会在空闲一段时间后把连接清掉如果客户端什么都不发光等服务端ping很可能连接已经被清了还浑然不知。所以我在客户端里也会加一个定时器定期发送业务心跳或者响应服务端ping。浏览器客户端的心跳代码一般长这样class SocketClient { constructor(url) { this.url url; this.socket null; this.heartbeatTimer null; this.reconnectTimer null; this.manualClose false; this.reconnectAttempts 0; } connect() { this.manualClose false; this.socket new WebSocket(this.url); this.socket.addEventListener(open, () { console.log(连接已建立); this.reconnectAttempts 0; this.startHeartbeat(); this.sendMessage(login, { userId: user-123 }); }); this.socket.addEventListener(message, (event) { try { const msg JSON.parse(event.data); if (msg.type heartbeat_ack) { this.lastAckAt Date.now(); } this.onMessage(msg); } catch (e) { console.error(消息解析失败:, e); } }); this.socket.addEventListener(close, (event) { console.log(连接关闭code:, event.code); this.stopHeartbeat(); if (!this.manualClose) { this.scheduleReconnect(); } }); this.socket.addEventListener(error, (error) { console.error(连接异常:, error); }); } sendMessage(type, data) { if (this.socket this.socket.readyState WebSocket.OPEN) { this.socket.send(JSON.stringify({ type, id: Math.random().toString(36).slice(2), timestamp: Date.now(), data })); } else { console.warn(连接不可用消息未发送:, type); } } startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer setInterval(() { this.sendMessage(heartbeat, {}); }, 25000); } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer null; } } scheduleReconnect() { const delay Math.min(30000, 1000 * 2 ** this.reconnectAttempts); console.log(计划 ${delay}ms 后重连); this.reconnectAttempts 1; this.reconnectTimer setTimeout(() { this.connect(); }, delay); } close() { this.manualClose true; this.stopHeartbeat(); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } this.socket.close(); } } const client new SocketClient(ws://localhost:8080); client.connect();这份代码里有几个关键点。第一重连策略用了指数退避第一次重连等1秒第二次2秒第三次4秒最多30秒。为什么要这么做因为如果服务端重启客户端同时疯狂重连会把服务端冲垮退避可以给服务端恢复的时间。第二manualClose标识很关键用户主动退出时不能触发自动重连。第三心跳间隔一般是25秒服务端超时阈值是10秒这个时间差是为了保证服务端能在连接真正死掉之前观察到心跳停摆。4.5 心跳参数怎么定才合理心跳间隔和超时阈值的设置没有放之四海皆准的数字但有几个原则值得讲。第一间隔不能太短。假设你有一万台设备每10秒发一次心跳那么平均每秒有1000个心跳包如果心跳包还带业务对象的序列化对服务端和网络都是不小的开销。我做过一个设备接入项目最初心跳间隔设5秒结果一个晚上心跳消息占据了总消息量的93%很多人看到都觉得离谱。后来调整到30秒负载立刻降下来。第二间隔必须显著小于NAT或代理的空闲超时时间。很多家用路由器的NAT映射老化时间在1到5分钟如果你心跳间隔比它还长连接可能被中间设备清掉服务端还没察觉。我一般把心跳间隔控制在25到40秒之间低于30秒比较好这样无论中间的网关超时是60秒还是90秒都能赶在它清理之前刷一下存在感。第三服务端超时阈值要大于一个心跳间隔 网络抖动余量。比如客户端30秒发一次心跳服务端如果35秒没收到任何包就判定死了那只要网络有3秒延迟波动就可能误杀。我通常把超时时间设成心跳间隔的1.5到2倍。以心跳30秒为例第一次没等到心跳先不杀再等30秒攒够60秒那基本上可以断定链路出问题了。还有一点是关于多条连接的心跳扫描效率。如果连接数上万用setInterval每30秒把所有连接扫一遍每次遍历一次Map的开销也不小。我见到有人用setInterval每秒扫一遍每个连接再判断时间戳上万连接下这个操作显得有点浪费。更好的做法是维护一个按最后存活时间排序的小顶堆或者至少放宽扫描粒度30秒扫一次比1秒扫一次要轻量得多前提是你的超时阈值足够宽。5. 常见问题与避坑实录5.1 连接总是掉线但服务端无报错这类问题最常见的原因就是前面说的半开连接。现象是客户端长时间不操作过一段时间再发消息发现发不出去或者服务端根本收不到客户端的消息但两端日志里都没有异常。排查思路分三步。第一步先确认是不是NAT空闲超时导致的临时把客户端心跳间隔改短到10秒验证如果掉线频率明显下降基本坐实。第二步看服务端有没有启用协议层ping/pong如果没有在半开状态下服务端永远不会发现连接异常。第三步如果在云服务器上部署还要检查安全组、防火墙和负载均衡器的空闲连接超时配置一些云厂商的SLB默认空闲超时只有60秒超过这个时间会自动断开连接。5.2 心跳ping/pong失效的典型场景我先说一个我踩过很深的坑用了ws库的ping()也监听了pong事件但线上仍然有大量连接被误杀。后来一查是客户端从浏览器连接浏览器虽然会自动回pong但有个前提是服务端发的确实是协议层的ping帧。如果我用的是某些经过二次封装的库它可能把应用层ping当成一个普通消息发出去浏览器就不会回协议层pong。所以排查这类问题的时候不要只看应用日志里有没有pong事件最好用抓包工具直接看看帧的类型。Chrome DevTools的Network面板里WebSocket消息有明确的帧类型区分能一眼看出你收到的到底是ping还是普通消息。在服务端可以用ws库的on(pong)来验证。还有一个容易被忽略的场景如果客户端和服务端之间存在WebSocket代理某些代理缓存或者改写帧也可能把pong吞掉导致服务端以为连接死了。这时候需要在代理层看是透传帧还是自动应答。有几个消息中间件的WebSocket网关是自带心跳拦截的服务端的ping会被网关直接消化掉那就得调整心跳策略。5.3 Nginx反向代理的WebSocket踩坑Nginx转发WebSocket请求时有一项比较关键的配置是Upgrade头传递。默认情况下Nginx配置HTTP代理会把请求头原样往后端传但WebSocket升级依赖Connection: Upgrade如果这个头没传后端就永远握手不成功。实际配置里一个比较稳的写法是location /ws { proxy_pass http://backend_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; }其中proxy_read_timeout直接决定Nginx等待后端响应的最长时间。HTTP场景下这个值可以短一点但WebSocket场景下如果设成默认的60秒那么一条长连接超过60秒没有数据流动Nginx就会自作主张断开这就是很多WebSocket每隔60秒就掉线一次的原因。我把这个值调成3600秒之后类似问题再没出现过。还有proxy_buffering对WebSocket来说一般要关掉否则数据可能被缓冲延迟。proxy_set_header Connection upgrade在HTTP/2场景下可能需要特殊处理不过这个属于边缘场景了。5.4 服务端内存飙升和多开连接泄漏写WebSocket服务最容易出现的稳定性问题就是连接对象泄漏。现象是服务端进程内存持续上涨最终OOM被系统杀掉。原因是代码里忘记在close事件中把对应的连接从全局Map里移除或者移除逻辑写在一个永远不会执行到的位置。比如把clients.delete(userId)写在message处理函数里的某条分支分支没走到连接就算关了也不会清理。排查这种问题最有效的办法是打点统计。我在wss.on(connection)和ws.on(close)里分别加一个计数器并且定时输出当前连接数观察连接数是否随客户端开关而上下变化。如果客户端关了但服务端连接数不降那基本就是close事件逻辑出了问题。另外要提醒的是ws库的wss.clients本身是Set提供了所有当前连接可以在定时器里用它来做统计。如果你维护了自己的Map一定要保证在两个地方同步更新正常关闭和异常错误。异常错误也必须清理不然错误事件触发后连接就挂在那里了。5.5 一条连接收到多条消息时数据错乱WebSocket的TCP层天然会有粘包、半包问题不过ws库内部已经把流式解析做完了普通业务开发者一般不会遇到。但如果你的服务端用了类似net模块自己解析WebSocket帧或者接入了某些消息队列网关那么消息边界就不是WebSocket帧而是你自己定的一套传输格式了这时候需要特别注意处理粘包。我遇到过的一个场景是设备端通过MQTT网关转WebSocket网关再推给前端。网关在转发时没有严格按WebSocket帧边界处理导致前端偶尔收到一条半截消息或者两条消息拼接在一起的消息JSON解析失败。最终解决办法是让设备端在发送消息时约定一个固定长度的HeaderHeader里写明正文长度WebSocket网关读取Header后按长度切分再从转发通道发出去。这个问题的本质是WebSocket协议本身是有消息边界能力的帧长度字段但如果你从外部系统接入数据时没有保留这个边界就需要在业务层自己封一层长度字段来还原边界。6. 写在最后心跳之外的那些事WebSocket写起来不难真正难的是稳定性。我自己在多次项目里被看似简单的连接问题折磨过之后最大的体会就是WebSocket不是一个连上就行的东西它是运行在一个极其复杂的网络环境里的长连接技术NAT、代理、负载均衡、防火墙、DNS解析任何一个环节都可能让一条长连接意外消失而长连接的生命周期管理完全要靠你自己构造的机制来兜底。心跳机制只是让死连接现出原形的第一步它解决的是可靠性问题。在此基础上后面你还要面对连接鉴权怎么做、消息怎么保证不丢不重、服务端怎么水平扩展、消息顺序怎么保证、离线消息怎么存每一个话题都值得单独开一篇。我现在做的项目里已经不只是简单的心跳ping/pong了而是把整个连接状态机拆成了未认证、已认证、重连中、已关闭这个几个状态并且在每个状态都设置了超时看门狗这样无论外部环境怎么折腾系统都能保证在可预期的时间内恢复到健康状态。如果你现在正准备在自己的项目里引入WebSocket我的建议是第一版可以按这篇文章的心跳方案落地先保证连接能自己活下去但架构上一定要预留连接状态管理的位置不要等到线上出了问题再回头改。下一篇文章我会专门写WebSocket的鉴权与消息可靠性设计包括Token换连接、消息确认重传、离线消息补偿这些话题到时候见。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进