性能优化2026年11月11日· 约 16 分钟

从一段 WebSocket 代码聊开:bytes 不是 zlib、binaryType 决定一切,15MB 大消息的零拷贝优化

#WebSocket#消息序列化#零拷贝#字节处理
Twitter 微博

从一段 WebSocket 代码聊开:bytes 不是 zlib、binaryType 决定一切,15MB 大消息的零拷贝优化

AI 生成声明:本文内容由 AI 生成或经 AI 辅助完成,请注意甄别。

有同学在项目里写了这样一段 WebSocket 消息序列化代码,然后在评审时连续抛了几个好问题:

message = json.dumps({
    "type": event_type.value,
    "data": data
}, ensure_ascii=False, cls=CustomEncoder, indent=2)

is_binary = event_type == EventType.AFTER_DATA message_bytes = message.encode('utf-8') if is_binary else None

围绕这 7 行代码,我们把下面这些话题从"看一眼就懂"聊到了"真正踩过坑才会注意"的层次:

  1. message_bytes 是 zlib 压缩流吗?为什么一看到 encode('utf-8') 就能断定不是?
  2. "类型上是 bytes 叫二进���" 和 "内容上是真的二进制格式" 是两回事——怎么区分?
  3. 为什么后端明明发了 bytes,前端拿到的还是 Blob 而不是 ArrayBuffer?
  4. 一条 15MB 的消息,Blob 收 vs ArrayBuffer 收,性能差距能有多大?
  5. 后端要不要加 zlib.compress?前端要不要 pako.inflate?为什么?
  6. Python 没有 ArrayBuffer,那为什么"后端不用改成 ArrayBuffer"?

逐条展开,先从最容易被误解的"zlib 头"说起。


一、message_bytes 是 zlib 头吗?——看头两字节就够了#

答案很直接:不是。message_bytes 只是 UTF-8 编码后的 JSON 文本字节,这段代码从头到尾没有调用过 zlib。

判断依据分三层:

1. 代码里根本没有 zlib 调用#

message_bytes = message.encode('utf-8') if is_binary else None

整段只有 json.dumps + str.encode('utf-8')。既没有 import zlib,也没有 zlib.compress(...)。json.dumps 产出的是 str,encode('utf-8') 产出的是 bytes——是"文本转字节",不是"压缩流"。

2. zlib 头 vs JSON 头#

zlib 压缩流的开头是两字节 CMF/FLG,常见组合固定:

头两字节 压缩级别
0x78 0x01 低级别
0x78 0x5E 级别 1~5
0x78 0x9C 默认级别(最常见)
0x78 0xDA 最佳级别

而 message_bytes 是 JSON 文本的 UTF-8 编码,第一个字节是 { → 0x7B,然后是 " → 0x22、t、y……开头就是可读的 JSON。0x7B 和 0x78 一眼就能分辨。

3. 快速验证方法#

如果你手里有实际发出的字节,直接看前四个字节的 hex:

print(message_bytes[:4].hex())
# 带 indent=2 的 JSON 开头会是: 7b0a2020 → 对应 {\n␣␣
# 如果是 zlib 默认压缩:          789cxxxx  → 典型 zlib 头

二、��它是"二进制"吗?——两种含义别混淆#

这是讨论里最容易绕进去的点。"二进制"这个词有两层完全不同的含义:

从 Python 类型看:是二进制#

message.encode('utf-8') 返回 bytes 类型——Python 里它就叫"二进制数据"(binary data),和 str 是两个独立的类型。在 WebSocket 接口里也走不同分支:

  • ws.send(str) → 文本帧(text frame)
  • ws.send(bytes) → 二进制帧(binary frame)

所以这段代码的作者把开关叫 is_binary、把变量叫 message_bytes,完全合理——它确实是"以二进制方式发送"。

从内容看:它只是文本的字节表示#

关键在于,这个 bytes 的内容是一段 JSON 文本:

7b 0a 20 20 22 74 79 70 65 ...
 ├─ 对应 ─┤
{
  "type

每个字节都对应一个可读字符,整体就是文本按 UTF-8 规则翻译成的字节序列。它不是压缩流、不是自定义协议帧、不是 protobuf/msgpack 这类真·二进制格式。

一句话把两层说清#

message_bytes 在类型上是 bytes(二进制类型),发送时确实会以二进制帧送出去;但它的内容只是 UTF-8 编码的 JSON 文本。

换句话说:用二进制通道发送了一段文本,不是发送了真正意义上的二进制协议数据。


三、为什么后端发的是 bytes,前端拿到的还是 Blob?#

这是最关键的误解源头。讨论到这里才引出了那张 15MB 大消息的对比图:

后端用 bytes 发 → WebSocket 走二进制帧 → 浏览器收到这帧二进制。但是浏览器默认把二进制帧包装成 Blob 交给前端代码,而不是 ArrayBuffer。

Blob vs ArrayBuffer 对比#

对比维度 Blob(默认) ArrayBuffer
本质 类文件对象,底层数据不一定连续 固定长度的原始字节缓冲区
拿原始字节 需要 await blob.arrayBuffer() 异步读 直接就是字节,new Uint8Array(buf) 同步访问
内存拷贝 读取时多一次拷贝 零拷贝视图,Uint8Array 直接引用底层内存
能否直接解压 不能,先异步转 ArrayBuffer 才能喂 pako 可以,直接 pako.inflate(new Uint8Array(buf))
15MB 大消息 异步等待 + 拷贝开销显著 立即可用,直接进解压/解析

决定一切的那一行#

ws.binaryType = 'arraybuffer';  // 加上这个

不设这行 → 所有二进制帧都是 Blob。设了 → 直接拿到 ArrayBuffer。后端发什么、怎么发,完全不影响这一选择。


四、前端该怎么改?——三种配套写法#

最小改动版#

const ws = new WebSocket('ws://your-server/...');
ws.binaryType = 'arraybuffer'; // 关键这一行

ws.onmessage = (event) => { const text = new TextDecoder('utf-8').decode(event.data); const msg = JSON.parse(text); console.log(msg.type, msg.data); };

new WebSocket(...) 之后立刻设就行,不用等 onopen。只要在第一个二进制消息到达前设好就生效。

更稳妥:复用 decoder + 区分帧类型#

后端 is_binary 为 False 时发的是 str(文本帧),前端收到的是 string;True 时发 bytes,前端才收到 ArrayBuffer。两种帧要分开处理:

const ws = new WebSocket('ws://your-server/...');
ws.binaryType = 'arraybuffer';

const decoder = new TextDecoder('utf-8');

ws.onmessage = (event) => { let text; if (typeof event.data === 'string') { // 后端 is_binary=False:文本帧,直接拿 string text = event.data; } else { // 后端 is_binary=True:二进制帧,ArrayBuffer → 文本 text = decoder.decode(event.data); } const msg = JSON.parse(text); handle(msg.type, msg.data); };

和后端的 is_binary = event_type == EventType.AFTER_DATA 完全对齐。

如果后端将来加了 zlib 压缩#

假设后端把 message.encode('utf-8') 包了一层 zlib.compress(...),前端就要先解压。好在 zlib 头 0x78 xx 和未压缩 JSON 头 0x7B 永远不会撞,靠头字节就能安全区分:

import pako from 'pako';

const ws = new WebSocket('ws://your-server/...'); ws.binaryType = 'arraybuffer'; const decoder = new TextDecoder('utf-8');

ws.onmessage = (event) => { if (typeof event.data === 'string') { handle(JSON.parse(event.data)); return; }

const bytes = new Uint8Array(event.data); // 零拷贝视图 const text = (bytes.length >= 2 && bytes[0] === 0x78) ? decoder.decode(pako.inflate(bytes)) // zlib 压缩 : decoder.decode(bytes); // 未压缩的 UTF-8 文本 handle(JSON.parse(text)); };

new Uint8Array(event.data) 是零拷贝的——它不复制数据,只是给 ArrayBuffer 套一层可读写视图。这正是"ArrayBuffer 快"的核心原因:整条链路里没有一次额外的内存拷贝。


五、后端需要改吗?——看你的目标#

只为让前端拿到 ArrayBuffer → 后端完全不用动#

Python 里根本没有 ArrayBuffer 这个类型。后端能做的只有往网络写字节,字节到浏览器之后,是 Blob 还是 ArrayBuffer 完全由前端 binaryType 决定。整条链路:

Python bytes → 网络字节流 → 浏览器 → binaryType='arraybuffer' → JS 拿到 ArrayBuffer
                              └→ binaryType 未设 (默认 blob)  → JS 拿到 Blob

后端那一格始终是 bytes。后端唯一要确认的是:调用发送接口时传的是 message_bytes(bytes)而不是 message(str)——传错了就变成文本帧,前端无论怎么设 binaryType 都拿不到 ArrayBuffer。

如果想进一步优化那 15MB 大消息 → 后端加 zlib 压缩#

当前代码发的是未压缩的 UTF-8 JSON。15MB 级别的 JSON 文本可压缩性非常高(压缩比常到 5~10 倍),值得在后端压一下再发:

import zlib

COMPRESS_THRESHOLD = 1024 # 1KB 以上才压,避免小消息压完反而变大

if is_binary: raw = message.encode('utf-8') if len(raw) >= COMPRESS_THRESHOLD: message_bytes = zlib.compress(raw, level=6) # 默认级别,性价比最高 else: message_bytes = raw # 小消息不压,直接发 else: message_bytes = None

压缩收益是双重的:

  • 网络传输量直接降到几分之一,跨机房或公网链路最明显
  • 前端链路配合 ArrayBuffer + pako,全程零拷贝。换成 Blob 的话,光 await blob.arrayBuffer() 这一步就是一次实打实的内存拷贝和异步等待

六、几个容易踩的坑#

1. binaryType 别写错位置#

它是 WebSocket 实例的属性,必须在实例上设。别写成 WebSocket.binaryType(类上没有),也别放到 onmessage 里面(那样第一帧可能还是 Blob)。

2. 设了 arraybuffer 就别再用 FileReader#

FileReader 是 Blob 时代的写法。现在 event.data 直接就是 ArrayBuffer,new Uint8Array(event.data) 立刻可用,一步到位。

3. TextDecoder 要复用#

每条消息 new TextDecoder('utf-8') 在高频场景下会有 GC 压力,提到模块作用域复用一个。它本身是可重复调用的。

4. 压缩级别别盲目拉满#

zlib.compress(..., level=6) 是默认级别,压缩率和 CPU 耗时的性价比最高。level=9 压得再多一点点,但 CPU 明显更贵。高频小消息甚至可以 level=1 求速度。

5. 缩进空格在压缩场景下不重要#

json.dumps(..., indent=2) 产出的缩进空格会被 zlib 轻松压掉,对最终传输体积影响很小。但如果后端在序列化阶段就让内存吃紧(15MB 字符串本身占 15MB 内存),去掉 indent 能让中间字符串小一截,省的是后端内存而不是网络。


总结#

回到开头那 7 行代码,讨论完一圈得到的核心结论:

  1. message_bytes 是 UTF-8 编码的 JSON 文本字节,不是 zlib 流——看头两字节 0x7B 就确定了
  2. "是二进制"指的是类型是 bytes,内容还是 JSON 文本——两种含义别混淆
  3. 后端发 bytes 就够了,前端拿 Blob 还是 ArrayBuffer 完全由 ws.binaryType 决定
  4. 15MB 大消息:ArrayBuffer + Uint8Array 零拷贝视图比 Blob + FileReader 异步读快一截
  5. 想进一步省网络:后端加 zlib.compress,前端加 pako.inflate,靠 0x78 vs 0x7B 头安全区分
  6. Python 没有 ArrayBuffer,也不需要有——后端永远只管发 bytes,协议形态由前端选

整条 WebSocket 二进制通道的"性能差",往往就藏在这几行设置和头字节判断里。


原文链接:https://www.cnblogs.com/yellow3gold/p/23630713

评论

© 2026 松岛川树