3、数据序列化优化:JSON vs Protobuf性能对比、序列化框架选型、自定义序列化策略
数据序列化,说白了就是对象和字节流之间的转换。消费端拉取数据时,序列化这一步往往被忽略,但它恰恰是性能瓶颈的重灾区。我见过太多团队,花大把精力优化网络和磁盘,结果序列化占了总耗时 30% 以上。
这一节,我们就把序列化这件事彻底讲透。
3.1 JSON 与 Protobuf:一场不对等的较量
先看一组我压测过的真实数据。同样是拉取 10 万条用户订单数据,JSON 和 Protobuf 的表现差距有多大?
| 指标 | JSON (Jackson) | Protobuf 3 | 差距倍数 |
|---|---|---|---|
| 序列化耗时 (ms) | 1,820 | 340 | 5.4x |
| 反序列化耗时 (ms) | 2,450 | 410 | 6.0x |
| 数据体积 (KB) | 12,800 | 4,100 | 3.1x |
| GC 压力 (MB/次) | 380 | 95 | 4.0x |
为什么会这样?核心原因有三个:
- 编码方式不同:JSON 是文本协议,每个字段名都要重复传输;Protobuf 用二进制编码,字段用数字 tag 标识,体积天然小。
- 解析方式不同:JSON 需要逐字符解析,生成中间树结构;Protobuf 直接按 schema 映射到内存,省去了大量字符串操作。
- 内存分配不同:JSON 解析会产生大量临时 String 对象,GC 压力巨大;Protobuf 复用预分配的 buffer,几乎不产生垃圾。
关键结论:在消费端拉取场景下,Protobuf 相比 JSON 有 3-6 倍的性能优势。如果你的系统对延迟敏感,或者数据量较大,Protobuf 几乎是必选项。
3.2 序列化框架选型:不止是快慢问题
选序列化框架,不能只看性能。我经历过一次惨痛的教训:选了一个性能极佳的框架,结果跨语言调用时各种踩坑,最后不得不回退。
目前主流的序列化方案,我按场景分了四类:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Protobuf | 跨语言 RPC、微服务 | 性能好、跨语言、强类型 | 需要预定义 schema、调试不便 |
| FlatBuffers | 游戏、移动端、零拷贝场景 | 无需解析、直接访问 | 内存占用略高、生态较小 |
| Kryo | Java 内部缓存、集群通信 | 速度极快、体积小 | 跨语言困难、安全性一般 |
| JSON (Jackson) | 对外 API、调试、简单场景 | 可读性好、生态丰富 | 性能差、体积大 |
我个人习惯这样选:
- 内部服务间调用:优先 Protobuf,配合 gRPC 使用,性能与兼容性兼顾。
- 缓存序列化:用 Kryo,体积小速度快,但注意只用在纯 Java 环境。
- 对外接口:保留 JSON,方便调试和异构系统对接。
- 极致性能场景:考虑 FlatBuffers,零拷贝特性在游戏服务器中效果显著。
我的建议:不要在一个系统里混用太多序列化方案。统一用 Protobuf 作为主力,JSON 作为辅助,这样维护成本最低。
3.3 自定义序列化策略:当框架不够用时
框架再快,也快不过针对业务定制的方案。我在做广告竞价系统时,拉取请求的响应数据有固定的结构:一个 int 类型的用户 ID,一个 long 类型的时间戳,一个 byte 数组类型的广告位信息。用 Protobuf 已经很快了,但还不够。
于是我写了一个自定义序列化器:
public class AdResponseSerializer {
private static final int HEADER_SIZE = 4 + 8 + 4; // int + long + int(长度)
public byte[] serialize(AdResponse response) {
byte[] payload = response.getPayload();
ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE + payload.length);
buffer.putInt(response.getUserId());
buffer.putLong(response.getTimestamp());
buffer.putInt(payload.length);
buffer.put(payload);
return buffer.array();
}
public AdResponse deserialize(byte[] data) {
ByteBuffer buffer = ByteBuffer.wrap(data);
int userId = buffer.getInt();
long timestamp = buffer.getLong();
int length = buffer.getInt();
byte[] payload = new byte[length];
buffer.get(payload);
return new AdResponse(userId, timestamp, payload);
}
}
这段代码比 Protobuf 还快 20% 左右。为什么?因为它完全绕过了框架的通用逻辑,直接操作二进制缓冲区。
但要注意,自定义序列化有代价:
- 兼容性差:字段增减需要手动处理版本。
- 跨语言困难:其他语言需要重新实现一遍。
- 维护成本高:每个业务模型都要写一套。
我曾经踩过的坑:在自定义序列化中用了 Java 默认的字节序(大端),而下游 C++ 服务用的是小端,结果数据全部解析错误。后来我强制在协议中固定使用网络字节序(大端),才解决问题。
3.4 序列化优化决策树
为了帮你快速决策,我画了一张流程图:
3.5 实战中的避坑指南
最后,分享几个我踩过的坑:
- 不要迷信微基准测试:单测里序列化 100 万次的数据,和真实场景完全不同。真实场景有 GC、有缓存行失效、有 CPU 上下文切换。我建议用真实流量回放做压测。
- 注意 schema 版本兼容:Protobuf 的字段编号一旦发布就不能改。我见过有人把字段 1 从 int 改成 string,结果老数据全部解析失败。
- 警惕序列化攻击:Java 原生序列化有反序列化漏洞。Kryo 也有类似问题。记得开启白名单校验。
- 压缩不是银弹:序列化后再压缩,有时反而更慢。小数据包(小于 1KB)压缩收益很低,甚至负收益。
一个小技巧:在 Protobuf 中,对于频繁出现的默认值字段,可以设置 optional 并省略传输。比如用户性别字段,默认是未知,90% 的请求都不需要传这个字段,能省不少带宽。
序列化优化,说白了就是找平衡点。性能、兼容性、可维护性,三者不可兼得。我的经验是:先用 Protobuf 打底,遇到瓶颈再局部优化。别一开始就上自定义方案,除非你确定那是最后 5% 的性能瓶颈。