bigywater.tech
数据编码与压缩 · 5

Protobuf 的字节长什么样:逐字节拆解与 schema 演进

Protobuf 到底怎么把对象变成字节的?这篇用 protoc 生成代码,把一条记录逐字节拆开,再量字段号、体积、schema 演进和默认值的实际行为。

241 对 358 字节
同一条商品记录的 Protobuf 编码与紧凑 JSON
299953 字节
字段号 16 起比 1–15 多出的字节(2 万条,每条 15 个字段头各多 1 字节)
+3.3%
zstd-3 压缩后 Protobuf 比 JSON 反而大的比例(未压缩时小 32.2%)

字节的结构

实测 · 本机执行输出
    偏移   字节数   字段号           类型  内容
     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 篇要展开的。

schema 演进

实测 · 本机执行输出
  新写(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。

分步演示

Protobuf 字节
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8(系统 Python,自带 compression.zstd)/ zstandard 0.25.0 / lz4 4.4.5 / zstd、lz4、brotli、protoc 命令行(Homebrew);msgpack、protobuf、snappy 等第三方库临时装在临时目录里,用完删除;所有数据都是本机脚本生成的合成数据(固定随机种子),没有使用任何真实网站或用户的数据;计时用 time.perf_counter,取多次中位数,数字每次运行会有小幅抖动。protobuf 7.36.2 运行库、protoc 36.2 生成代码,.proto 文件由脚本写在临时目录,用完删除;解析 wire 格式的函数是我手写的,与 protoc --decode_raw 对照;所有数字都是确定性的字节数
    • 没有验证:packed repeated 数值字段(会省掉每个元素的字段头);oneof、map、enum 的编码;proto2 的行为;Go、Java 的 protobuf 实现是否产生相同字节(同一份数据序列化的字节在不同实现之间不保证一致);编码和解码的速度(见第 7 篇);Any、Struct 等知名类型;gRPC 的帧格式
    京ICP备2026067502号-1京公网安备11010502063487号