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

有 schema 与无 schema:体积、速度与调试成本

带 schema 的 Protobuf 和不带 schema 的 JSON、MessagePack 到底差在哪?这篇不只比体积:速度、校验、没有 schema 时能读出什么、数据损坏时的表现、内存占用,逐项量一遍,看有 schema 带来的好处有多少只是“不重复写键名”。

2.4%
把键名挪到表头后,JSON 数组比 Protobuf 只大的比例(5034293 对 4914350 字节)
2449 / 3000
随机翻转 1 个字节后,Protobuf 静默解出不同的值的次数(JSON 1884,MessagePack 2405)
11 对 36 MiB
解码 2 万条 products 的峰值内存增量:Protobuf 与标准库 json

速度

实测 · 本机执行输出
  JSON(标准库)                          编码    39.7 ms  解码    43.3 ms
  JSON(orjson)                       编码     4.9 ms  解码    20.2 ms
  MessagePack                        编码    13.0 ms  解码    30.8 ms
  Protobuf(从 dict 构造消息再序列化)          编码    42.4 ms  
  Protobuf(仅解析,不访问字段)                编码   -        解码     3.8 ms
  Protobuf(解析并访问全部字段)                编码   -        解码    25.7 ms
  Protobuf 解析后转成 dict(MessageToDict)                 解码   186.3 ms(转成 Python 字典的代价)

Protobuf 的解析本身很快(3.8 ms),逐个访问字段时才花时间(25.7 ms);但要得到 Python 字典(MessageToDict)要 186.3 ms。orjson 的解码是 20.2 ms,编码 4.9 ms。从 dict 构造 Protobuf 消息再序列化是 42.4 ms。

校验

实测 · 本机执行输出
  price 写成字符串 "abc"            JSON 接受、MsgPack 接受、Protobuf 抛出 TypeError(must be real number, not str)
  缺少 url 字段                    JSON 接受、MsgPack 接受、Protobuf 接受,序列化 128 字节
  多了一个未知字段 extra               JSON 接受、MsgPack 接受、Protobuf 抛出 AttributeError(Protocol message Product has no "extra" )
  stock 写成 1.5                 JSON 接受、MsgPack 接受、Protobuf 抛出 TypeError('float' object cannot be interpreted as )
  id 写成 2**70                  JSON 接受、MsgPack 抛出 OverflowError、Protobuf 抛出 ValueError(Value out of range: 11805916207174113034)

schema 在赋值时能拦住类型错误和未知字段(JSON、MessagePack 不会),但缺字段不会被拦,会用默认值。需要“必填”要自己检查。

自描述

实测 · 本机执行输出
  JSON 前 80 字节:b'{"id":1000000,"url":"https://shop.example.com/item/1000000?ref=list&page=0","tit'
  MessagePack 解成通用结构(不知道任何 schema):{'id': 1000000, 'url': 'https://shop.example.com/item/1000000?ref=list&page=0', 'title': 'wico biwa wasu kosekefo julome…
  Protobuf 用 protoc --decode_raw 看到的前 8 行: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" | 6: 249 | 7: 0x3ff199999999999a | 8: "tag-wizetesa"
  歧义:把恰好是合法消息的字节(08 01 12 01 78)存进 title 字符串字段,decode_raw 显示为:3 {   1: 1   2: "x" }(没有 schema 分不清它是字符串还是嵌套消息)
  浮点 price 在 decode_raw 里显示为 4: 0x405c59999999999a(只知道是 64 位,不知道是 double 还是 fixed64 还是别的)

JSON 和 MessagePack 的数据里带着键名和类型。Protobuf 没有 schema 只能看到字段号和粗略类型,字符串与嵌套消息、double 与 fixed64 都分不清。调试时要保留 .proto。

schema 的收益

实测 · 本机执行输出
  表示                                  字节    zstd-3
  JSON 对象(每条带键名)                 7214161   1331194
  JSON 数组 + 表头                   5034293   1221884
  MessagePack 对象                 6357305   1413148
  MessagePack 数组 + 表头            4717408   1307318
  Protobuf                       4914350   1378051

Protobuf 比 JSON 对象小很多,但把键名挪到表头的 JSON 数组(5034293 字节)只比 Protobuf(4914350)大 2.4%,zstd 压缩后还更小(1221884 对 1378051)。Protobuf 的优势里有一大块只是“不重复写键名”。

损坏

实测 · 本机执行输出
  JSON         358 字节:抛异常 1116、解出相同 0、静默解出不同的值 1884(共 3000 次)
  MessagePack  316 字节:抛异常 595、解出相同 0、静默解出不同的值 2405(共 3000 次)
  Protobuf     241 字节:抛异常 551、解出相同 0、静默解出不同的值 2449(共 3000 次)

翻转 1 个字节:三种格式都有一半以上情况下静默解出不同的值,没有一次解出相同(因为总是改变了某个值)。要发现损坏得靠校验和(CRC、zstd 帧的校验等),格式本身不提供。

内存

实测 · 本机执行输出
  json      输入 6.9 MiB,解码后峰值内存增量 36 MiB(3 次:[36, 36, 37],macOS 的 ru_maxrss 单位是字节)
  orjson    输入 6.9 MiB,解码后峰值内存增量 50 MiB(3 次:[50, 50, 50],macOS 的 ru_maxrss 单位是字节)
  msgpack   输入 6.1 MiB,解码后峰值内存增量 29 MiB(3 次:[29, 29, 29],macOS 的 ru_maxrss 单位是字节)
  protobuf  输入 4.7 MiB,解码后峰值内存增量 11 MiB(3 次:[11, 11, 11],macOS 的 ru_maxrss 单位是字节)

解码 2 万条商品:Protobuf 11 MiB,msgpack 29 MiB,标准库 json 36 MiB,orjson 50 MiB。Python 对象本身的开销远大于数据大小;orjson 更快但峰值更高,原因我没有查。

分步演示

有无 schema
本步的对象

    本文用到的工具与边界

    • 本机 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、msgpack 1.2.3、orjson 3.13.0;.proto 与生成代码在临时目录,用完删除;第一节各 7 次中位数,第五节每种 3000 次随机翻转(固定种子),第六节每种 3 次子进程测量,其余确定性
    • 没有验证:Protobuf 的 Python 实现是否在访问字段时才转对象(只看到耗时现象);orjson 峰值内存更高的原因;Go 或 Java 里的 Protobuf 解码速度;JSON Schema、MessagePack 加外部校验;Avro 这类把 schema 放进文件头的格式;加上 CRC 之后的检错率
    京ICP备2026067502号-1京公网安备11010502063487号