TT Lab
开始
学习 学习路径 课程

改了字段编号,旧客户端悄悄读出了错误的值

一行字节里藏着字段编号

在 TT Lab 中继续学习

一句话总结

protobuf 的字节里没有字段名称。一个字节的 tag 08 表示“字段 1,varint”,后面的 96 01 就是 150。名称和声明类型是由读取方的 .proto 补上的——所以如果改了编号,解析器会在没有任何错误的情况下,把另一个字段的值读出来。

为什么需要它

JSON 像 {"qty": 3} 这样,把名称一起发送。人读起来方便,改了字段名也立刻就能发现。代价是每条消息都要把名称当作字符串带上,数字也要写成字符串。在流量很大的内部服务中,这笔开销是看得见的。

Protocol Buffers 做了相反的选择:不发送名称,只发送编号;值也不用文本,而是用最短的二进制表示来发送。作为代价,读取方必须知道 schema(.proto),而且 schema 中的编号本身就成了契约。整门课程讲的都是这个代价。只要亲手把字节读一遍,就会亲身体会到为什么会这样。

这个实验的镜像里既没有 protoc,也没有 protobuf 的 Python 包。这反而是件好事。编码文档规定的规则,用 Python 标准库写一百行就能全部实现,而亲手打开库所隐藏起来的东西,正是这个模块的目的。

工作原理

我们完全照着文档中的第一个例子来走一遍。对 message Test1 { int32 a = 1; } 设置 a = 150 并序列化,会得到三个字节。

08 96 01

先看 varint。把整数按每 7 位切开,从低位开始依次输出,如果后面还有,就把该字节的最高位(MSB)置 1。150 的二进制是 10010110。在低 7 位 0010110 上加上“后面还有”的标记,得到 10010110 = 0x96,剩下的 1 是 0x01。所以是 96 01。1 是一个字节 01,300 是 ac 02,16384 是 80 80 01——值越小,越短。下面这些值是写这篇文章时用 Python 亲自算出来的。

    1 → 01
  127 → 7f
  128 → 80 01
  150 → 96 01
  300 → ac 02
16384 → 80 80 01

tag 是按 varint 编码的 (field_number << 3) | wire_type。低 3 位是 wire type,其余是字段编号。08 是 0000 1000,所以 wire type 为 0,编号为 1。wire type 共有六种,实际会遇到的是其中四种。

编号 名称 对应的类型
0 VARINT int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 I64 fixed64, sfixed64, double
2 LEN string, bytes, 子消息, packed repeated
5 I32 fixed32, sfixed32, float

(3 SGROUP 和 4 EGROUP 用于已废弃的 group。)字段编号 1 到 15 的 tag 是一个字节,从 16 起是两个字节——(16 << 3) | 0 = 128,所以是 80 01。proto3 语言指南之所以说“把 1 到 15 分配给经常使用的字段”,原因就在这个计算里。

wire type 的作用是告知长度。VARINT 读到最高位为 0 的字节为止,I64 是 8 个字节,I32 是 4 个字节,LEN 则是紧随其后的 varint 所表示的长度。正因如此,即使遇到不认识的编号,解析器也知道该跳过多少。旧的二进制程序能毫无错误地跳过新字段,原理就在这里,下一个模块会实测这一性质。

LEN 记录是 tag 后面跟长度 varint,再跟载荷。对 message Test2 { string b = 2; } 放入 "testing",就是这样:

12 07 74 65 73 74 69 6e 67
│  │  └── "testing" 의 UTF-8 7바이트
│  └───── 길이 7
└──────── 태그 (2 << 3) | 2 = 0x12

长度不是字符数,而是字节数。一条韩文备注(四个韩文字符加一个空格)虽然只有五个字符,按 UTF-8 却是 13 个字节,所以长度 varint 也是 13。如果按字符数去数,解析器就会从错误的位置去读下一个 tag。

子消息也是 LEN。在 message Test3 { Test1 c = 3; } 中,如果 c.a = 150,就是 1a 03 08 96 01——tag 1a(3:LEN)、长度 3,然后刚才那三个字节原样放在里面。把载荷用同一个函数再解一遍,就得到里面的那条消息。

负数是个陷阱。int32 和 int64 按补码处理负数,文档对此的解释是:“作为 unsigned 64 位整数,最高位是置位的,所以十个字节全部用上”。-1 是 ff ff ff ff ff ff ff ff ff 01,十个字节。负数频繁出现的字段应当使用 sint32。sint 会先经过 ZigZag 转换——正数 p 变成 2p,负数 n 变成 2|n|-1。0→0、-1→1、1→2、-2→3,交替排列,32 位的公式是 (n << 1) ^ (n >> 31)。这样 -1 就成了 varint 1,也就是 01 一个字节。-150 的 ZigZag 是 299 → ab 02 两个字节。同一个值,一个是 10 字节,一个是 2 字节,这一点会在实验中亲自测量。

缺失的字段就是不写这条记录。既没有头部,也没有字段数量。消息不过是把记录一条条接起来,所以字段顺序也得不到保证,解析器必须能以任何顺序读取。文档明确指出:“不要假定序列化后的字节是稳定的”——即使把同一条消息序列化两次,也不保证字节相同,所以不能在它上面叠加哈希或 CRC。

packed repeated 从 Edition 2023 起是默认方式。标量的重复字段不再给每个元素都加 tag,而是把值连接在一条 LEN 记录里。对 repeated int32 e = 5 放入 1、2、3,就是 2a 03 01 02 03——一个 tag 对应三个值。文档要求,无论是以 packed 形式还是按元素逐个到来,解析器都必须两种都能读。

在现场相遇的样子

最常见的事故是“数据损坏了,却没有任何错误”。某个团队整理 schema 时,为了好看把字段编号重新编了一遍。编译通过,测试也通过了。但仍在使用旧二进制的消费方服务,开始把订单号读成了订单数量。站在解析器的立场,编号 2 上来了一个 varint,它只是按规则读了而已。wire 中没有名称,所以根本没有任何信息能告诉它“这不是 qty”。这类事故在日志里没有异常,往往要几天才能发现。

第二种是对大小的错觉。有人假定“一个整数 4 个字节”来计算容量,结果负数的 int32 每个要发十个字节,实际用量是预期的两倍还多。反过来,换成 sint32 之后,也可能缩小到一半以下。选择类型时要看值的分布,这个习惯就是这么来的。

第三种是字符串长度。自己写过解析器的人,第一次碰到的 bug,几乎无一例外是把 UTF-8 字节数与字符数混为一谈。只用 ASCII 测试永远暴露不出来,遇到第一条韩文备注就会出问题。

下一项实验要做什么

在 /root/grpc/pb.py 中依次实现 varint、tag、ZigZag、字段、消息的编码器和解码器。亲自测量 150 如何变成 96 01,以及 int32 -1 是十个字节而 sint32 -1 是一个字节。接着解码 fixture 字节,筛出 v1 schema 不认识的字段,并把子消息和 packed repeated 各解两次。评分器会导入你的 pb.py,并用指导语中没有出现的随机值做往返测试,所以不能把值写死。必须把规则实现出来。