从一段 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 行代码,我们把下面这些话题从"看一眼就懂"聊到了"真正踩过坑才会注意"的层次:
message_bytes是 zlib 压缩流吗?为什么一看到encode('utf-8')就能断定不是?- "类型上是
bytes叫二进制" 和 "内容上是真的二进制格式" 是两回事——怎么区分? - 为什么后端明明发了
bytes,前端拿到的还是Blob而不是ArrayBuffer? - 一条 15MB 的消息,
Blob收 vsArrayBuffer收,性能差距能有多大? - 后端要不要加
zlib.compress?前端要不要pako.inflate?为什么? - 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 行代码,讨论完一圈得到的核心结论:
message_bytes是 UTF-8 编码的 JSON 文本字节,不是 zlib 流——看头两字节0x7B就确定了- "是二进制"指的是类型是
bytes,内容还是 JSON 文本——两种含义别混淆 - 后端发
bytes就够了,前端拿 Blob 还是 ArrayBuffer 完全由ws.binaryType决定 - 15MB 大消息:
ArrayBuffer+Uint8Array零拷贝视图比Blob+FileReader异步读快一截 - 想进一步省网络:后端加
zlib.compress,前端加pako.inflate,靠0x78vs0x7B头安全区分 - Python 没有 ArrayBuffer,也不需要有——后端永远只管发
bytes,协议形态由前端选
整条 WebSocket 二进制通道的"性能差",往往就藏在这几行设置和头字节判断里。
原文地址: https://www.cveoy.top/t/topic/qHWd 著作权归作者所有。请勿转载和采集!