旁支 W2
编码
同一条消息,写成 JSON 和写成二进制,差多少字节?把 300 写成 varint(AC 02)。
这一章是初稿,会在写完前八章后再修订。
旁支 W1 在每条消息前写上长度,把字节流切成了一帧一帧。帧里面的字节怎么排,还没说。
到第 4 章为止,服务器推给 Ben 的一条消息有这几样东西:服务器存下时给的 id(第 1 章),手机发送前起的消息 ID消息 IDmessage ID客户端在发送之前给每条消息生成的唯一 ID,重发时不变。服务端靠它认出重发的消息。在术语表里查看 msg_id(第 4 章,128 位随机数),会话 conversation_id,发送者 sender_id,正文 text,服务器收到的时间 created_at(毫秒)。最常见的写法是 JSON:
{"id":482913207,"msg_id":"744ea99e-2638-aecd-4b0a-119e631d2d54","conversation_id":1204387,"sender_id":300,"text":"我到楼下了","created_at":1791198000412}
已经去掉了所有空格,msg_id 写成通常的 UUID 字符串。另一种常见写法是 Protobuf:先在一份 schemaschema(消息定义)用一份文件写下每种消息有哪些字段、什么类型、各自的编号,再由它生成服务端和各个 App 的代码。Protobuf 的 .proto 文件就是一种。在术语表里查看 里给每个字段一个编号,线上只写编号和值,不写名字。
message Message {
uint64 id = 1;
bytes msg_id = 2; // 16 个字节
uint64 conversation_id = 3;
uint64 sender_id = 4;
string text = 5;
uint64 created_at = 6; // 毫秒
}
先说清楚:这一篇里没有东西会坏。JSON 是很体面的选择:Slack 的 Socket Mode 推给应用的事件是 JSON,Discord 的 gateway 可以选 JSON,Matrix 从头到尾都是 JSON。要回答的是:两种写法差在哪,差的这些值不值得换。
1. 同一条消息,两种字节
先猜:Ana 的“我到楼下了”,服务器推给 Ben 时写成 JSON,是写成 Protobuf 的几倍大?
点一个字段,看它在两边各占哪些字节
JSON · 158 字节
ab字段名 / tag 和长度12值我一个汉字 3 字节,占 3 格
- JSON
- 158 字节
- Protobuf
- –
- JSON ÷ Protobuf
- –
JSON 158 字节,Protobuf 55 字节,2.9 倍。点一下各个字段,看字节花在哪:
- 字段名。JSON 每条消息都把
"conversation_id":这些名字写一遍,6 个名字连引号和冒号一共 64 字节,加上大括号和逗号 7 字节;Protobuf 每个字段只写一个字节的 tag(字段编号加类型,第 2 节细说)。 - 数字。JSON 写十进制字符,一位一个字节:
created_at是 13 个字节;Protobuf 用 varintvarint(变长整数)小的数用少的字节:每个字节放 7 位,最高位表示“后面还有”。0–127 一个字节,300 是 AC 02 两个字节。Protobuf 的整数和字段编号都这样写。在术语表里查看(小的数占的字节少,第 2 节细说),同一个数 6 个字节。 msg_id。16 个随机字节,JSON 写成 UUID 是 36 个字符再加引号;Protobuf 直接放 16 个原始字节,加 2 个字节的 tag 和长度。(JSON 里改用不带填充的 base64 是 22 个字符,能省 14 字节。)- 正文。两边一样:都是同样的 15 个 UTF-8 字节,“我到楼下了”一个字 3 字节。
所以正文越长,倍数越小。正文 20 个汉字时是 203 对 100 字节,2.0 倍;50 个汉字时 293 对 191,1.5 倍。
2. 把 300 写成 varint
Ana 是 App 的第 300 个用户,sender_id 是 300。Protobuf 里这个字段是 20 ac 02 三个字节。
tag:字段编号 << 3 | 类型。sender_id 是 4 号字段,类型 0 是 varint,4 << 3 | 0 = 32 = 0x20。类型 2 是“后面跟长度”,用于字符串和字节,所以 text(5 号)的 tag 是 5 << 3 | 2 = 0x2a。编号 1 到 15 的 tag 只占一个字节,Protobuf 的文档建议把它们留给最常出现的字段。
值:varint 每个字节放 7 位,从低位开始;最高位是 1,表示后面还有字节。
- 300 的二进制是
1 0010 1100,9 位,放不进一个字节的 7 位。 - 先取低 7 位
010 1100=0x2c;后面还有,最高位置 1:0x2c | 0x80=ac。 - 剩下的
300 >> 7= 2,放得下,最高位是 0:02。
所以 300 是 ac 02。0 到 127 一个字节,到 16,383 两个字节,服务器 id 482,913,207 是 5 个字节,毫秒时间戳是 6 个字节。(负数另有写法,叫 zigzag,这条消息用不到。)编码文档用 150 → 96 01 讲同一件事。
3. 估算:这些字节值多少
先压缩一下。 WebSocket 有个标准扩展 permessage-deflate,浏览器会主动要求,服务端开了就用。它默认在一条连接上共享压缩上下文:后一条消息可以引用前面消息里出现过的字节,JSON 里一遍遍重复的字段名几乎不再占地方。
拿一个会话里的 50 条消息来算(这里假设的一批:Ana 和 Ben 轮流说,正文是互不相同的短句),平均每条:
| 平均每条(字节) | JSON | Protobuf | 倍数 |
|---|---|---|---|
| 不压缩 | 160.8 | 58.3 | 2.76 |
| 单独压缩 | 144.6 | 62.2 | 2.32 |
| 共享上下文 | 70.7 | 47.7 | 1.48 |
| 整页压缩 | 52.7 | 43.2 | 1.22 |
- 不压缩:服务端没开压缩时。这一行是一整页的平均:一页里 Protobuf 每条多 2 字节的 tag 和长度(JSON 只多一个逗号),一半消息是 Ben 发的,他的 id 也更长,所以是 2.76 倍,不是 2.9 倍。
- 单独压缩:每条消息从零开始压(协商了 no_context_takeover)。短消息几乎压不动,二进制的反而变大(58.3 → 62.2),所以服务端一般只压大于某个大小的消息。
- 共享上下文:一条一条推送,压缩器一直留着。平常聊天就是这一行:消息一条条推给在线的 Ben。
- 整页压缩:50 条放进一条消息一起压。手机断线回来、一次补拉一批消息时是这一行。
压缩把 JSON 的大头吃掉了,剩下的差距主要在 UUID 的十六进制字符和十进制数字上。
服务器:v1 高峰每秒 1,390 次投递,每次多 158 − 55 = 103 字节,一共每秒约 143 KB,1.15 Mbit/s。这对一台服务器不算什么;对手机,一条消息多一百来个字节,同样不算什么。
压缩也有代价:共享上下文就要为每条连接留着压缩的状态。按 zlib 的说明,默认参数下服务器发消息用的压缩器约 268 KB;手机发上来的消息也压缩的话,还要一个解压器,约 40 KB;一条连接一共约 308 KB,而全书算的一条空闲连接是 30 KB。v1 的 1 万条连接就是 3.08 GB。RFC 7692 允许协商更小的窗口(256 字节到 32 KB),但它只缩小窗口那部分:窗口 1 KB 时一条连接还要约 150 KB,因为压缩器里 131 KB 的哈希表和输出缓冲由服务器自己的 memLevel 决定,和窗口无关(调小它也压得更差)。
解析要花多少 CPU,取决于语言和库,这里没有能直接搬来的公开数字,就不估了。
结论:在 v1 的规模下,字节不是换格式的理由。 不压缩时差 2.9 倍,可这点带宽不值钱;压缩以后只差 1.2–1.5 倍,代价是每条连接几百 KB 的内存。
4. 真正的理由:新老版本要一起用好几年
第 5 章会给消息加一个序号(seq)序号(seq)会话里每条消息的编号,由负责这个会话的消息服务连续分配。它决定会话里的顺序,也让客户端发现缺了哪一条;手机回来时,也按它判断哪个会话落后了。在术语表里查看。新服务端从那天起每条消息都带 seq,可用户手机上的 App 不会同一天升级,有人一年都不升。打开场景里的“加上第 5 章的 seq”:JSON 多了 "seq":1284 11 个字节,Protobuf 多了 38 84 0a 3 个字节。旧版 App 读到这条消息会怎样?
- Protobuf:
0x38是7 << 3 | 0,旧版的代码里没有 7 号字段,但从类型 0 就知道后面是一个 varint,读完跳过就行。跳过不认识的字段是格式本身的规定,各语言的官方实现都这样做,proto3 还会把它们原样留着,转发时不丢。 - JSON:取决于每个端用的解析库。多数默认忽略不认识的键:Swift 的 Codable、Android 上的 Gson 和 Moshi、Go 的
encoding/json,网页的JSON.parse本来就什么都收。也有默认严格的:Kotlin 的 kotlinx.serialization 默认ignoreUnknownKeys = false,Java 的 Jackson 默认开着FAIL_ON_UNKNOWN_PROPERTIES,遇到不认识的键就抛异常。只要有一个端、一个版本是严格的,加字段那天它就读不了消息。JSON 也能跳过新字段,只是这靠每个端都记得配置,是一条约定,不是格式的规定。
还有几条,也都是“很多版本、很多端”的问题:
- 名字和编号分开。线上只有编号,字段可以随便改名;规则只有一条,编号永远不重用,删掉的字段写进
reserved,免得以后有人拿同一个编号表示别的东西,文档列了重用的后果:解析出错、数据错乱,甚至泄露数据。 - 一份 schema 生成各端的代码:Go、Swift、Kotlin、TypeScript 从同一份
.proto生成结构和编解码代码,类型在编译时检查,不用四个端各手写一份。 - 64 位整数:JavaScript 的数字只在 2^53(约 9×10^15)以内是精确的。v1 的 id 远小于它;只有换成 Snowflake 那样的 64 位 ID 时,JSON 里才得写成字符串,Discord 就是这样做的。
公平地说,JSON 也可以配一份 schema(JSON Schema、OpenAPI)并生成代码。所以真正的决定是“有一份带编号的 schema”;有了它,线上用二进制几乎是白送的,顺便省下 2.9 倍的字节,还不用为每条连接留一个压缩器。
5. 代价
- 看不懂了。抓包或者打日志,看到的是
08 b7 d7 a2 e6 01 12 10 …。没有 schema,protoc --decode_raw只能告诉你“1 号字段是个 varint”。所以要有工具:日志里用 Protobuf 的 JSON 映射打印;调试代理要能拿到 schema。注意这个 JSON 映射的解析器默认拒绝不认识的字段,和二进制正好相反,用它时要把“忽略未知字段”打开。 - 多一个仓库、多一步构建。schema 要有一个各端共用的地方,各端构建时生成代码,版本要对上。网页端要多带一个库。
- 规矩要守。编号不能重用,类型不能随便改(
uint64改成string就读不出来了);schema 的改动要有人审。 - 对外不友好。给第三方的开放接口、webhook,人家要的是能直接读的 JSON。
6. 其他答案
- Telegram 用自己的 TL 序列化:也有 schema,也是二进制;整数是定长的 4 或 8 字节,不用 varint。代价是多几个字节,换来对齐、好算;而且只有 Telegram 自己的工具认它。
- Signal 用 Protobuf:服务端的
Envelope、libsignal 的SignalMessage都是.proto定义的,代价就是第 5 节那些。 - Discord 的 gateway 可以选 JSON 或 ETF(Erlang 自带的二进制格式),再选
zlib-stream或zstd-stream压缩,每条连接一个压缩上下文。代价是每条连接的压缩内存,换来人人能读的 JSON。 - Matrix 全用 JSON,要签名的地方用 canonical JSON(同一个对象只有一种写法)。代价是字节,换来任何人都能用任何语言写一个服务器或客户端。
- MQTT 只搬字节,消息体写成什么由应用决定,所以这个决定还是你的;它自己报文头里的长度用的也是每字节 7 位的变长整数。
- CBOR(RFC 8949)和 MessagePack(msgpack.org)是“二进制的 JSON”:数字更短,但通常写成带字段名的 map,格式里也不带 schema(CBOR 可以另配 CDDL)。缺的是兼容规则,得自己约定。
7. 这一篇的决定
帧切好了,帧里怎么写也定了。主线从第 5 章“顺序”继续:Ana 和 Ben 几乎同时发了一句话,两个人看到的顺序却不一样。