WebSocket长连接实战:握手、心跳、重连与广播拆包
简介面向具备 Java Web 基础的开发者的一份 WebSocket 入门演示工程用于解决浏览器与服务器之间只能依靠轮询进行单向通信的问题。工程围绕协议开放的握手过程与 TCP 层消息帧展开演示服务端如何主动向客户端推送消息适合作为理解全双工通信机制的最小可运行样例。压缩包共 19 个文件约 41KB以 Eclipse 工程配置为主prefs、xml、component、classpath、project 等另配 1 个 Java 源文件、1 个 JSP 页面、pom.xml 构建描述与 websocket-api.jar 依赖包另有 readme.txt 说明使用方式导入 IDE 后即可阅读与调试。已有 391 人学习下载可作为课程实验、接口联调或自建推送功能的参考底稿。1. WebSocket Demo 拆包它省掉的不止一行代码某公司的内部工单系统最早用setInterval每 3 秒拉一次列表接口十来个人在线时后端 QPS 看着不高可一旦有人开着页面过夜第二天连接池里全是半死不活的请求最坏情况下消息要等一个轮询周期才出现。后来换成一份 WebSocket demo同一批用户的后端请求量掉到接近零消息到达延迟从最坏 3 秒变成几十毫秒。这份 demo 的价值不在多一个技术栈它把长连接最容易出问题的四件事拆成能单独跑起来的最小单元握手、心跳、重连、广播每一块都有独立脚本和可观察日志改坏一个不影响另一个。适合两类人一类是想把实时推送塞进现有 Web 项目、又不想一上来就引入消息中间件的后端另一类是前端被readyState老是 3、onclose不知道什么时候才真触发折磨过的人。下面按我实际拆包的顺序来。2. 从握手到首帧把 Upgrade 这条链路亲手走一遍很多人第一次接 WebSocket 就觉得它很玄学明明端口通、明明代码没报错浏览器就是提示连接失败。问题往往不在业务代码而在 101 这次协议升级有没有真正走完。这一章把链路从报文层拉出来看再用最小服务端跑通一次回声。2.1 一次 Upgrade 到底交换了什么浏览器发起的并不是一个全新的协议连接它先伪装成一个普通 HTTP 请求带上几个特定头服务端同意后才切换到全双工。原始请求长这样GET /ws HTTP/1.1 Host: 127.0.0.1:8081 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13服务端认账后回 101同时给出握手校验值HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOoSec-WebSocket-Key是客户端随机生成的 16 字节再 base64Sec-WebSocket-Accept的算法固定把 Key 拼上协议规定的魔法字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11做一次 SHA-1 再 base64。这个值不对浏览器直接判握手失败报的是一个非常笼统的WebSocket connection failed。所以排查的第一步永远是把原始报文抓到看是请求没发出去、代理把Upgrade头吃了还是服务端返回了 200 而不是 101。请求头里还有两处容易被忽略。Sec-WebSocket-Version: 13是当前通行的版本号个别老客户端会带 8服务端如果只认 13 就会拒绝返回 426。Origin头浏览器会自动带上服务端要不要校验它取决于你的鉴权策略但别指望靠它当唯一的安全边界。2.2 起一个最小服务端字段比代码更值得看先建目录装依赖。这里用 Node 的ws库因为它同时提供客户端和服务端实现压测脚本可以直接复用省掉一套工具链mkdir ws-demo cd ws-demo npm init -y npm i ws服务端本体不到三十行但每个选项都有代价// server.js —— 不依赖框架的最小 WebSocket 服务端 const { WebSocketServer } require(ws); const wss new WebSocketServer({ port: 8081, path: /ws, // 只接管这一个路径避免和静态资源抢端口 maxPayload: 64 * 1024, // 单帧上限 64KB防止有人塞大包把内存打满 perMessageDeflate: false, // demo 阶段先关压缩省掉一类难查的粘包假象 clientTracking: true, // 维护 wss.clients 集合心跳和广播都靠它 }); wss.on(connection, (socket, req) { const id Math.random().toString(36).slice(2, 10); socket.id id; console.log([open] ${id} ip${req.socket.remoteAddress}); socket.on(message, (raw, isBinary) { console.log([msg] ${id} binary${isBinary} bytes${raw.length}); // 回声链路是否通发一帧回来就知道 socket.send(raw, { binary: isBinary }); }); socket.on(close, (code, reason) { console.log([close] ${id} code${code} reason${reason.toString() || -}); }); socket.on(error, (err) { // 单独兜住否则一个 ECONNRESET 能把整个进程带走 console.log([error] ${id} ${err.message}); }); });maxPayload是第一个后悔药。默认值虽然不算小但真实业务里一张图片 base64 转过来就能顶上去超过限制ws会直接以 1009 关闭连接日志里只有一行 close code没有堆栈。把它显式写成一个业务能接受的上限比事后猜要省事得多。perMessageDeflate关掉是有意为之。压缩对文本消息确实省带宽但它会引入上下文协商一旦中间还有别的代理也做压缩排查断帧时你会怀疑人生。demo 阶段先关等内容协议稳定、消息体积确实需要压了再打开并且只在服务端开、客户端显式协商。path也建议显式指定。不写的话它会接管所有路径的升级请求本地调试时很容易出现我在别的路径上试了个东西结果被这个服务端接走了的情况。启动node server.js日志打出一行监听提示就算起来了。这时候先别急着写前端用命令行验证一遍。2.3 用命令行确认链路再写浏览器代码用ws自带的客户端跑一发比开浏览器快得多// probe.js —— 一进一出确认握手、帧、关闭三个环节都正常 const WebSocket require(ws); const ws new WebSocket(ws://127.0.0.1:8081/ws); ws.on(open, () { console.log(open, readyState , ws.readyState); // 1 才是 OPEN ws.send(hello); }); ws.on(message, (data) { console.log(recv, data.toString()); ws.close(1000, done); // 1000 表示正常关闭对端能收到 reason }); ws.on(close, (code, reason) { console.log(closed, code, reason.toString()); });关闭码值得记住几个1000 正常关闭1001 对端离开比如用户关页面1006 异常关闭且这个码只读、不能主动发出去1009 消息过大。日志里看到 1006基本就是链路中途断了去查代理和心跳别在业务代码里找。浏览器端写法更短但有两个细节必须加script // 先 localhost 走通再换 wss const ws new WebSocket(ws://127.0.0.1:8081/ws); // 二进制帧在这个模式下给 ArrayBuffer否则 Chrome 会给 Blob读起来要异步 ws.binaryType arraybuffer; ws.onopen () { // CONNECTING 阶段调 send 会直接抛 InvalidStateError所以放在 onopen 里 ws.send(JSON.stringify({ type: sys.hello, ts: Date.now() })); }; ws.onmessage (e) { const data typeof e.data string ? e.data : new TextDecoder().decode(e.data); console.log(frame, data); }; ws.onclose (e) console.log(close, e.code, e.reason, clean , e.wasClean); /scriptwasClean这个字段很实用它告诉你这次关闭是不是走完了完整的关闭帧交换。页面切后台、进程被杀、网络闪断都是wasClean false这正是后面重连逻辑要接手的信号。3. 消息协议设计帧格式、心跳间隔与背压的三条线握手通了只代表能说话不代表能长期说下去。真正把长连接跑稳的是消息协议里三件看起来无聊的事帧要不要带序号、心跳多久打一次、对端消费不过来时你怎么知道。这三条线没定后面重连和广播全是空中楼阁。3.1 别裸传字符串给每帧加个信封随手send(hello)在 demo 里没问题一进业务就会出现这条消息谁发的、要不要回执、乱序了怎么办。我一般先定一个最小信封四个字段解决 80% 的争议{ v: 1, type: chat.message, seq: 1024, ts: 1730000000000, room: r-1001, payload: { text: hi } }v是协议版本客户端和服务端版本不一致时可以在sys.hello阶段直接拒绝比跑到一半发现字段对不上好查。type建议用域名.动作的两段式命名比如sys.ping、chat.message、room.join前缀一分类日志里 grep 就能拉出某一类流量。seq是服务端单调递增的序号重连补发全靠它。ts用毫秒时间戳只用于排查延迟不用于排序。payload单独包一层而不是把业务字段平铺在顶层是为了让后续加traceId、retry、compressed这类元信息时不用改业务解析代码。平铺的代价在你加第二个元字段的时候就会显现。帧的类型其实在协议层是 opcode 决定的0x1 文本、0x2 二进制、0x8 关闭、0x9 ping、0xA pong。业务信封只在 0x1 和 0x2 里出现控制帧是协议自己用的别混。这一点很关键因为浏览器端没法主动发 0x9只能靠应用层自己造心跳而服务端可以发两边策略不一样。3.2 心跳间隔不是拍脑袋25 秒怎么来的服务端心跳的标准写法是定时 ping、等 pong、超时就踢// 服务端心跳25 秒一轮连续两轮没回 pong 就踢掉 const HEARTBEAT_INTERVAL 25_000; const timer setInterval(() { for (const socket of wss.clients) { if (socket.isAlive false) { socket.terminate(); // 注意是 terminate 不是 close对端可能已经收不到帧了 continue; } socket.isAlive false; // 先假设它已经死了等 pong 把它救回来 socket.ping(); } }, HEARTBEAT_INTERVAL); timer.unref(); // 别让这个定时器拖住进程退出 wss.on(connection, (socket) { socket.isAlive true; socket.on(pong, () { socket.isAlive true; }); });25 秒这个数字不是玄学它来自中间层。多数反向代理和负载均衡的空闲连接超时默认在 60 秒上下取一半以下就能保证连接在被回收之前先有一次心跳流量经过。如果你把心跳设成 55 秒那和没设差不多因为连接在第 60 秒就被中间层掐了你只是在被掐之前刚好发了一帧。浏览器端要自己实现应用层心跳因为发不了协议层 ping// 客户端应用层心跳两轮没收到 pong 就主动重连 const PING_GAP 20_000; let missCount 0; function startHeartbeat(ws, onDead) { const t setInterval(() { if (ws.readyState ! WebSocket.OPEN) return; if (missCount 2) { clearInterval(t); onDead(); // 交给外层触发重连 return; } missCount 1; ws.send(JSON.stringify({ type: sys.ping, ts: Date.now() })); }, PING_GAP); return t; }PING_GAP要略小于服务端的HEARTBEAT_INTERVAL这样即使服务端不发 ping客户端也在持续往链路里灌流量不会出现两边都以为对方还活着、其实中间早就断了的情况。收到sys.pong时把missCount清零这个动作别写漏漏了就是每 40 秒重连一次日志里全是正常关闭非常迷惑。3.3 背压bufferedAmount 是唯一能提前预警的指标长连接跑久了内存一路涨十有八九是这里没做判断。socket.send()是非阻塞的数据先进入内核缓冲和库的发送队列对端消费慢就会一直堆。bufferedAmount就是这个队列的当前长度const HIGH_WATER 1 20; // 1 MiB超过就丢帧而不是继续堆 function sendSafely(socket, data, { highWater HIGH_WATER } {}) { if (socket.readyState ! socket.OPEN) return false; // 1 表示 OPEN if (socket.bufferedAmount highWater) { console.warn([drop] id${socket.id} buffered${socket.bufferedAmount}); return false; // 宁可丢这一帧也不要把内存压到 OOM } socket.send(data, (err) { if (err) console.warn([send-err] ${err.message}); }); return true; }阈值怎么定看你的单帧大小和可接受延迟。单帧 1KB、阈值 1MB意味着最多积压约一千帧按每秒十帧算就是一百秒的延迟这时候丢帧比送达更有价值。如果业务是不能丢的比如订单状态那就别用丢帧策略改成给这个连接降级暂停推送、落库、等重连后按seq补发这正是下一章要做的事。bufferedAmount还有一个用法是低水位恢复。超过高水位之后不要立刻恢复推送要等它掉到高水位的一半以下再恢复否则会在阈值附近来回抖动日志里全是 drop 和 resume。这个阈值区间建议用两个常量算别写魔法数。4. 断线重连与房间广播把 WebSocket 连接层封装成可复用模块业务代码里最不该出现的就是裸的new WebSocket。它一旦散落在几个组件里重连策略、心跳、消息队列会各写一套最后没人说得清哪个连接在什么时候断的。这一章把连接层收成一个类再叠上房间广播和补发。4.1 指数退避加抖动参数怎么定重连最忌讳的是固定间隔。服务端刚重启几百个客户端同时每 1 秒撞一次等于自己给自己打了一波流量攻击血泪经验都是这么来的。// 带抖动和退避的重连封装接在上一章的心跳之后 class ReconnectingSocket { constructor(url, opts {}) { this.url url; this.minDelay opts.minDelay ?? 500; // 首次重连等 0.5 秒 this.maxDelay opts.maxDelay ?? 15_000; // 上限 15 秒 this.factor opts.factor ?? 1.8; // 每次乘 1.8 this.jitter opts.jitter ?? 0.3; // 上下浮动 30% this.attempt 0; this.queue []; // 断线期间待发消息 this.handlers new Map(); this.open(); } open() { this.ws new WebSocket(this.url); this.ws.binaryType arraybuffer; this.ws.onopen () { this.attempt 0; // 成功一次就清零退避 this.flush(); // 把断线期间攒的消息补发出去 this.emit(open); }; this.ws.onmessage (e) { const frame JSON.parse(typeof e.data string ? e.data : new TextDecoder().decode(e.data)); if (frame.type sys.pong) { this.emit(pong); return; } this.emit(frame.type, frame); }; this.ws.onclose (e) { this.emit(close, e); if (e.code 1000) return; // 主动关闭不重连 this.schedule(); }; this.ws.onerror () {}; // 交给 onclose 收尾这里只吞掉 } schedule() { const base Math.min(this.maxDelay, this.minDelay * Math.pow(this.factor, this.attempt)); const wait Math.round(base * (1 Math.random() * this.jitter)); this.attempt 1; this.timer setTimeout(() this.open(), wait); console.log([reconnect] attempt${this.attempt} wait${wait}ms); } flush() { while (this.queue.length this.ws.readyState WebSocket.OPEN) { this.ws.send(this.queue.shift()); } } send(obj) { const raw JSON.stringify(obj); if (this.ws.readyState WebSocket.OPEN) { this.ws.send(raw); } else { this.queue.push(raw); // 队列要设上限见下面说明 } } emit(type, payload) { (this.handlers.get(type) || []).forEach((fn) fn(payload)); } on(type, fn) { if (!this.handlers.has(type)) this.handlers.set(type, []); this.handlers.get(type).push(fn); } }参数上minDelay500ms 是给短抖动留的恢复窗口factor1.8 比 2 略温和五次重试大概到 5 秒左右十次封顶maxDelay15 秒是权衡客户端是网页用户不会等太久超过这个值不如直接提示网络异常。jitter必须大于 0否则整点重连的尖峰还是会出现只是推迟了。queue一定要设长度上限。断网十分钟攒下几千条待发消息一恢复就是一波密集发送bufferedAmount立刻冲高。我一般设 200 条或者按字节数设 256KB超了就丢最老的并打一条日志丢的这条比整个页面卡死更容易接受。4.2 房间订阅与广播的最小实现一对一回声走通之后下一个需求一定是给某个房间的人同时推。服务端用一张Map就能撑住const rooms new Map(); // roomId - Setsocket function joinRoom(socket, roomId) { if (!rooms.has(roomId)) rooms.set(roomId, new Set()); rooms.get(roomId).add(socket); socket.rooms socket.rooms || new Set(); socket.rooms.add(roomId); } function leaveAll(socket) { for (const roomId of socket.rooms || []) { const set rooms.get(roomId); if (!set) continue; set.delete(socket); if (set.size 0) rooms.delete(roomId); // 空房间必须清掉否则 Map 只增不减 } socket.rooms new Set(); } function broadcast(roomId, payload, except) { const set rooms.get(roomId); if (!set) return 0; const frame Buffer.from(JSON.stringify(payload)); let sent 0; for (const socket of set) { if (socket except) continue; // 发送者自己不收省一次客户端去重 if (sendSafely(socket, frame)) sent 1; // 复用上一章的高水位判断 } return sent; }leaveAll要在close事件里无条件调用。很多人只在收到room.leave消息时才清理结果用户直接关页面房间里的Set一直挂着已经失效的 socket广播时遍历一堆死连接。表现是内存缓慢上涨、广播计数虚高很难通过接口监控发现。broadcast里先把帧JSON.stringify成Buffer再循环发而不是在循环里序列化。房间 500 人时这一步能省掉 500 次序列化主导航级别的房间差别很明显。4.3 seq 与补发重连之后不能当作什么都没发生用户在地铁里断了 8 秒重连回来这 8 秒里房间推的消息去哪了如果只是重连、不清状态客户端会缺一段而且没人知道缺了。解法是客户端在重连后主动带上最后收到的序号{ type: sys.resume, payload: { room: r-1001, since: 1018 } }服务端每个房间维护一个环形缓冲只留最近 N 条class RingBuffer { constructor(size 500) { this.size size; this.items []; } push(frame) { this.items.push(frame); if (this.items.length this.size) this.items.shift(); // 丢最老的 } since(seq) { return this.items.filter((f) f.seq seq); } }size怎么定按峰值消息速率乘可接受的断线时长。房间每秒 5 条、允许断 100 秒500 就够。超出缓冲范围时不能装作补齐了得回一帧sys.resume_failed让客户端做个全量刷新这个分支一定要写否则用户看到的是界面看起来正常、数据少了一截。5. 避坑与排查五个让长连接当场翻车的现场这一段里每个坑都是我在真实联调里撞过的共同点是现象和原因隔得远只看错误信息会往完全错误的方向查。按现象、原因、解决三段记遇到类似表现可以直接对。5.1 连接建立阶段的坑现象本地node probe.js一切正常浏览器打开页面却稳定报WebSocket connection failed控制台没有更多信息网络面板里那条请求状态是 200。原因请求绕过了反向代理代理不认Upgrade头把它当普通 HTTP 请求转发给后端后端返回了页面或者 200浏览器拿不到 101 就判失败。这个现象在 curl 里看不出来因为 curl 默认不发升级头。解决在代理层显式放行升级头不写这两行后面全白搭# nginx 里的 location 片段关键是这两个 header proxy_http_version 1.1; # 1.0 不支持长连接升级 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; # 别让代理在 60 秒时把空闲连接掐了最后一行常被漏掉。proxy_read_timeout默认 60 秒即使心跳正常、业务正常超过这个时间没有数据流过代理也会主动断。表现就是每隔一分钟整点掉线一批非常规律。另一条现象连接能建但连上后两三秒就被踢关闭码 1006。原因perMessageDeflate一边开一边关。服务端开了压缩、客户端不协商或者中间代理也做了一次压缩帧解不出来就断开。解决先两边都关确认稳定后再统一开别在链路没跑通的时候叠加压缩这一层变量。5.2 数据收发阶段的坑现象服务端日志显示发出去了前端onmessage里e.data打印出来是[object Blob]解析 JSON 直接抛错。原因浏览器默认把二进制帧给成Blob读它必须走FileReader异步很多人拿到就直接JSON.parse。而且服务端如果发的是Buffer在浏览器侧就是二进制帧不是字符串。解决连接建立后第一件事设ws.binaryType arraybuffer然后用TextDecoder统一解码。更彻底的做法是约定清楚文本协议一律发字符串二进制只用于明确的大块数据两边按type分流。现象页面开着不动二十分钟后点击按钮发消息readyState还是 1但消息石沉大海。原因链路其实已经断了只是客户端还没收到关闭事件NAT 超时、休眠唤醒都会这样。readyState反映的是本地状态机不是网络真实可达性。解决不能信readyState要信心跳。把sys.ping的missCount逻辑接上两轮没回 pong 就主动close并触发重连。这一条是长连接里最典型的看起来正常故障监控上完全看不出来。现象压测跑到八百连接服务端内存从 200MB 涨到 1.8GGC 日志一片红。原因广播时没有做bufferedAmount判断某个客户端消费慢它的发送队列一直堆其它人正常只有这一个把内存拖上去。解决所有发送统一走sendSafely超水位就丢帧并计数把这个计数接到监控上。丢帧数突然上升基本等于某个客户端网络出了问题比等 OOM 再查要好得多。5.3 上线之后的坑现象服务端重启后客户端在三十秒内全部重连成功但服务端 CPU 瞬间打满接了一波超时告警。原因重连没加抖动全部客户端在同一秒撞上来同时每个连接重连后立刻发sys.resume补发逻辑又是全量扫环形缓冲两件事叠在一起。解决退避必须有jittersys.resume也要限流服务端在connection事件里做一个短时间的排队比如每 50ms 最多处理 20 个恢复请求其余延后。这个改动很小但能把重启后的尖峰削平。现象移动端用户反馈切回 App 后要等十几秒才有新消息。原因页面进后台被系统挂起定时器不执行心跳停了连接也被回收。回到前台时readyState可能是 3但重连要等下一次schedule触发。解决监听visibilitychange回到前台时主动检查readyState不是 OPEN 就立刻触发一次立即重连绕开退避等待同时重置missCount。这一条不做移动端体验会明显差一截。6. 压测与上线前自检一条脚本把连接数打上去单机跑通和能上线之间差一次压测。别用浏览器开十个标签页假装压测浏览器对同域并发连接数有限制你测的是浏览器不是服务端。用ws写一个能拉连接数的脚本跑之前先把文件描述符上限调上去不然第一批连接就被系统拒了# 单进程能开的 fd 数量压测前必须放开 ulimit -n 65535// bench.js —— 只打连接数和首帧延迟别在生产环境跑 const WebSocket require(ws); const N Number(process.argv[2] || 500); const URL process.argv[3] || ws://127.0.0.1:8081/ws; let opened 0, failed 0; const latencies []; const t0 Date.now(); for (let i 0; i N; i 1) { const ws new WebSocket(URL); const sentAt Date.now(); ws.on(open, () { opened 1; ws.send(JSON.stringify({ type: sys.ping, ts: sentAt })); }); ws.on(message, () { latencies.push(Date.now() - sentAt); // 首帧往返粗略但够用 }); ws.on(error, () { failed 1; }); if (i % 100 0) { // 分批起连接避免本地端口和 CPU 同时打满导致数据失真 setTimeout(() {}, 10); } } setInterval(() { latencies.sort((a, b) a - b); const p99 latencies[Math.floor(latencies.length * 0.99)] || 0; console.log(opened${opened} failed${failed} p99${p99}ms elapsed${Date.now() - t0}ms); }, 2000);观察项不用多三项就够定位问题观察项采集方式判断标准建连成功率opened / N低于 99% 先查 fd 上限和端口范围首帧往返 P99上面脚本的latencies内网应在几十毫秒上千毫秒说明有排队常驻内存process.memoryUsage().rss每 10 秒打一次连接稳定后应走平持续上涨就是队列没清压测里最容易忽略的是连接建立本身的开销。五百个连接同时起服务端在connection回调里做的事查库鉴权、加载用户信息会被放大五百倍所以鉴权结果我一般放在握手阶段用 token 缓存别在connection里做同步 IO。从那以后我每次把长连接推到预发环境之前都会强制走一遍这三步先用命令行探针确认 101 和关闭码再挂上心跳跑满一小时看有没有整点掉线最后用压测脚本拉到目标连接数的两倍看一眼内存曲线。少任何一步问题都会在半夜以最难看的形式出现。希望帮到你。本文还有配套的精品资源点击获取