资讯动态

WebSocket帧格式深度解析:从bit级拆解到粘包半包与掩码还原

发布时间:2026/10/22 3:13:28 来源:尧图企业网站定制
前一阵帮某团队排查一个 WebSocket 服务的问题客户端连上之后服务端偶尔解析出乱码更严重的时候直接进程崩溃。我做的第一件事不是翻代码而是抓包。打开抓包面板一个文本帧安静地躺在那里81 8E 3E 2F D3 74 56 4A BF 18 51 03 F3 03 51 5D BF 10 1F 0E。很多同学看到0x81就懵了——这不就是一堆普通字节吗其实这正是 WebSocket 帧格式的核心。如果不懂这个字节里每一位的含义粘包、半包、掩码、UTF-8 截断这些问题排查起来会非常痛苦。这篇文章就把帧格式从 bit 层面彻底拆一遍再聊聊传输的数据格式怎么设计顺便分享几个真实踩过的坑。适合后端、客户端和所有需要调试长连接的开发者尤其是现在 AI 应用里高频使用流式输出的场景。1. 一次抓包从81 8E说起帧头里的关键字段1.1 第一个字节FIN、RSV1-3 和 OpcodeWebSocket 帧的第一字节非常关键。把0x81拆成二进制就是1000 0001从左往右看每一位的含义都不相同。第一位是 FINFinish表示这个消息是否已经完整发送。FIN1 说明这是消息的最后一个分片FIN0 说明后面还有 continuation 帧接着传。第二个到第四个 bit 是 RSV1、RSV2、RSV3正常情况下必须是 0。什么时候会变成 1只有当双方通过扩展协商好某个特性时才会用比如压缩扩展就常把 RSV1 置 1。如果你没有开启任何扩展但对方发来的帧里 RSV 不是 0那基本就是协议错误直接关连接就行。低四位是 Opcode它决定了这一帧承载的数据类型。Opcode含义0x0连续分片帧Continuation0x1文本帧Text0x2二进制帧Binary0x8关闭帧Close0x9Ping 帧0xAPong 帧0x3-0x7保留的非控制帧0xB-0xF保留的控制帧所以0x81 1000 0001翻译过来就是FIN1RSV 全 0Opcode0x1这是一个未分片的文本帧。抓包看到这个头就能非常确定这是客户端在向服务器发送文本数据。1.2 第二个字节MASK 位与载荷长度编码再来看第二个字节0x8E二进制是1000 1110。第一位是 MASK 位后面七位是载荷长度。MASK 位等于 1说明发送端对这个帧做了掩码处理后面会跟着 4 字节的 masking key。紧接着的 7 位0001110表示载荷长度是 14 字节。这里有一个很多人忽略的细节客户端发给服务器的帧MASK 位必须是 1服务器发给客户端的帧MASK 位必须是 0。这不是可选的约定而是协议强制要求。如果服务器收到一个未掩码的客户端帧应该直接按协议错误断开连接。如果客户端收到服务器发来的掩码帧同样属于协议违规。那 7 位最长只能表示 0 到 127也就是 127 字节。万一消息超过 127 字节怎么办协议预案很优雅如果载荷长度字段等于 126就表示真正的长度藏在接下来的 2 个字节里按无符号 16 位整数读取最大可以表达 65535。如果载荷长度字段等于 127就表示真正的长度藏在接下来的 8 个字节里按无符号 64 位整数读取。所以实际解析帧头时长度字段有三个分支。很多初学者一上来就死记“第二个字节后两位是长度”结果遇到大消息直接解析错位。其实框架很简单先看 7 位长度再根据 126/127 决定是否继续读扩展长度。下面这段 Python 代码是许多 WebSocket 库内部逻辑的高度简化版本却能解释 90% 的帧解析问题。def parse_ws_frame(data: bytes): b1, b2 data[0], data[1] fin (b1 7) 1 opcode b1 0x0F masked (b2 7) 1 length b2 0x7F offset 2 if length 126: length int.from_bytes(data[2:4], big) offset 2 elif length 127: length int.from_bytes(data[2:10], big) offset 8 mask None if masked: mask data[offset:offset 4] offset 4 payload data[offset:offset length] if mask: payload bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) return { fin: fin, opcode: opcode, masked: masked, length: length, payload: payload, }把开篇那串字节喂进去mask就是3E 2F D3 74解出来之后你会发现明文其实就是一串非常普通的字符串。这一步是后面排查所有诡异乱码问题的基本功。1.3 扩展长度与掩码还原的完整过程前面说到 126 和 127 分支实际操作中翻车最多的就是 127。因为 8 字节长度本身是一个 64 位无符号整数其中最高位必须为 0。也就是说解析时不要把 8 个字节一股脑转成 64 位带符号整数否则一旦某个大长度声明超出 2^63你读出来的可能是个负数后面切片逻辑直接崩。掩码还原就一句话payload 的每个字节与 masking key 按i % 4循环异或。我这里再手动算一个字节给你看明文第一个字符 h 的 ASCII 是 0x68 masking key 第一个字节是 0x3E 0x68 XOR 0x3E 0x56这个0x56就是抓包工具里看到的密文字节。反推时用0x56 XOR 0x3E就又得到0x68。掩码不是什么加密算法它只是一个线性可逆变换没有秘钥管理也没有鉴权能力。后面我会专门讲它到底在防什么。2. Opcode 决定“数据形状”文本帧、二进制帧与控制帧2.1 文本帧与二进制帧的表层差异文本帧0x1要求载荷必须是合法的 UTF-8 字节序列从字节流里切割出来后还不能直接当字符串用必须做一次 UTF-8 校验。很多人写服务端收文本帧时不做校验直接把字节强转字符串遇到异常字节就得到一串“乱码”更糟的是把半个中文字符也当成完整字符处理消息直接语义错乱。二进制帧0x2则完全不同它不关心你载荷里是什么图片、音频、序列化后的结构体甚至一段压缩数据都可以原样塞进去。二进制帧适合承载“已经编码完毕、并且接收方明确知道如何解码”的数据。那什么时候用文本帧什么时候用二进制帧我个人的经验是只要载荷是给人读的就用文本帧只要载荷是要交给程序反序列化的就用二进制帧。比如一个聊天消息{type:chat,content:你好}就是文本。一段语音编码后的 opus 流就是二进制。如果一套系统里既有文本又有音频那就不应该在同一个连接里靠“猜”来区分而要在应用层消息里加类型字段或者干脆用子协议区分。2.2 close 帧的载荷不简单状态码与原因字符串关闭帧0x8是很容易被低估的帧。它可能没有载荷也可能带 2 字节状态码和一串 UTF-8 原因字符串。常见状态码包括状态码含义1000正常关闭1001客户端/服务器正在离开比如页面关闭1002协议错误1003收到无法处理的数据类型1007文本帧的 UTF-8 校验失败1008违反策略1009消息太大1011服务器遇到内部错误这里有个很经典的坑如果 close 帧的载荷长度为 1协议上不合法。因为状态码最少需要 2 字节你给 1 个字节怎么解释很多框架直接把这种情况归类为解析失败。另一面close 帧的载荷长度也不能超过 125因为它属于控制帧控制帧的载荷上限就是 125 字节。如果关闭原因写太长有些实现会粗暴截断这在跨语言互通时尤其危险。正常关闭流程是一端发送 close 帧另一端收到后也回复 close 帧然后再关闭底层 TCP 连接。不要收到 close 就直接把 TCP 连接掐了优雅关闭能让对端少报很多莫名其妙的“套接字错误”。2.3 Ping/Pong真正的心跳是帧级别的Ping 帧0x9和 Pong 帧0xA在业务代码里几乎“透明”因为它们太底层了底层框架一般直接处理根本不会上抛到业务层。但正因为透明很多人会忽略它们对连接稳定性的价值。我排查过一个真实问题某客户端连接服务器后过几小时就“死”掉但双方业务层都没有收到任何错误。后来抓包发现客户端所在网络环境有一个空闲超时机制只要连接上 5 分钟没有任何数据包经过就会被中间设备强制静默丢弃。而我们的应用层因为没有心跳系统一直以为连接活着。解决方案就是定期发 Ping客户端每 30 秒发一个 Ping 帧服务器必须立刻回复 Pong 帧。这个 Ping/Pong 往返在协议层就完成了不经过业务解析。Ping 帧同样有载荷不超过 125 字节的限制标准做法是回一个内容相同的 Pong。为什么把心跳放在协议层而不是业务层因为业务层心跳是业务消息会被并发、排队、消费慢影响很难区分“连接死了”和“业务处理慢”。而 Pong 是协议强制立即响应的控制帧如果发了 Ping 之后一段时间没收到对应 Pong那基本可以断定链路出了问题。2.4 控制帧的四条铁律把控制帧的规则归结起来其实就是四条铁律控制帧的 FIN 必须为 1不允许分片因为控制帧本身太小没必要拆。控制帧的载荷长度必须小于等于 125所以不能携带大的自定义数据。控制帧必须被及时处理不能在收到 Ping 之后先排队处理一堆数据帧最后才回 Pong。控制帧可以出现在分片消息的中间但它不能作为分片消息的一部分。第四条其实挺有趣。你可以在一个长消息中间插入一个 Ping对方可以立刻回复 Pong而不用等长消息收完。这样做不会干扰消息重组但如果你在一个未完成的分片消息中间又收到一个 opcode 非 0x0 的数据帧那就是协议错误。这两者要分清控制帧是“插队”允许的数据帧是“插队”不允许的。理解分片消息之后你就能看懂 0x0 连续帧的意义了。发送一个大文本消息时可以把消息拆成多个帧第一个帧 opcode0x1 且 FIN0中间若干个 opcode0x0 且 FIN0最后一个 opcode0x0 且 FIN1。收到方必须缓存所有分片直到 FIN1 才能拼出完整消息。这个机制特别适合大 payload 场景也是很多数据库长连接协议里“消息过大”的兜底方案。3. 传输的数据格式从“裸 JSON”到自定义协议头3.1 最常见的 Text Frame JSONWebSocket 本身不定义消息格式它只负责把字节完整地、按序地送到对端。剩下的语义完全由应用层决定。所以实际项目里最常见的做法就是文本帧 JSON。好处不用多说可读性强、跨语言友好、调试方便。但你得提前约定 JSON 的外层结构。我见过一个团队一开始服务端往客户端推消息时用{type:cmd,data:{...}}后来另一个模块直接推裸字符串客户端JSON.parse直接抛异常。这是典型的“应用层协议缺失”问题。我建议无论消息多简单至少留住三个字段类型type、序号id、载荷data。类型用于路由序号用于对齐请求与推送载荷放真正的业务数据。即使你只做一个小工具这个“壳”也会在后续扩展时救你一命。JSON 还有一个隐性问题文本帧必须是合法 UTF-8。但 JSON 里如果出现了孤立的代理项、非法的控制字符很多解析器会因为“这不是合法 JSON”而失败。而发出这些内容的发送端往往完全无感知因为字符串拼接时它只是往字节流里塞了几个特殊码点。所以服务端收到文本帧后最好先做 UTF-8 校验再做 JSON 解析不要两步混在一起。3.2 二进制帧里放什么二进制帧适合的场景非常明确图像、音频、视频片段、序列化结构体。比如实时语音里一帧 opus 编码可能只有几十到几百字节直接塞进二进制帧非常高效。再比如屏幕共享每一帧压缩后的图像数据大小波动很大用二进制帧发就可以省掉 JSON base64 的膨胀开销。选型时可以在心里做个对比数据格式可读性序列化开销适合场景裸 JSON高中调试频繁、结构简单的消息MessagePack中低需要一定可读性的二进制场景Protobuf低极低大规模 RPC、强类型约束自定义二进制头低最低实时音视频明确知道每个字段含义有一个通用原则二进制帧不意味着你必须从头设计一套复杂的编解码。如果业务类型不多直接在第一个字节放一个type后面跟上对应结构的字节流完全够用。真正要避免的是那种“猜结构”的实现——收到二进制帧后靠消息长度和顺序反推字段边界一旦发端升级版本收端就彻底解析乱了。3.3 设计一个够用的应用层协议头每次讲到 WebSocket 传输格式我都会建议有追求的团队不要只停留在“发 JSON”这个层面。你可以基于二进制帧做一个很小的协议头把版本、类型、请求 ID、载荷长度和校验值包进去。一个够用的协议头可以长这样0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 -------------------------------- | magic0x57 | version0x01 | type uint8 | flags uint8 | -------------------------------- | message_id uint32 | -------------------------------- | payload_len uint32 | -------------------------------- | checksum uint16 | --------------------------------magic 字段用来防止把普通二进制流误识别成协议包version 字段让未来的协议升级不用推倒重来type 字段是消息路由的关键message_id 用于请求/响应关联payload_len 和 WebSocket 帧里的长度字段是两层关系因为 WebSocket 帧长度是整个二进制帧的载荷长度而这里 payload_len 是业务载荷的长度可能比帧载荷略小checksum 则用来在解包后快速校验数据完整。这套设计不是必须的但它能解决一个真实问题你无法控制 WebSocket 底层对超大消息的分片策略也无法保证所有代理服务器都不篡改 payload。有了自己的头无论底层怎么分片、重组你最后都能用 checksum 判断业务数据是否完整。3.4 要不要用子协议协商WebSocket 握手时有一个Sec-WebSocket-Protocol请求头可以用来做子协议协商。客户端先列出自己支持的协议列表比如json.v1、protobuf.v1服务端在握手响应里选一个。子协议的价值在于同一个 WebSocket 连接上双方可以提前锁定数据格式不用每个消息都带上“我是 JSON”这种自描述信息。如果你的系统未来要兼容多种消息格式强烈建议在握手阶段就把这个约定做好。需要注意的是服务端只能从客户端列出的协议里选不能自己发明一个没有经过客户端认可的协议。否则客户端收到一个不认识的子协议名握手阶段就会失败。这个字段和 MIME Type 不一样。WebSocket 没有 Content-Type它只有文本/二进制两个通道具体语义靠子协议来绑定。把子协议名当作“连接级 Content-Type”来理解非常贴切。4. 帧格式之外的经典事故粘包、半包、掩码与 UTF-8 截断4.1 TCP 不认 WebSocket 帧粘包半包是常态很多人一上来就在 socket 回调里直接“一个回调一个帧”地处理这是 WebSocket 开发里最大的误解。TCP 是字节流不是消息流。它不保证一次 read 恰好返回一个完整的 WebSocket 帧。一个帧可能被拆成多次 read多个帧也可能在一次 read 里连在一起。用快递比喻就是WebSocket 帧是标准纸箱TCP 是一条流水线传送带。纸箱可以横着放、竖着放两个纸箱还可以叠在一起传送带不会帮你分拣。你必须自己盯着传送带看到一截就判断是不是一个完整纸箱的边界。曾经有个案例某服务端用了自研协议每收到一个 TCP 数据包就解一次帧头。结果某个大 payload 被网络层拆成了两个包第一个包只包含帧头和部分数据代码直接按不完整数据进行业务处理处理完还把消息交给下游下游收到的内容永远是残缺的。排查到最后问题不是业务逻辑也不是并发而是最基本的数据包边界判断。4.2 缓冲区解析的正确姿势正确做法是维护一个接收缓冲区每次 read 后把新数据追加进去再循环尝试解析。def read_frames(sock): buffer b while True: data sock.recv(4096) if not data: break buffer data parsed, buffer try_parse_frames(buffer) for frame in parsed: handle_frame(frame) def try_parse_frames(buffer): frames [] while True: if len(buffer) 2: break b2 buffer[1] length b2 0x7F offset 2 if length 126: if len(buffer) 4: break length int.from_bytes(buffer[2:4], big) offset 4 elif length 127: if len(buffer) 10: break length int.from_bytes(buffer[2:10], big) 0x7FFFFFFFFFFFFFFF offset 10 masked (b2 7) 1 if masked: offset 4 frame_len offset length if len(buffer) frame_len: break frame_data buffer[:frame_len] buffer buffer[frame_len:] frames.append(parse_ws_frame(frame_data)) return frames, buffer关键点在于长度不够时不要丢弃数据要继续等长度够时切出完整帧后要把剩余部分放回缓冲区。很多初学者在这里把 buffer 重新赋值为空下一次 read 又从头开始结果帧边界错乱越解析越乱。另外不论读到的长度多大都要先和系统设定的“最大消息长度”比较。恶意客户端可以构造一个payload len127的帧声称有 10GB 数据如果没有上限策略你的内存会先被吃掉。正常框架默认消息上限往往在 1MB 到 64MB 之间超过就直接回 1009 状态码并关闭连接。4.3 掩码不是安全功能抓包看见乱码别慌开篇那个81 8E 3E 2F D3 74 ...的包如果直接在抓包工具里看 payload就是随机乱码。很多人第一反应是自己解密有问题其实那只是掩码之后的字节流。掩码的背景是防止早期缓存污染攻击客户端可以恶意构造一个内容看起来像 HTTP 响应的包如果中间代理不认识 WebSocket 帧就可能把这段载荷误当作普通 HTTP 响应缓存下来。加上掩码之后载荷内容不再是可预测的明文缓存代理就很难污染数据了。所以一定要记住掩码不是加密更不能当作安全机制。它用来防“意外”而不是防“攻击”。真正的数据保护还是要靠 TLS 层也就是wss://连接。不要以为掩码后别人就看不到你的聊天内容抓包工具直接还原给你看。4.4 UTF-8 被截断的坑和消息过大策略文本帧要求合法 UTF-8但网络传输不会管你的字符边界。比如汉字“你好”编码成字节是E4 BD A0 E5 A5 BD六个字节可能被 TCP 拆成E4 BD和A0 E5 A5 BD两段。如果你在每一段上都做decode(utf-8)第一段必然报错。正确做法是先按帧格式把完整 payload 切出来等确定这是一个完整帧之后再一次性解码。框架层面不会有人帮你处理“半个字符”因为协议只保证帧边界不保证字符合法性。你必须在自己的解析层把这一步扎扎实实做掉。消息过大的处理也是这样。如果一端发了 5MB 的消息而另一端没有大小限制业务还没开始内存就先爆了。多数框架支持配置max_message_size但如果你手写协议一定要在帧解析环节就判断好不要等整个大消息都进内存了才发现问题。5. 在 AI 场景里帧格式如何影响传输体验5.1 流式 token 与连接长生命周期AI 应用里 WebSocket 最常见的用法是大模型服务端通过长连接向客户端持续推送流式 token。这种场景的典型特征是连接生命周期很长消息频率很高但每个消息的 payload 可能很小有时就是一句话里的几段文本。这就产生了一个隐形开销每个 WebSocket 帧都有至少 2 字节的帧头再加上掩码和扩展长度可能变成 6 到 14 字节的额外开销。如果服务端每生成一个 token 就发一个帧一秒生成几十个 token网络包数量会非常夸张。实测下来与其一 token 一帧不如做一个小聚合在应用层把一批 token 攒成一条消息再通过一个文本帧发出去。这样既保持实时性又大幅减少小包数量。另外长连接必然伴随心跳。AI 推理有时很慢慢到客户端以为连接断了实际服务端还在算。服务端应该定期发 Ping或者客户端定期发 Ping确保中间链路不因空闲被回收。同时应用层要区分“空闲”和“故障”收到 Pong 不一定是服务端已经算完但至少证明 TCP 链路活着。5.2 多模态音频/图像的二进制帧问题多模态场景里客户端可能一边上传音频流一边接收文本回复。音频流数据量不大但实时性要求高用文本帧 base64 编码显然浪费带宽和 CPU。更合理的是用二进制帧直接发送编码后的 opus 或 PCM 数据。这里要注意分片结构。音频数据经常是持续产生的不可能等一整段完整音频才发送。你可以每一小块音频作为一个独立的二进制消息也可以把一段音频拆分成分片消息。前者实现简单但消息数量大后者需要自己处理 continuation 帧的拼接稍微复杂一点。我的经验是别轻易用分片除非单个音频消息可能超过几十 KB。否则独立小消息 时间戳字段组合比分片拼接可靠得多。图像传输也类似。一帧屏幕截图压缩后可能超过 100KB这种大 payload 如果不走分片代理层和中间设备可能会缓冲整帧后再转发导致延迟波动。实践里超过 64KB 的消息就应该考虑分片发送并且在应用层加上消息 ID方便接收方把分片重组起来。5.3 抓包工具与诊断清单排查 WebSocket 问题时我最常用的工具就是抓包工具的 WebSocket 过滤器和浏览器开发者工具里的连接面板。前者能看到原始字节流和每一帧的十六进制内容后者能直接显示解析后的 payload 文本。两者配合使用基本能覆盖绝大多数问题。如果你自己手写了解析层我建议你按下面的清单过一遍第一个字节的 FIN 和 Opcode 是否符合业务预期第二个字节的 MASK 位是否正确客户端发给服务器应等于 1服务器发给客户端应等于 07 位长度是不是 126/127扩展长度是否正确读出如果带了掩码masking key 是不是正好 4 字节有没有收到心跳响应多久没收到 Pong 了帧长度有没有超过配置的最大消息上限每次排查都按照这个清单走能把大部分帧格式问题压缩到十分钟内定位。很多看起来像是业务 bug 的“消息读取不完整”“数据诡异化”本质上都是帧边界没切对或长度字段读错。最后再分享一个我自己的习惯无论用现成框架还是自研协议团队里至少要有一个成员能够不看文档、手工把一个 WebSocket 帧从字节流里解出来。只要有人能做到这一步绝大多数的连接问题都不会成为事故顶多算小插曲。如果你还没试过手工解析建议拿本文开篇的81 8E 3E 2F D3 74 56 4A BF 18 51 03 F3 03 51 5D BF 10 1F 0E跑一遍代码把那串密文还原成明文你对帧格式的理解会上一个新台阶。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑