首页 / 资讯中心 / 文章详情

WebSocket核心原理与工程实践:从轮询、帧格式到心跳重连

WebSocket核心原理与工程实践:从轮询、帧格式到心跳重连 ★ FEATURED ARTICLE
最近在做一个实时数据看板项目需求是后端一有数据变更前端页面要立刻刷新。顺着这个需求翻了无数资料发现大家对“前端数据推送”的理解大多停留在“用WebSocket就完事了”但对“它和轮询、长轮询本质差在哪”“WebSocket握手到底在握手什么”“帧格式里每一比特在表达什么意思”却说不太清楚。正好借着这个机会我把协议规范重新啃了一遍又用抓包工具验证了握手和收发的全过程。这篇就按我自己的理解脉络从数据推送方案演进讲到 WebSocket 的底层帧结构最后落回工程实践里的几个典型坑位。如果你也想把实时通信的原理吃透、而不只是会用某个库这篇值得往下看。1. 从“轮询”到“推送”前端实时数据方案的演进与选型逻辑1.1 短轮询最朴素的“定时问”前端要拿到新数据最容易想到的办法就是定时器每隔几秒发一次 HTTP 请求问后端“有没有新数据”。这个方案在技术选型时经常被拉出来对比因为它实现成本几乎为零——后端完全不需要常驻连接前端只需要setInterval或者requestAnimationFrame里包一个fetch就行。但短轮询的问题也非常直接。首先绝大部分请求都是在做无用功服务端根本没有新数据也要把请求完整地处理一遍返回一个“没变化”的空响应。这里面的开销不只是带宽还有连接建立、HTTP 头解析、服务端任务队列唤醒这些隐性成本。其次实时性永远和请求频率互相打架频率高了浪费资源频率低了延迟变大。实测下来短轮询能接受的场景基本只有两种一种是数据变化极慢比如每十分钟才变一次的报表统计另一种是对实时性毫无要求的内部工具。其余场景建议直接跳过这个方案。1.2 长轮询延长请求生命周期的折中长轮询在思路上做了一个调整客户端发起请求后服务端不立刻返回“没数据”而是把请求挂起一直挂到有新数据或者等到超时才返回响应。客户端收到响应后再立刻发起下一次请求。这样看起来“推送感”强了很多因为数据一旦产生就能顺着这个挂起的请求马上返回延迟比短轮询低很多。但长轮询的代价是把压力转移到了连接资源上服务端要同时挂起很多连接每个连接都占用内存、文件描述符和线程/协程资源。一旦请求量上去服务端频繁地挂起、超时、断开、重连网络抖动还会导致大量连接同时重建这就是常见的“惊群效应”。我在早期一个简单聊天功能里用过长轮询高峰期的时候连接的建立和销毁频率非常高服务端日志里全是连接关闭再重连的记录排查问题也变得很难受。后来才明白长轮询本质上只是 HTTP 模型下的一个“补丁”它让服务端能主动“压住”响应但并没有解决请求-响应模型的根本限制每一个数据都要靠客户端先发起一次请求才能被“牵出来”。1.3 为什么最终需要 WebSocket选型判断表WebSocket 和前面两种方案的本质区别在于它把连接从一个“一次性的请求-响应会话”变成了“一条可双向持续收发数据的通道”。一旦握手成功服务端可以随时往这条通道里扔数据而不需要等客户端开口要。还有一个容易被忽略的对比对象是 SSEServer-Sent Events通过EventSource实现。SSE 走的是 HTTP 长连接服务端可以持续向客户端推送文本数据但它有一个硬性限制数据只能从服务端单向流向客户端。如果业务里需要前端往服务端发实时指令、聊天消息、游戏操控等反向数据SSE 就不够用了。另外 SSE 默认只支持文本虽然可以自己编码二进制WebSocket 原生支持二进制帧。对比维度短轮询长轮询SSEWebSocket数据方向单向客户端请求单向客户端请求服务端→客户端双向实时性取决于轮询间隔较高高最高连接开销每次请求都新建长连接挂起一个长连接一个长连接二进制数据按需编码按需编码不友好原生支持自动重连无无内置需自建适用场景低频状态刷新简单消息通知行情推送、日志流聊天、协作、游戏、实时看板选型的时候我的判断很简单只要明确需要“服务端主动推送”这个能力且不需要前端反向发数据SSE 反而比 WebSocket 少写很多重连代码但凡是双向交互场景比如在线协作、IM、实时协同编辑、动态表单联动就直接上 WebSocket别再用轮询方案去凑合了。2. WebSocket 握手一次 HTTP 升级怎么变成双向通道2.1 握手请求里到底带了什么WebSocket 握手这一步经常被跳过很多人以为客户端new WebSocket(url)之后就“神秘地”建立了一条 TCP 连接。实际上握手是一个完全标准的 HTTP GET 请求只是请求头里带上了升级相关的字段。用浏览器或者 Node 里的客户端库连接ws://127.0.0.1:8080/chat时发出的请求头长这样GET /chat HTTP/1.1 Host: 127.0.0.1:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13 Origin: http://localhost:3000逐个解释这几个关键头的含义Upgrade: websocket和Connection: Upgrade告诉服务端这条 HTTP 请求完成后不要关闭连接而是切换协议到 WebSocket。Sec-WebSocket-Key客户端生成的一个随机值通常是 16 字节随机数再经过 Base64 编码。这个值的作用是让服务端能确认“对方确实想升级成 WebSocket”并防止无意义的缓存命中。Sec-WebSocket-Version协议版本号目前实际用的是 13。如果你发的版本不是 13服务端可以在响应头里通过Sec-WebSocket-Version告诉你它支持的版本。很多人在这里有一个误解以为Sec-WebSocket-Key是加密密钥握手之后的数据要靠它加密。其实不是它只是一个握手校验因子数据帧本身并不用它来做对称加密。真正的加密是在 TLS 层完成的也就是wss://就是 WebSocket over TLS和https://是同一个层面的东西。2.2 101 响应与服务端的校验逻辑服务端收到上面的升级请求后如果同意升级会返回如下响应HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo关键在这行Sec-WebSocket-Accept。它的计算方式是固定的把客户端传来的Sec-WebSocket-Key拼接上一个固定的 GUID 字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11然后做 SHA-1 哈希再对哈希结果做 Base64 编码。写成实现逻辑就是accept_value base64_encode( sha1(client_key 258EAFA5-E914-47DA-95CA-C5AB0DC85B11) )这个 GUID 是一个固定的魔法值写死在协议规范里。为什么要有它主要是防止一些只实现了部分协议的老客户端或者代理设备拿旧的握手逻辑来“误打误撞”地升级成功。加上这个固定的挑战值之后所有符合规范的实现都能计算出相同的结果不符合的就会校验失败服务端可以直接拒绝。我做过一个比较完整的服务端实现手工校验的例子是这样的示意代码const crypto require(crypto); function computeAccept(key) { const hash crypto .createHash(sha1) .update(key 258EAFA5-E914-47DA-95CA-C5AB0DC85B11) .digest(binary); return Buffer.from(hash, binary).toString(base64); }服务端校验时拿这个结果和请求里的Sec-WebSocket-Accept对比一致才握手成功。如果客户端计算的值和服务端不一致浏览器端的WebSocket会直接触发onerror根本不会进入onopen。当初我手写客户端握手时在这个 accept 的计算上栽过一次跟头——字符串拼接顺序写反了导致服务端一直返回 400排查了半天才发现是 GUID 拼接到了 key 前面实际规范是 key 在前、GUID 在后。2.3 握手完成之后HTTP 的作用就结束了一旦 101 响应完成这条 TCP 连接上的“HTTP 语义”就正式结束连接变成了一个独立的 WebSocket 通道。此后的数据不再使用 HTTP 的请求-响应结构而是用一套全新的帧格式来承载消息。这里有个值得理解的点WebSocket 没有完全抛开 TCP它依然建立在一条 TCP 连接之上。所以 TCP 层能提供的可靠性丢包重传、顺序保证、流量控制WebSocket 都能继承。同时它又在 TCP 之上自定义了一套“消息边界”机制后面讲帧格式的时候你会看到一个消息对应一个 frame 或者一组 frame接收端能清晰地知道一条消息从哪里开始、到哪里结束这一点和裸 TCP 流的字节流模型完全不同。我自己用抓包工具看过握手过程的完整交互能明显看到一个奇妙的现象同一端口上先跑了完整的 HTTP 请求-响应紧接着就是二进制协议的数据帧。所以对于代理服务器、负载均衡器这类设备来说WebSocket 握手的兼容性挑战恰恰在于它们既要识别出这是一次 HTTP 升级请求不能把 101 响应当普通响应拦掉又要在握手之后继续透明地转发 TCP 流。这也是为什么很多老一代代理设备对 WebSocket 支持不好的原因。3. 帧格式与数据收发协议底层的二进制细节3.1 帧头的两个字节大多数信息都在这里WebSocket 数据帧的最小单位是 frame每个 frame 的头是可变长的但前两个字节包含的信息量最大。把第 1 个字节这 8 个 bit 从高位到低位拆开来看bit 位字段名含义bit 7FIN1 表示当前消息的最后一个帧0 表示后面还有延续帧bit 6-4RSV1-3扩展协商预留位没有启用扩展时必须是 0bit 3-0opcode帧类型1 文本、2 二进制、8 关闭、9 ping、10 pong、0 延续帧第 2 个字节则负责两件事最高一位是MASK掩码标志剩下的 7 位表示 payload 的长度。如果 payload 长度小于 126那么这 7 位就直接是长度值如果等于 126那么真正的长度是后面连续 2 个字节的 16 位无符号整数如果等于 127那么后面 8 个字节组成一个 64 位长度表示一个超大帧。举个例子一个很常见的文本帧“hello”它在网络上的前两个字节可能是0x81 0x05。0x81拆成二进制是10000001FIN 是 1opcode 是 1说明这是一个完整的文本帧第二字节0x05表示 payload 长度是 5所以接收端读完前 2 个字节后再读 5 个字节就拿到了 UTF-8 编码的 “hello”。这个“按长度切分消息”的设计就是前面说的消息边界机制。我写过一个解析器来确认这些字段用 JavaScript 读帧头核心逻辑大概是function parseFrameHeader(buffer) { const firstByte buffer[0]; const fin (firstByte 7) 0x01; const opcode firstByte 0x0f; const secondByte buffer[1]; const masked (secondByte 7) 0x01; let payloadLength secondByte 0x7f; let offset 2; if (payloadLength 126) { payloadLength buffer.readUInt16BE(offset); offset 2; } else if (payloadLength 127) { payloadLength Number(buffer.readBigUInt64BE(offset)); offset 8; } return { fin, opcode, masked, payloadLength, offset }; }在实际抓包里你会看到短消息绝大部分都是0x81开头整个帧体很短协议开销非常小。这比轮询那种每次都带一整套 HTTP 头的方案在带宽效率上高出很多。3.2 掩码规则为什么客户端到服务端的帧必须做 XOR帧格式里第二个字节的MASK位还有一个硬性规则从客户端发往服务端的帧必须置为 1并且帧头后面必须紧跟一个 4 字节的 masking key而服务端发往客户端的帧必须置为 0不需要掩码。这个不对称设计很多人不理解甚至觉得多余。掩码的计算逻辑是让 payload 的每一个字节和 masking key 循环做 XOR 运算。伪代码如下function applyMask(payload, maskKey) { const result Buffer.from(payload); for (let i 0; i result.length; i) { result[i] ^ maskKey[i % 4]; } return result; }为什么网页端发出去的数据要掩码服务端回传却不用这个问题可以追溯到协议设计时的考量如果不做掩码恶意网页构造的 WebSocket 数据帧很容易被中间代理设备误解为 HTTP 请求或者响应从而污染缓存或执行一些非预期的本地操作。加了一层随机掩码后代理设备既无法预测客户端要发送的原始字节内容就无法可靠地利用这种歧义发起攻击。用一个好记的类比掩码不是加密更像是给数据做了一层“变形”让旁路设备看不懂但接收端按规则“变形回去”就能还原。实际开发中这些掩码和 parse 都由协议栈自动完成了但如果你要调试协议或者写一个低层客户端这个细节直接决定你会不会出现“收不到正确数据”的诡异问题。我在自己写最小实现的时候最开始没有做掩码处理结果服务端正常发帧但客户端收到的全是乱码最后定位到问题是只解析了帧头没有跳过那 4 字节 mask key也没有对 payload 做 XOR 还原。3.3 分片传输FIN、continuation 和消息边界WebSocket 支持把一个逻辑消息拆成多个帧传输这就是分片fragmentation。分片的具体规则是第一个帧的 opcode 表示真实消息类型文本或二进制FIN 设为 0表示后面还有延续帧中间的所有帧 opcode 都是 0FIN 都为 0最后一个帧的 opcode 也是 0但 FIN 设为 1表示消息结束。为什么要分片最直接的理由是在发送一个很大的消息时你不知道整条消息最终多大也没必要一次性把几十兆数据全部塞进一个帧里。分片允许应用程序在不知道消息总长度的情况下就“开始发送”边发边产生数据。这个特性对来自流媒体的数据、服务端逐步生成的大报文非常有用。接收端在实现上通常要维护一个“当前正在组装的分片状态”记录正在组装的 opcode 类型、已接收的总长度遇到 FIN1 才把完整消息交给上层。如果中间来了一个不属于延续帧的新类型帧就属于协议错误应该直接关闭连接。顺带提一个容易误解的点分片和 ping/pong 控制帧之间的关系。协议规定分片消息之间可以插入 ping/pong 控制帧但是控制帧不能接受分片它们必须以单独的帧发送并且控制帧的 payload 长度上限是 125 字节。这个限制保证了心跳和控制信令即使在大消息传输过程中也能及时到达不会被一个超长的分片消息“堵死”。4. 连接保活与异常恢复心跳、断线和重连的工程实践4.1 为什么 TCP 保活不够用WebSocket 依赖 TCP 连接但 TCP 本身的 keepalive 机制在真实环境中基本指望不上。操作系统级别的 TCP keepalive 默认探测周期非常长Linux 下默认通常是 2 小时才发送一次探测包而且很多云厂商的负载均衡和代理设备在连接静默几分钟后就会主动掐断空闲连接。这意味着一个看起来还“活着”的 WebSocket 连接可能在几十分钟内已经被中间设备静默关闭了而两端都不知道。更隐蔽的情况是“半开连接”手机锁屏、电脑休眠、网络切换这些场景下TCP 连接已经在系统层面断开了但应用层没有收到任何 FIN 包WebSocket 的onclose可能迟迟不触发页面上就一直停留在“看似已连接”的状态。4.2 协议层 ping/pong 与业务心跳的分工WebSocket 协议本身提供了心跳机制一端发送 opcode 为 9 的 ping 帧另一端收到后必须回复 opcode 为 10 的 pong 帧并且 pong 帧的 payload 应当尽量和 ping 保持一致。这个机制可以验证“TCP 链路是否仍然可用”因为 ping 能触发对端和中间设备的实际响应。但这里有个实战中很容易踩的坑浏览器的WebSocketAPI 并没有直接暴露发送 ping 帧的方法。你要在 JavaScript 里发 ping只能通过自己封装协议或者直接发送一个控制帧很多库也不会帮你做这件事。所以实际项目中“心跳”通常被设计为业务层心跳客户端周期性发送一个普通文本消息比如{type:ping}服务端收到后回复{type:pong}或者直接回显。只要在指定时间窗口内收到过回应客户端就认为连接是健康的否则主动调用close()并触发重连。我个人的习惯是双层结合底层能控制帧就发协议 ping/pong浏览器环境里用业务心跳兜底同时在前端监听onclose和onerror做重连。心跳间隔一般设在 15 到 30 秒之间连续两次心跳无响应就判定连接失效。间隔太小会增加无谓流量太大又不够敏感这个需要根据你的业务容忍度去调。4.3 断线重连的退避策略断线重连听起来简单写不好会变成“重连风暴”大量客户端同时断线同时重连把服务端打得措手不及。常见的做法是指数退避加随机抖动第一次重连延迟 1 秒第二次 2 秒第三次 4 秒以此类推并且每次在计算的延迟上增加一个随机偏移量比如 0 到 1000 毫秒之间的随机值让客户端的重连时间点分散开。如果页面刚好处于后台标签页还要注意浏览器的定时器节流机制后台页面的setTimeout最小间隔会被放大甚至被挂起。一个稳妥的做法是在visibilitychange事件里监听页面可见性变化重新计算重连时机前台时才真正发起重连。另外重连时的状态一致性也必须考虑重连成功后客户端要主动向服务端“重新注册”订阅关系因为基于连接维度的上下文比如订阅了哪些频道在服务端可能已经随旧连接销毁了。我自己在项目里就遇到过重连成功但收不到数据的情况因为服务端只在新连接的建立时检查了鉴权没有恢复此前的订阅列表。这个细节在 WebSocket 方案里几乎是必踩的。5. 真实项目里的坑代理超时、粘包误解与内存篇5.1 代理服务器的超时和升级头配置WebSocket 长连接一旦经过反向代理或负载均衡最典型的问题就是“连接被静默切断”。拿常见的 Nginx 来说默认的proxy_read_timeout是 60 秒意思是只要 60 秒内后端没有返回数据Nginx 就会主动关闭这条连接。而 WebSocket 这种连接恰好有很多“静默期”——没有业务消息的时候不可能每秒钟都有数据在跑于是 60 秒一过连接就没了前端才恍然发现 need 重连。解决的核心有两点一是把proxy_http_version设为1.1并且显式地传Upgrade和Connection头二是把proxy_read_timeout拉长常见的做法是设为 3600 秒甚至更长。实际配置大概是location /ws/ { proxy_pass http://backend_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }这里提一个很多人忽视的点如果你在一台机器上同时对外开放了 HTTP 和 WebSocket 两种流量而它们共用同一个域名或端口Nginx 需要用map指令根据Connection头来动态设置Connection字段以免普通 HTTP 请求也被强行升级成 WebSocket。把$http_upgrade非空时才设为upgrade其他情况保持close这样两种流量才能和平共处。5.2 WebSocket 到底有没有粘包问题“粘包”这个概念在 TCP 编程里家喻户晓。不少人把 TCP 的粘包问题直接搬到 WebSocket 上担心多个消息连续发送时会黏在一起无法拆分。这里要明确一点WebSocket 是有消息边界的协议它天然不会出现 TCP 层面的粘包。无论 TCP 底层把多少个 frame 的字节流一次扛过来WebSocket 接收端的解析器都能依据每个 frame 头里的 payload 长度字段逐个精确切割出独立 frame。只要你的解析器没有把“一次读到的所有字节”直接当作“一条消息”处理就不会有粘包问题。真正会出现粘包的只有裸 TCP 自己设计协议的情况比如自定义了一个length data的报文结构却忘了处理半包和粘包。所以这句话可以放心onmessage里拿到的每一次数据就是一条完整的单独消息。5.3 监听器泄漏与内存增长实践中一个隐蔽性很强的坑是事件监听的重复添加。很多前端代码会在每次重连成功时给 WebSocket 实例绑定onmessage、onclose但如果用的不是addEventListener而是直接赋值比如ws.onmessage handler其实不会叠加反而是在重连逻辑里用addEventListener或者把同一个处理函数绑到多个旧实例上会造成内存里堆积大量不再使用的闭包和回调。我见过一个线上看板项目每次断线重连就注册一个新的onmessage监听页面运行半天后内存占用翻了几倍——因为旧实例虽然已经关闭但它们所引用的 DOM 节点和数据处理闭包仍然被监听器持有。现在的浏览器虽然对未引用对象可以做垃圾回收但只要监听器还挂在旧 WebSocket 实例的事件源上、而旧实例又被某些对象引用着就永远不会被回收。正确的做法是在onclose或重连之前显式解绑旧监听器或者统一用一个全局唯一的回调函数替代。手动封一层时我习惯把事件处理函数引用都存下来重连时先removeEventListener再重新addEventListener这样即使重连一万次内存里也始终只有一份引用。5.4 移动端和弱网环境的特殊处理移动端设备切换 Wi-Fi 和蜂窝网络时TCP 连接会直接断掉但浏览器不一定会立刻触发onclose。这个场景下及时的心跳机制几乎是唯一能让前端快速感知断线的手段。如果你只依赖onclose用户可能在网络恢复很久之后还看到“连接中”的死界面。页面进入后台也是一个被反复强调的场景浏览器为了省电会挂起定时器业务心跳可能长时间不执行等回到前台时才突然发起重连。可以主动在visibilitychange触发时立刻校验连接状态而不是默默等待下一个心跳定时器被唤醒。实测下来这种处理能显著减少“切后台再回来之后数据恢复延迟”的体验问题。还有一点值得注意WebSocket 对 URL 路径和子协议的约束。请求路径/ws和/ws/是不同的路径后端如果做了精确路径匹配前端换了路径会导致 404 而握手失败。子协议subprotocol可以同时声明多个服务端选择一个返回但两端如果各写各的协议名握手可能正常但后续消息解析完全对不上。这些都是排错时容易被忽略的低级问题。扯远一点做实时功能前先想清楚消息格式文本 JSON 还是二进制帧。业务层用 JSON 解析灵活、调试直观适合绝大多数场景只有视频流、音频流、二进制协议设备类数据才需要走二进制帧。很多团队在文档里写“用 WebSocket 传 JSON”但真正实现时没有统一封包结构导致服务端和客户端各解析各的调试成本一下子就上去了。以上这些内容多数是我在实际项目里一遍遍踩出来的经验。回过头看WebSocket 的底层原理并不神秘无非是“一次 HTTP 升级 一套带边界的帧协议 一套控制信令”真正考验人的地方恰恰是它在真实网络环境中与代理设备、网络切换、重连风暴这些现实问题交织在一起时的处理能力。把帧格式和握手逻辑吃透再在工程层面对连接生命周期做足够细致的兜底你手里的实时功能才算真正能扛住线上环境。
阅读完成 · 觉得有帮助?
咨询建站