# 旁支 W2：编码

> 同一条消息，写成 JSON 和写成二进制，差多少字节？把 300 写成 varint（AC 02）。

深入理解 IM 系统 · https://im.liko.page/zh/encoding/

[旁支 W1](/zh/framing/) 在每条消息前写上长度，把字节流切成了一帧一帧。帧里面的字节怎么排，还没说。

到[第 4 章](/zh/duplicates/)为止，服务器推给 Ben 的一条消息有这几样东西：服务器存下时给的 `id`（第 1 章），手机发送前起的消息 ID `msg_id`（第 4 章，128 位随机数），会话 `conversation_id`，发送者 `sender_id`，正文 `text`，服务器收到的时间 `created_at`（毫秒）。最常见的写法是 JSON：

```json
{"id":482913207,"msg_id":"744ea99e-2638-aecd-4b0a-119e631d2d54","conversation_id":1204387,"sender_id":300,"text":"我到楼下了","created_at":1791198000412}
```

已经去掉了所有空格，`msg_id` 写成通常的 UUID 字符串。另一种常见写法是 Protobuf：先在一份 schema 里给每个字段一个编号，线上只写编号和值，不写名字。

```protobuf
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](https://docs.slack.dev/apis/events-api/using-socket-mode) 推给应用的事件是 JSON，Discord 的 gateway 可以选 JSON，Matrix 从头到尾都是 JSON。要回答的是：两种写法差在哪，差的这些值不值得换。

## 1. 同一条消息，两种字节

*（互动图：请在网页上查看 https://im.liko.page/zh/encoding/）*

JSON 158 字节，Protobuf 55 字节，**2.9 倍**。点一下各个字段，看字节花在哪：

- **字段名**。JSON 每条消息都把 `"conversation_id":` 这些名字写一遍，6 个名字连引号和冒号一共 64 字节，加上大括号和逗号 7 字节；Protobuf 每个字段只写一个字节的 tag（字段编号加类型，第 2 节细说）。
- **数字**。JSON 写十进制字符，一位一个字节：`created_at` 是 13 个字节；Protobuf 用 varint（小的数占的字节少，第 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 的文档](https://protobuf.dev/programming-guides/proto3/#assigning)建议把它们留给最常出现的字段。

**值**：varint 每个字节放 7 位，从低位开始；最高位是 1，表示后面还有字节。

1. 300 的二进制是 `1 0010 1100`，9 位，放不进一个字节的 7 位。
2. 先取低 7 位 `010 1100` = `0x2c`；后面还有，最高位置 1：`0x2c | 0x80` = **`ac`**。
3. 剩下的 `300 >> 7` = 2，放得下，最高位是 0：**`02`**。

所以 300 是 `ac 02`。0 到 127 一个字节，到 16,383 两个字节，服务器 id 482,913,207 是 5 个字节，毫秒时间戳是 6 个字节。（负数另有写法，叫 zigzag，这条消息用不到。）[编码文档](https://protobuf.dev/programming-guides/encoding/)用 150 → `96 01` 讲同一件事。

## 3. 估算：这些字节值多少

**先压缩一下。** WebSocket 有个标准扩展 [permessage-deflate](https://www.rfc-editor.org/rfc/rfc7692)，浏览器会主动要求，服务端开了就用。它默认在一条连接上**共享压缩上下文**：后一条消息可以引用前面消息里出现过的字节，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 的说明](https://zlib.net/zlib_tech.html)，默认参数下服务器发消息用的压缩器约 **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 章](/zh/ordering/)会给消息加一个序号（seq）。新服务端从那天起每条消息都带 `seq`，可用户手机上的 App 不会同一天升级，有人一年都不升。打开场景里的“加上第 5 章的 seq”：JSON 多了 `"seq":1284` 11 个字节，Protobuf 多了 `38 84 0a` 3 个字节。旧版 App 读到这条消息会怎样？

- **Protobuf**：`0x38` 是 `7 << 3 | 0`，旧版的代码里没有 7 号字段，但从类型 0 就知道后面是一个 varint，读完跳过就行。[跳过不认识的字段](https://protobuf.dev/programming-guides/proto3/#unknowns)是格式本身的规定，各语言的官方实现都这样做，proto3 还会把它们原样留着，转发时不丢。
- **JSON**：取决于每个端用的解析库。多数默认忽略不认识的键：Swift 的 Codable、Android 上的 Gson 和 [Moshi](https://square.github.io/moshi/1.x/moshi/moshi/com.squareup.moshi/-json-adapter/fail-on-unknown.html)、Go 的 [`encoding/json`](https://pkg.go.dev/encoding/json#Unmarshal)，网页的 `JSON.parse` 本来就什么都收。也有默认严格的：Kotlin 的 kotlinx.serialization [默认 `ignoreUnknownKeys = false`](https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/ignore-unknown-keys.html)，Java 的 Jackson [默认开着 `FAIL_ON_UNKNOWN_PROPERTIES`](https://github.com/FasterXML/jackson-databind/blob/2.18/src/main/java/com/fasterxml/jackson/databind/DeserializationFeature.java)，遇到不认识的键就抛异常。只要有一个端、一个版本是严格的，加字段那天它就读不了消息。JSON 也能跳过新字段，只是这靠每个端都记得配置，是一条约定，不是格式的规定。

还有几条，也都是“很多版本、很多端”的问题：

- **名字和编号分开**。线上只有编号，字段可以随便改名；规则只有一条，**编号永远不重用**，删掉的字段写进 `reserved`，免得以后有人拿同一个编号表示别的东西，[文档](https://protobuf.dev/programming-guides/proto3/#deleting)列了重用的后果：解析出错、数据错乱，甚至泄露数据。
- **一份 schema 生成各端的代码**：Go、Swift、Kotlin、TypeScript 从同一份 `.proto` 生成结构和编解码代码，类型在编译时检查，不用四个端各手写一份。
- **64 位整数**：JavaScript 的数字只在 2^53（约 9×10^15）以内是精确的。v1 的 id 远小于它；只有换成 Snowflake 那样的 64 位 ID 时，JSON 里才得写成字符串，Discord [就是这样做的](https://docs.discord.com/developers/reference#snowflakes)。

公平地说，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 映射的解析器[默认拒绝不认识的字段](https://protobuf.dev/programming-guides/json/)，和二进制正好相反，用它时要把“忽略未知字段”打开。
- **多一个仓库、多一步构建**。schema 要有一个各端共用的地方，各端构建时生成代码，版本要对上。网页端要多带一个库。
- **规矩要守**。编号不能重用，类型不能随便改（`uint64` 改成 `string` 就读不出来了）；schema 的改动要有人审。
- **对外不友好**。给第三方的开放接口、webhook，人家要的是能直接读的 JSON。

## 6. 其他答案

- **Telegram** 用自己的 [TL 序列化](https://core.telegram.org/mtproto/serialize)：也有 schema，也是二进制；整数是定长的 4 或 8 字节，不用 varint。代价是多几个字节，换来对齐、好算；而且只有 Telegram 自己的工具认它。
- **Signal** 用 Protobuf：服务端的 [`Envelope`](https://github.com/signalapp/Signal-Server/blob/main/service/src/main/proto/TextSecure.proto)、libsignal 的 [`SignalMessage`](https://github.com/signalapp/libsignal/blob/main/rust/protocol/src/proto/wire.proto) 都是 `.proto` 定义的，代价就是第 5 节那些。
- **Discord** 的 gateway 可以选 [JSON 或 ETF](https://docs.discord.com/developers/events/gateway#encoding-and-compression)（Erlang 自带的二进制格式），再选 `zlib-stream` 或 `zstd-stream` 压缩，每条连接一个压缩上下文。代价是每条连接的压缩内存，换来人人能读的 JSON。
- **Matrix** 全用 JSON，要签名的地方用 [canonical JSON](https://spec.matrix.org/latest/appendices/#canonical-json)（同一个对象只有一种写法）。代价是字节，换来任何人都能用任何语言写一个服务器或客户端。
- **MQTT** 只搬字节，[消息体写成什么由应用决定](https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html)，所以这个决定还是你的；它自己报文头里的长度用的也是每字节 7 位的变长整数。
- **CBOR**（[RFC 8949](https://www.rfc-editor.org/rfc/rfc8949)）和 **MessagePack**（[msgpack.org](https://msgpack.org/)）是“二进制的 JSON”：数字更短，但通常写成带字段名的 map，格式里也不带 schema（CBOR 可以另配 [CDDL](https://www.rfc-editor.org/rfc/rfc8610)）。缺的是兼容规则，得自己约定。

## 7. 这一篇的决定

**决策卡**

- 问题：一条消息写成 JSON 是 158 字节，写成 Protobuf 是 55 字节；压缩以后只差 1.2–1.5 倍，v1 高峰多出的带宽约 1.15 Mbit/s，不值一提。真正的麻烦在后面：服务端会不断加字段，而好几年里的旧版 App 都还在线。
- 选择：每种消息在一份 schema 里定义，字段有编号（Protobuf）；线上写二进制，各端代码从 schema 生成；不认识的字段跳过，编号永不重用。日志、调试工具、对外接口用它的 JSON 映射。
- 代价：抓包和日志看不懂，要靠 schema 和工具；多一个共用的 schema 仓库和一步代码生成；网页端多一个库；编号和类型的规矩要有人守。
- 什么时候重新考虑：加 seq 时（第 5 章，这是第一次加字段）；旧版 App 越积越多、要决定支持到哪个版本时（第 30 章，老版本 App 与新服务端）；ID 换成 64 位时，JSON 映射里要写成字符串。
- 其他答案：JSON 加压缩（Slack、Discord 可选、Matrix）；Telegram 的 TL；Discord 的 ETF；CBOR、MessagePack。

帧切好了，帧里怎么写也定了。主线从[第 5 章“顺序”](/zh/ordering/)继续：Ana 和 Ben 几乎同时发了一句话，两个人看到的顺序却不一样。
