数据序列化实测:JSON、MessagePack 与 Protobuf
TL;DR:我把同一份五万条结构化日志分别编成 JSON、MessagePack 和 Protobuf 各跑五轮,结论和流行说法不太一样。MessagePack 只把体积压到 JSON 的 80.9%,Protobuf 压到 51.4%,但一旦过一遍 gzip,三者的差距全部收敛到同一个水平,MessagePack 反而比 JSON 大 2.1%。真正拉开距离的是速度和 schema 演进能力,而这两项在技术选型讨论里几乎没人提。
很多人在决定要不要从 JSON 换成二进制线格式时,手上拿到的都是别人博客里的一张柱状图。那些图基本不甘地把「快」和「小」混在一起讲,也很少告诉你它测的那份数据长什么样。所以我干脆在本机把同一份数据用几种方式各编一遍,把每个数字连同比对逻辑一起摊开,你可以照着复现。
测试环境与方法
所有数字都是 2026-10-25 在本机实跑出来的,环境如下:
- 系统与运行时:Windows 11,Python 3.13.12
- msgpack 1.2.3、orjson 3.13.0、msgspec 0.22.0、protobuf 7.36.2(均为当日从 PyPI 安装的最新版本)
- 数据:随机种子固定为 20261025,五万条日志事件,每条含九个字段(整型时间戳、字符串等级、字符串服务名、32 位十六进制 trace_id、整型状态码、浮点耗时、整型字节数、布尔命中标记、字符串消息)
- 计时口径:每项跑五轮,取最快的一轮,避免后台抖动放大差距
- Protobuf 没有用 protoc:为了让你直接复制就能跑,我用
descriptor_pb2在运行时构造了 schema
构造 protobuf 类型的这一段是整套流程里唯一不常见的部分,先贴在前面:
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
T = descriptor_pb2.FieldDescriptorProto
fp = descriptor_pb2.FileDescriptorProto()
fp.name = "bench.proto"
fp.syntax = "proto3"
msg = fp.message_type.add()
msg.name = "Event"
for name, num, typ in [("ts", 1, T.TYPE_INT64), ("level", 2, T.TYPE_STRING)]:
f = msg.field.add()
f.name, f.number, f.type, f.json_name = name, num, typ, name
f.label = T.LABEL_OPTIONAL
Desc = descriptor_pool.Default().Add(fp)
Event = message_factory.GetMessageClass(Desc.message_types_by_name["Event"])
一、体积:省下来的其实主要是字段名
五万条记录的整体编码结果(单位字节):
| 编码方式 | 字节数 | 相对紧凑 JSON | gzip level 6 后 | gzip 后占比 |
|---|---|---|---|---|
| JSON(紧凑分隔符) | 10,294,992 | 100.0% | 2,659,489 | 25.8% |
| JSON(默认分隔符) | 11,194,996 | 108.7% | — | — |
| orjson | 10,294,992 | 100.0% | — | — |
| msgspec.json | 10,294,992 | 100.0% | — | — |
| MessagePack(msgpack-python) | 8,330,309 | 80.9% | 2,715,571 | 26.4% |
| MessagePack(msgspec) | 8,330,309 | 80.9% | — | — |
| Protobuf | 5,294,403 | 51.4% | 2,657,929 | 25.8% |
| pickle protocol 5 | 5,693,833 | 55.3% | — | — |
先看三个容易被混为一谈的事实。
第一,orjson 和 msgspec.json 产出的字节数与标准库 JSON 完全一样,都是 10,294,992 字节。它们的卖点是快,不是小。如果你是为了省体积才换过去,那你换错了对象。
第二,MessagePack 只降到 80.9%。原因是它没有 schema,每条记录都得把九个字段名原封不动再写一遍。我把同样的数据从 map 编码改成位置数组,同样是 MessagePack,字节数从 8,330,271 掉到 5,080,271,正好省掉 3,250,000 字节。摊到单条记录:164 字节里有 66 字节是键名及其类型头,占 40.2%。也就是说,MessagePack 相对 JSON 那 19.1% 的优势,几乎全是把字段名换成紧凑编码省下来的,数据本身没怎么变。
第三,Protobuf 能到 51.4%,是因为它写进字节流的不是字段名而是字段编号,彻底没有键名开销。代价也很直接:没有 .proto 文件,谁也读不懂这串字节。
二、压缩之后,二进制格式的优势还剩多少
这是本轮最反直觉的一组数字。我把上面三种编码各过一遍 gzip(level 6):
gzip(JSON)= 2,659,489 字节,占原始 JSON 的 25.8%gzip(MessagePack)= 2,715,571 字节,占原始 JSON 的 26.4%gzip(Protobuf)= 2,657,929 字节,占原始 JSON 的 25.8%
请注意:三个结果落在 25.8% 到 26.4% 之间,宽度只有 0.6 个百分点,而 gzip(MessagePack) 甚至比 gzip(JSON) 还大 56,082 字节,也就是 2.1%。
背后的机制不难理解:字段名高度重复,恰好是字典类压缩算法最擅长的部分。MessagePack 手动把这部分冗余清掉了,等于提前做了一遍 gzip 本来就会做的事,结果只是换了处理顺序,没有额外收益。
它给出的决策信号非常清楚:如果你的瓶颈是磁盘占用或传输带宽,优先级第一件事是打开压缩,而不是换线格式。 换格式的收益主要体现在 CPU 与延迟上,不在体积上。我实际测试时就发现,把 JSON 原样丢给 gzip 就能拿到和 Protobuf 几乎持平的体积,而 JSON 还保留着可读、可 grep、可 diff 的全部好处。
三、速度:差距比体积大得多
编码耗时(五万条整体,取五轮最快值):
| 编码器 | 耗时 |
|---|---|
| json.dumps | 118.44 ms |
| msgpack.packb | 68.90 ms |
| pickle.dumps | 72.35 ms |
| msgspec.json.encode | 38.24 ms |
| msgspec.msgpack.encode | 34.15 ms |
| orjson.dumps | 26.86 ms |
| protobuf SerializeToString | 20.24 ms |
解码耗时(同一份数据):
| 解码器 | 耗时 |
|---|---|
| json.loads | 200.94 ms |
| msgpack.unpackb | 179.29 ms |
| msgspec.json.decode | 121.07 ms |
| orjson.loads | 113.68 ms |
| msgspec.msgpack.decode | 75.39 ms |
| pickle.loads | 59.47 ms |
| protobuf ParseFromString | 16.51 ms |
三个观察:
1. orjson 比标准库 json 快 4.4 倍(26.86 ms 对 118.44 ms),而且产出字节完全一致。这是整张表里唯一「零成本升级」的一条:改一行 import 就能拿到。
2. 同一个 MessagePack,两个库差 2.4 倍(msgspec 75.39 ms 对 msgpack-python 179.29 ms)。所以选二进制格式时,「选哪个实现」的影响不比「选哪个格式」小。
3. Protobuf 编解码都是最快的(20.24 ms 与 16.51 ms)。解码端相对标准库 JSON 有 12.2 倍差距,这个量级足以改变服务的尾延迟。
四、真正该决定的是 schema 演进,不是体积
体积可以当场测出来,schema 演进要过半年才会咬人。这一节我把两者都跑了。
4.1 Protobuf 的默认值省略既是优势,也是陷阱
Protobuf 官方文档在 Specifying Field Cardinality 一节里,对没有写 optional 的标量字段原话是这样说的:
the field is set to the default (zero) value. It will not be serialized to the wire. In fact, you cannot determine whether the default (zero) value was set or parsed from the wire or not provided at all.
同样的意思在 Default Field Values 一节被重复了一遍:
> Note that for implicit-presence scalar fields, once a message is parsed there's no way of telling whether that field was explicitly set to the default value (for example whether a boolean was set to false) or just not set at all
我实际把它逐字节验证了一遍。用一个含 id、status、name、cached 四个字段的消息:
- 什么都不设 → 序列化结果 0 字节
- 显式设
status=0、name=""、cached=False→ 序列化结果 0 字节,与上一条字节完全相同 - 只设
name="hello"→1a0568656c6c6f,7 字节 - 把字段改成
optional后,显式设status=0→1000,2 字节,且HasField("status")返回True;不设 → 0 字节,HasField返回False
规模效应也不含糊:把五万条记录的 status 全改成 0,Protobuf 从 5,294,391 降到 5,144,391 字节,缩小 2.8%,正好等于每条记录省下 3 字节;如果每条记录除时间戳外都留成默认值,整体只剩 400,007 字节。
所以这条特性的两面必须一起读:它既省字节,也永久抹掉了「设了 0」与「没设」的区别。 如果你的协议里有一个「0 有特殊含义」的标量字段(例如 quota=0 表示显式禁用而非未配置),那就必须加 optional,否则这个语义在线路上不可恢复。
4.2 未知字段:转发是安全的,但有一条硬边界
官方文档 Unknown Fields 一节的原话:
Proto3 messages preserve unknown fields and include them during parsing and in the serialized output, which matches proto2 behavior.
我实际验证:新版 schema 写入 trace_id 后得到 17 字节 080712037376631a086162636431323334;用不含该字段的旧 schema 解析,读出 id=7、name='svc',再原样序列化——输出与原始字节完全相同。这意味着老版本的转发服务可以安全透传新数据,这是 HTTP 网关、消息队列消费者这类中间层非常看重的性质。
但同一节的 Retaining Unknown Fields 紧接着列出了会丢的两种情况,其中一条是:
Serialize a proto to JSON.
也就是说,同一个服务透传二进制是安全的,一旦中间某跳转成 JSON,未知字段就没了。这条边界值得写进设计文档,因为它只在你做转换时才暴露。
还有一条我觉得最危险,也是实测出来的:把字段编号 3 的类型从 string 改成 int32,再用新 schema 去解旧数据——没有报错,得到 0。字段编号一旦分配就不能改类型,这不是建议,是硬约束。
4.3 MessagePack:安静到你以为没问题
MessagePack 没有 schema,因此也不存在「演进规则」。同一个字段从整数换成字符串:
{"id": 1, "amount": 100}→ 13 字节{"id": 1, "amount": "100"}→ 16 字节
消费端拿到的分别是 100 和 '100',全程没有任何报错。缺字段、多字段也一样静默:{"id": 1} 只有 5 字节,多一个 currency 就到 26 字节,下游完全是「看数据脸色行事」。
补救办法是在消费端加类型契约。用 msgspec 的 Struct 可以把这条边界做出来,官方文档 supported-types 的原话是:
During decoding, any unknown fields are ignored (this can be disabled, see forbid-unknown-fields), and any missing optional fields have their default values applied. An error is raised during decoding if the type doesn't match or if any required fields are missing.
注意那句默认行为——未知字段默认是忽略的。这意味着拼写错误不会被发现;如果再配上带默认值的可选字段,一个 typo 会直接降级成默认值而不报错。我在本机验证了两种写法,区别只在一个类参数:
class Ev(msgspec.Struct):
id: int
amount: int
msgspec.msgpack.decode(byte_string, type=Ev)
# 默认:Ev(id=1, amount=100) —— currency 被静默丢弃
class StrictEv(msgspec.Struct, forbid_unknown_fields=True):
id: int
amount: int
# 加了参数:msgspec.ValidationError: Object contains unknown field `currency`
五、踩坑录:六个我本机复现出来的报错
以下每条都是我在这台机器上真跑出来、原样抄下来的错误文本。
报错一:`TypeError: can not serialize 'datetime.datetime' object`
>>> msgpack.packb({"ts": datetime.datetime(2026, 10, 25, 6, 10)}, use_bin_type=True)
TypeError: can not serialize 'datetime.datetime' object
原因是 MessagePack 规范本身没有时间戳类型,需要走 Ext 扩展。加上 datetime=True 也过不了,会换成另一条:
ValueError: can not serialize 'datetime.datetime' object where tzinfo=None
因为 naive datetime 无法确定时区语义。两个修法:要么显式带上 tzinfo 再用 datetime=True,要么自己先把 datetime 转成字符串或 Unix 时间戳。别指望 orjson 那种自动转 ISO 串的行为在其他库里也存在——我实际对比时 orjson 直接输出 b'{"ts":"2026-10-25T06:10:00"}',msgspec.json 也一样,只有 msgpack 需要你动手。
报错二:`TypeError: Dict key must be str`
>>> orjson.dumps({1: "one", 2: "two"})
TypeError: Dict key must be str
orjson 官方 README 的写法是:不指定 OPT_NON_STR_KEYS 时,dumps() 会抛出 JSONEncodeError,而它是 TypeError 的子类。修法是 orjson.dumps(d, option=orjson.OPT_NON_STR_KEYS)。
这里最值得注意的是标准库的对照行为:json.dumps({1: "one"}) 不报错,直接输出 {"1": "one"},回读时键变成了 str。同一个输入,一个库静默改类型、另一个库抛异常,这种分歧在跨服务边界上会变成最难查的那一类 bug。
报错三:`ValueError: int is not allowed for map key when strict_map_key=True`
>>> msgpack.packb({1: "one"}, use_bin_type=True)
ValueError: int is not allowed for map key when strict_map_key=True
msgpack-python 默认开了 strict_map_key。JSON 家族这边要么静默转换、要么报错,MessagePack 这里直接拒绝,三方态度各不相同。非字符串字典键最好不要出现在跨格式的数据里,这是最省事的设计约束。
报错四:大整数三种溢出,三种报错
同一个值 2^70(等于 1,180,591,620,717,411,303,424)交给四个库:
json.dumps→ 正常,回读完全相等orjson.dumps→TypeError: Integer exceeds 64-bit rangemsgpack.packb→OverflowError: Integer value out of range- protobuf 的
int64字段 →ValueError: Value out of range: 1180591620717411303424
标准库 JSON 能承载任意精度整数,这一点其实是隐患而不是优势。Python 侧看着完全正确,等到 JavaScript 消费端用 Number 读进去,2^53+1 就变成 9007199254740992。如果数据会跨语言,大 ID 一律走字符串,这条建议对这四个库都成立。
报错五:`msgspec.ValidationError: Expected int, got str`
>>> msgspec.msgpack.decode(byte_string, type=Ev)
msgspec.ValidationError: Expected `int`, got `str` - at `$.amount`
这条其实是好消息:它把 MessagePack 默认会静默放过的类型漂移变成了显式失败,而且错误信息里带了 JSON Pointer 路径 $.amount,定位很准确。缺字段时它给的是另外一条:
msgspec.ValidationError: Object missing required field `amount`
报错六:`orjson.JSONDecodeError` 撞上标准库 JSON 产出的非法 JSON
>>> import json, orjson
>>> json.dumps({"v": float("nan")})
'{"v": NaN}'
>>> orjson.loads(json.dumps({"v": float("nan")}))
orjson.JSONDecodeError: unexpected character, expected a JSON value: line 1 column 7 (char 6)
标准库 json 默认把 NaN 与 Infinity 输出成裸字面量,这不是合法 JSON,严格解析器会直接拒绝。而 orjson 和 msgspec.json 反过来把它们静默序列化成 null(orjson 官方 README 原话:serializes Nan, Infinity, and -Infinity, which are not compliant JSON, as null)。MessagePack 则原样保留 nan。
三种处理,两个方向:一种是产出坏数据,一种是静默丢数据。生产环境应该显式加上 allow_nan=False 或走浮点校验,把这条路堵死在前。
六、什么时候不该换成二进制格式
基于上面所有数字,我的判断是这几种情况先别换:
1. 瓶颈只在磁盘或带宽 —— 直接 gzip(JSON),实测 25.8%,只比 gzip(Protobuf) 差 0.06 个百分点,还保留了全部可读性。
2. 数据要被人读 —— 配置文件、日志管道、审计留痕。这一条压倒一切性能考量。
3. 上下游语言超过两种且没有中心化 schema 仓库 —— Protobuf 需要分发 .proto,MessagePack 需要额外的契约机制。没有这些,二进制格式带来的只是「跑起来没问题、半年后改不动」。
4. 数据会被 grep、diff 或 code review —— 二进制一次性判掉了这三类工具。
5. 没有承诺永不复用的字段编号体系 —— 如果团队不会做这个承诺,那 Protobuf 的兼容性保证是拿不到的。
反过来说,值得换的信号是这三种:单条消息很小但 QPS 极高(CPU 成本被放大)、服务之间高扇出调用(延迟会叠加),以及已经存在 schema 治理机制(那就别浪费它)。
本文的边界
为了不让结论被误读,我把这次没做的部分也写清楚:
- 全部在本机 Windows 11 上单进程串行运行,没有测并发场景,也不含跨机器网络栈的真实开销
- 数据是合成日志,字段类型分布固定;如果你的负载是嵌套极深的结构体或大量小消息,数值会变化
- 没有覆盖 Avro、Thrift、FlatBuffers、Cap'n Proto,也没有测 Thrift 的两层协议
- gzip 只跑了 level 6;zstd、lz4、brotli 没有纳入本文对照
- protobuf 用的是 pure-Python 运行时(protobuf 7.36.2),没有启用 upb C 扩展,所以它的数字是保守的,生产环境可以更快
- 版本口径截至 2026-10-25
本文不含任何联盟链接,也不会因此产生佣金。
相关阅读
如果你在意的是「落盘的文件怎么变小」,可以看我另一篇针对 Parquet 默认写法的实测:"Parquet 与 Arrow 实测:默认字典编码让我的文件大了 15%"。那一篇讲的是磁盘上的列式文件格式,本篇讲的是穿越网络的线格式,两者不在同一个层。想接着把本地数据直接查起来,可以接 DuckDB 1.5.6 实测:40 MB CSV 不建库直查。而当你最终要把这套东西搬进长期运行的服务,日志与可观测这一层的坑我都跑过一遍,写在 Python logging 实测:多进程丢日志、重复输出与六个真实报错 里。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):