Protobuf 到底怎么把对象变成字节的?这篇用 protoc 生成代码,把一条记录逐字节拆开,再量字段号、体积、schema 演进和默认值的实际行为。
偏移 字节数 字段号 类型 内容
0 4 1 varint 1000000
4 55 2 长度前缀 'https://shop.example.com/item/1000000?ref=list&page=0'
59 50 3 长度前缀 'wico biwa wasu kosekefo julomesa zanisa wegiraka'
109 9 4 64 位 113.4
118 5 5 长度前缀 'CNY'
123 3 6 varint 249
126 9 7 64 位 1.1
135 14 8 长度前缀 'tag-wizetesa'
149 10 8 长度前缀 'tag-fica'
159 14 8 长度前缀 'tag-pofavovo'
173 26 9 长度前缀 'fovekesu/nojina/panutobo'
199 22 10 长度前缀 '2026-10-01T22:28:17Z'
221 20 11 长度前缀 (嵌套消息 18 字节)
前 24 字节:08 c0 84 3d 12 35 68 74 74 70 73 3a 2f 2f 73 68 6f 70 2e 65 78 61 6d 70 (08 = 字段 1 类型 varint;12 = 字段 2 类型 长度前缀)
protoc --decode_raw 的输出前 5 行:1: 1000000 | 2: "https://shop.example.com/item/1000000?ref=list&page=0" | 3: "wico biwa wasu kosekefo julomesa zanisa wegiraka" | 4: 0x405c59999999999a | 5: "CNY"
手写解析得到 13 个顶层字段(tags 的 3 个元素各算一个,嵌套的 seller 算一个);protoc --decode_raw 输出 13 个顶层条目:一致Protobuf 的每个字段是“字段头 + 值”。字段头 = (字段号 << 3) | 类型,类型只有四种:varint、64 位、长度前缀、32 位。字符串、嵌套消息和 bytes 是“长度 + 内容”;repeated 字符串每个元素各带一份字段头。我手写的解析与 protoc --decode_raw 的结果一致。
字段号 1–11:2 万条共 4854350 字节(平均 242.7);字段号 16–26:5154303 字节(平均 257.7),多 299953 字节 = 每条多 15.0 字节;一条记录有 15 个字段头(13 个顶层 + seller 里的 2 个),每个多 1 字节 字段 1 的字段头(varint):08;字段 15:78;字段 16(varint 类型)的字段头:8001(2 字节)
字段号 1–15 的字段头 1 字节,16 起 2 字节。数据量大时(2 万条,每条 15 个字段头)差别是 299953 字节。常出现的字段用小号。
格式 字节 zstd-3 gzip-6 JSON Lines(紧凑) 7214161 1331194 1484671 MessagePack(逐条拼接) 6357305 1413148 1496373 Protobuf(长度前缀拼接) 4894350 1375435 1449744 Protobuf 比 JSON 小 32.2%,比 MessagePack 小 23.0%;压缩后比 JSON 大 3.3%
未压缩时 Protobuf 比 JSON 小 32.2%,比 MessagePack 小 23.0%。但 zstd-3 压缩之后 Protobuf 比 JSON 大 3.3%,gzip 之后略小。字段名、引号等本来就是压缩器容易去掉的内容,压缩后优势缩小;这也是第 20 篇要展开的。
新写(v2 多了 price)→ 旧读(v1):字节 08 07 12 02 61 62 19 00 00 00 00 00 00 23 40;读出 {id=7, name='ab'}
旧写(v1)→ 新读(v2):字节 08 07 12 02 61 62;读出 {id=7, name='ab', price=0.0}
字段改名 name→title(编号不变):字节 08 07 12 02 61 62;读出 {id=7, title='ab'}
int32 → int64(同编号),值 2**40:字节 08 80 80 80 80 80 20 12 02 61 62;读出 {id=0, name='ab'}
int32 → int64,值 -5:字节 08 fb ff ff ff ff ff ff ff ff 01 12 02 61 62;读出 {id=-5, name='ab'}
int32(v1)→ string(v4)读,id=7:字节 08 07 12 02 61 62;读出 {id='', name='ab'}
string(name)→ int32(v5)读:字节 08 07 12 02 61 62;读出 {id=7, name=0}
旧版本读到新字段后再写回:原字节 15 字节,旧版本重新序列化 15 字节(保留了未知字段 price),新版本再读 price=9.5线上只有字段号没有字段名,所以改名没有影响,加字段新旧互读都不报错,旧版本还会保留自己不认识的字段并原样写回。要小心的是改类型:int64 的大值被 int32 读成 0;类型不匹配的值被当成未知字段,读出默认值而不是报错。
Plain(a=0, b=5, s=""):10 05(2 字节);Opt(a=0, b=5, s=""):08 00 10 05 1a 00(6 字节)
Plain.HasField("a") → ValueError(Field pre.Plain.a does not have presence.)
Opt.HasField("a") = True,Opt().HasField("a") = False;Plain 解出来的 a 是 0,无法区分"写了 0"和"没写"
1000 条 stock=0、rating=0.0 的记录:proto3 普通字段共 0 字节(全是空消息);JSON 的 "stock":0,"rating":0.0 每条至少 24 字节proto3 的普通字段等于默认值时不写出,所以全是零的记录编码成空;代价是分不清“写了 0”和“没写”。需要区分时用 optional,它会写出值并支持 HasField。