深入理解 IM 系统

旁支 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 的几倍大?

点一个字段,看它在两边各占哪些字节

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,表示后面还有字节。

  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,这条消息用不到。)编码文档用 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 几乎同时发了一句话,两个人看到的顺序却不一样。