3. 序列化与反序列化:JSON/Protobuf/Avro 对比、性能与兼容性
序列化这事儿,说白了就是把内存里的对象变成一堆字节,好让它能在网络上飞,或者存到磁盘里睡大觉。反序列化就是反过来,把字节再变回对象。
我这些年跟序列化框架打了太多交道了。从最早的 Java 原生序列化,到后来 JSON 一统天下,再到 Protobuf 和 Avro 在微服务和数据管道里大放异彩。每个框架都有自己的脾气,选错了,后面改起来真是欲哭无泪。
今天咱们就掰开揉碎,看看这三种主流方案到底该怎么选。
3.1 三大序列化框架速览
先给个全景图,让你心里有个数。
| 特性 | JSON | Protobuf | Avro |
|---|---|---|---|
| 数据格式 | 文本(自描述) | 二进制(需 Schema) | 二进制(Schema 内嵌或外置) |
| Schema 定义 | 无强制 Schema | .proto 文件 | .avsc (JSON 格式) |
| 序列化速度 | 慢(文本解析) | 快(二进制编码) | 快(二进制编码) |
| 数据体积 | 大(含字段名) | 小(无字段名) | 小(无字段名) |
| 兼容性演进 | 灵活但易出错 | 强(有明确规则) | 强(Schema 演化机制) |
| 跨语言支持 | 极好 | 好 | 好 |
| 典型场景 | Web API、配置文件 | RPC、微服务通信 | 大数据管道、Kafka |
嗯,这里要注意,JSON 虽然看起来最“自由”,但自由往往意味着责任。没有 Schema 约束,字段名改一下,新旧版本就全乱套了。
3.2 序列化性能:到底差多少?
我记得有一次做性能压测,系统瓶颈竟然卡在 JSON 序列化上。QPS 一上去,CPU 直接飙满。换成 Protobuf 后,同样的机器,吞吐量翻了一倍。
咱们看个简单的对比数据(基于 Java 环境,对象包含 10 个字段,100 万次序列化+反序列化):
| 指标 | JSON (Jackson) | Protobuf 3 | Avro 1.11 |
|---|---|---|---|
| 序列化耗时 | ~3200 ms | ~450 ms | ~520 ms |
| 反序列化耗时 | ~4100 ms | ~380 ms | ~490 ms |
| 序列化后大小 | ~180 bytes | ~45 bytes | ~48 bytes |
看到了吧?JSON 的体积是 Protobuf 的 4 倍,速度慢了将近 8 倍。为什么?
JSON 是文本协议,每个字段名都要原样写进去。比如 "userName": "张三",光字段名就占了 10 个字节。Protobuf 和 Avro 用的是字段编号,比如 1: 张三,一个 Varint 就搞定了。
但话说回来,JSON 也有它的优势——调试方便。你直接 curl 一下就能看到返回结果,不用装额外的工具。所以我的习惯是:对外 API 用 JSON,内部 RPC 和数据管道用 Protobuf 或 Avro。
3.3 兼容性影响:这才是真正的坑
性能差几倍,顶多多花点钱买机器。但兼容性问题,搞不好就是线上事故。
我曾经遇到过这么一件事:一个老服务升级了字段类型,把 price 从 int 改成了 long,结果下游消费方反序列化直接抛异常。那晚的 on-call 电话,我现在还记得。
3.3.1 JSON 的兼容性:看似灵活,实则脆弱
JSON 没有 Schema,所以“兼容”全靠约定。常见的做法是:
- 新增字段:下游要能忽略未知字段
- 删除字段:上游要保证下游不再使用
- 修改字段类型:基本是灾难,比如
"price": 100改成"price": "100",很多解析库会直接报错
userId 改成了 user_id,结果 20 多个服务全部要跟着改。如果一开始就用 Protobuf 的字段编号,这种问题根本不会发生。
3.3.2 Protobuf 的兼容性:规则明确,但需遵守
Protobuf 的兼容性规则非常清晰,我总结成一句话:字段编号是契约,类型和编号都不能乱改。
具体规则:
- 可以新增字段:用新的编号,旧代码会忽略
- 可以删除字段:但 不能重用 被删除的编号(用 reserved 关键字标记)
- 可以改字段名:但编号不变,序列化不受影响
- 不能改字段类型:比如 int32 改成 int64,反序列化会截断或报错
看个例子:
// 旧版本 v1.proto
message Order {
int32 id = 1;
string product = 2;
int32 quantity = 3;
}
// 新版本 v2.proto
message Order {
int32 id = 1;
string product = 2;
// 删除了 quantity,但编号 3 必须保留
reserved 3;
// 新增字段,用新编号
int32 amount = 4;
string note = 5;
}
这里有个细节:reserved 3 是必须的。如果不写,将来有人不小心用了编号 3,新旧数据就会串,那 bug 极难排查。
3.3.3 Avro 的兼容性:Schema 演化是核心
Avro 的兼容性设计跟 Protobuf 不太一样。它把 Schema 本身作为数据的一部分(或者通过 Schema Registry 管理),反序列化时用 写 Schema 和 读 Schema 做对比,自动做字段映射。
Avro 定义了三种兼容性模式:
| 模式 | 说明 | 典型场景 |
|---|---|---|
| BACKWARD | 新 Schema 可以读旧数据(新增字段要有默认值) | 消费者先升级 |
| FORWARD | 旧 Schema 可以读新数据(只能删除有默认值的字段) | 生产者先升级 |
| FULL | 同时支持 BACKWARD 和 FORWARD | 最推荐,但限制最多 |
举个例子,Avro Schema 演化:
// 旧 Schema (v1.avsc)
{
"type": "record",
"name": "User",
"fields": [
{"name": "name", "type": "string"},
{"name": "age", "type": "int"}
]
}
// 新 Schema (v2.avsc) - 新增字段,必须给默认值
{
"type": "record",
"name": "User",
"fields": [
{"name": "name", "type": "string"},
{"name": "age", "type": "int"},
{"name": "email", "type": ["null", "string"], "default": null}
]
}
注意 email 字段的类型是 ["null", "string"],这是 Avro 的联合类型,表示可以为 null。默认值设为 null,这样旧数据反序列化时,新字段就能自动补 null。
3.4 一张图看懂三种方案的演进逻辑
下面这张图,我画出了三种方案在数据演进时的核心差异。你看一眼就能明白,为什么 Avro 在数据管道里这么受欢迎。
3.5 实战选型建议
说了这么多,到底怎么选?我根据自己的经验,给几个场景化的建议:
- 微服务 RPC 调用:首选 Protobuf。性能好,兼容规则明确,gRPC 生态成熟。
- 大数据管道(Kafka、Hadoop):首选 Avro。Schema 演化能力强,配合 Schema Registry 可以做到无缝升级。
- 对外 REST API:用 JSON。客户端语言千奇百怪,JSON 的普适性无可替代。
- 配置文件:用 JSON 或 YAML。不需要考虑兼容性演进,简单就好。
- 移动端与服务端通信:Protobuf 是更好的选择。省流量,省电量,用户体验更好。
一句话总结:序列化框架的选择,本质是在 性能、兼容性、可调试性 三者之间做权衡。没有银弹,只有最适合你场景的方案。