接口上线三个月,加了一个字段 couponId,上游没升级,下游直接反序列化失败;或者金额 19.90 变成 19.899999999999999。这类问题都不是"序列化工具用错",而是协议没有约定演进规则。
选序列化和选数据库一样:先问约束,再选工具。本文按四个问题拆——可读性、体积、跨语言、兼容演进。
1. 四个必答问题
| 问题 | 影响 | 决定什么 |
|---|---|---|
| 可读性 | 能不能用 curl/日志直接看、能不能手改 |
调试成本、排障速度 |
| 体积与编解码开销 | 带宽、GC、CPU | 高频内部调用 vs 低频对外 |
| 跨语言 | 是否有非 JVM 消费方 | 多语言栈必须放弃 JVM 专有格式 |
| 兼容演进 | 加字段/删字段/改类型是否破坏旧客户端 | 决定这套协议能不能活三年 |
四个问题里,只有兼容演进是单向门:选错可以换,改错要停机。所以先定演进规则,再选格式。
2. JSON 的边界与关键配置
| 问题 | 表现 | 处理 |
|---|---|---|
| 数字精度 | long 超过 2^53 用 JS 解析丢精度;金额用 double 出现 19.899999999999999 |
大整数与金额用 String 或 BigDecimal + 字符串序列化 |
null vs 缺失 |
{"a":null} 与 {} 在多数绑定里都得到 a == null |
需要区分时用 Optional/JsonNullable,或约定"缺失即默认值" |
| 时间格式 | Date 默认时间戳、LocalDateTime 默认数组 |
全局固定 ISO-8601 字符串,禁用时间戳 |
| 字段名冗长 | 长字段名在高频调用里累积成可观字节 | 内部协议考虑短名或换二进制 |
| 未知字段 | 上游加字段,下游旧版直接抛异常 | 对外接口必须允许未知字段 |
ObjectMapper mapper = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // 兼容上游加字段
.serializationInclusion(JsonInclude.Include.NON_NULL) // 省字节,但丢失 null 语义
.build();
mapper.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
FAIL_ON_UNKNOWN_PROPERTIES 与 NON_NULL 是两个必须做决定的开关:
- 前者保持
true适合内部严格契约(字段拼错立刻发现),对外暴露的接口应关掉; - 后者省字节,但下游无法区分"没传"和"传了 null",若业务依赖这个区别就必须保留 null。
3. Protobuf 的机制与演进规则
message Order {
int64 id = 1; // tag 编号,不是字段顺序
string user_id = 2;
int32 amount = 3; // 分为单位,避免浮点
repeated Item items = 4;
optional string coupon = 5; // proto3 显式存在性
}
| 机制 | 含义 | 演进影响 |
|---|---|---|
tag 编号 |
编码进二进制,字段名不传输 | 编号是永久契约,删除后不可复用 |
varint |
小整数 1 字节,大整数变长 | 负数用 sint32/sint64,否则编码成 10 字节 |
| 未知字段 | proto3 保留并透传 | 新旧版本可互通 |
optional(proto3) |
显式存在性 | 加字段安全,旧端读为默认值 |
repeated |
可重复,默认 packed | 改 packed 会影响旧解析器 |
| 字段删除 | 必须 reserved 编号与名字 |
不复用编号是唯一硬规则 |
message Order {
reserved 6, 7; // 曾用过的编号,永久占位
reserved "old_status"; // 曾用过的名字
}
Protobuf 的兼容性边界很清晰:加字段永远安全,删字段要 reserved,改类型不安全。把 int32 改成 string 是编码层不兼容,int32→int64 在正数范围兼容但负数会出问题,跨语言实现还可能不一致。改动字段类型等于改协议版本。
4. Hessian 的定位
| 维度 | Hessian 2.0 | 说明 |
|---|---|---|
| 生态 | Java 为主(Dubbo 默认之一) | 跨语言实现少且不完整 |
| 可读性 | 二进制,需专用工具 | 不能直接看 |
| 体积 | 小于 JSON,大于 Protobuf | 字段名仍会被编码 |
| 演进 | 靠字段名与类型,删除字段通常兼容 | 无显式 schema |
| 风险 | 反序列化可构造任意类型 | 只应在可信内网、固定类型白名单下使用 |
Hessian 的价值在"Java 到 Java 的 RPC,要快又要类型信息"。它的弱点是跨语言和安全性——一旦协议对外开放,Hessian 就不是选项。Hessian2 相对早期版本改进了引用机制与 int/long 压缩,但仍不具备 Protobuf 那种 schema 驱动的演进能力。
5. 为什么 JDK 原生序列化不能做对外协议
| 问题 | 后果 |
|---|---|
类名、serialVersionUID 写进字节流 |
改包名、改类名即不兼容 |
serialVersionUID 不显式声明 |
编译器自动生成,任何字段改动都变 → InvalidClassException |
readObject 可被构造 |
反序列化即代码执行面,历史漏洞密集 |
| 体积与速度 | 普遍差于主流二进制格式 |
| 跨语言 | 基本不可能 |
public class Order implements Serializable {
private static final long serialVersionUID = 20240301L; // 显式固定,不靠自动生成
private transient String cache; // 不参与序列化
}
它适合的场景只剩一个:同进程/同版本的临时持久化(本地缓存文件、会话复制)。对外协议一律不用。
6. 版本兼容的三条纪律
- 只增字段,不删不改。删除改为"标记废弃 + 停止写入",保留编号占位。
- 编号/字段名永不复用。复用的编号会与新语义冲突,旧客户端读到不认识的类型。
- 默认值语义要写进文档。
int的0、string的""、bool的false无法区分"未设置"和"设为默认",需要显式存在性时用optional/指针/包装类型。
// 反例:删字段后复用同名
{"userId": 123, "status": "PAID"} // 旧版 status 是 int 枚举,新版是 string
// 正例:新增字段带默认,旧字段保留但不再写
{"userId": 123, "legacyStatus": 2, "statusCode": "PAID", "schemaVersion": 3}
第三条最容易被忽略:加一个 schemaVersion 字段的成本极低,但它让"客户端到底是哪个版本"从猜测变成事实。
7. 性能判断的依据
不要凭"JSON 慢"的印象选型,按机制判断:
| 因素 | 影响 | 说明 |
|---|---|---|
| 是否反射 | 反射绑定的编解码在热路径上开销明显 | 生成式绑定(如编译期代码生成)可摊薄 |
| 是否有 schema | 有 schema 可省字段名、可做类型校验 | Protobuf 的体积优势主要来自这里 |
| 是否压缩 | gzip 对文本收益大、对二进制收益小 | 小报文压缩反而变大 |
| 分配次数 | 每次编解码产生的临时对象 | 高频场景直接体现为 GC 压力 |
| 字符串编码 | UTF-8 与 UTF-16 的转换 | JVM 内部 String 到字节的转换不可忽略 |
结论性判断:大报文 + 跨语言 + 长期演进 → Protobuf;对外 API + 调试优先 → JSON;JVM 内部 RPC + 已有生态 → Hessian/专用序列化。具体耗时按各版本实测,不要引用二手基准。
8. 与 RPC 框架的关系
<!-- Dubbo 3.x:序列化是可替换的扩展点 -->
<dubbo:protocol name="dubbo" serialization="hessian2" />
<!-- 可选:fastjson2 / protobuf / avro,需引入对应扩展 -->
Dubbo 把序列化当 SPI 扩展点,serialization 属性决定用哪种实现;Triple(gRPC 兼容协议)默认走 Protobuf。换序列化等于换协议——服务端与客户端必须同时支持,不能灰度到一半混跑,除非协议本身向后兼容。
9. 排错表:常见坑
| 坑 | 表现 | 修法 |
|---|---|---|
| JSON 用浮点存金额 | 对账差几分钱 | 分为单位的整数或 String 传输 |
| Protobuf 改了字段编号 | 老数据被解析成错误字段,静默错值 | 编号只增不复用,删除用 reserved |
| 跨语言用 Hessian | 非 JVM 客户端无实现或行为不一致 | 换 Protobuf/JSON |
| 实体加字段导致反序列化失败 | 上游发新版、下游旧版抛异常 | 关闭 FAIL_ON_UNKNOWN_PROPERTIES |
依赖 serialVersionUID 自动生成 |
加个 toString 都可能 InvalidClassException |
显式声明并固定 |
| 用 JSON 传二进制 | Base64 膨胀约 33% | 直接传二进制或换协议 |
10. 边界表
| 场景 | 首选 | 理由 |
|---|---|---|
| 对外开放 API | JSON | 可读、工具链全、调试零成本 |
| 内部高频 RPC | Protobuf | 体积与解析开销最小,schema 强约束 |
| 已有 Dubbo 体系 | Hessian2(内网) | 落地成本最低,但不出网 |
| 前端直连后端 | JSON | 浏览器原生 |
| 日志/事件落盘 | JSON Lines | 可被采集器直接解析 |
| 超大数据传输 | Protobuf + 分块 | 单条报文过大时先分片 |
一句话:JSON 换调试效率,Protobuf 换带宽与演进纪律,Hessian 换 JVM 内的省事。三者都不是"更先进",只是交换的东西不同。
11. 自测
- 序列化的四个问题里,哪一个选错后最难纠正?为什么?
{"a":null}与{}在常见 JSON 绑定里有什么区别?要区分它们该怎么做?- Protobuf 删除字段时为什么必须
reserved?复用一个旧编号会发生什么? int32改成string属于哪一层的不兼容?- JDK 原生序列化的
serialVersionUID不显式声明会带来什么后果? - 什么情况下 gzip 压缩小报文反而让传输变大?