2535 字
约 8 分钟
17
S06-X13 · 序列化与协议:JSON、Protobuf 与 Hessian

接口上线三个月,加了一个字段 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. 版本兼容的三条纪律

  1. 只增字段,不删不改。删除改为"标记废弃 + 停止写入",保留编号占位。
  2. 编号/字段名永不复用。复用的编号会与新语义冲突,旧客户端读到不认识的类型。
  3. 默认值语义要写进文档。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. 自测

  1. 序列化的四个问题里,哪一个选错后最难纠正?为什么?
  2. {"a":null} 与 {} 在常见 JSON 绑定里有什么区别?要区分它们该怎么做?
  3. Protobuf 删除字段时为什么必须 reserved?复用一个旧编号会发生什么?
  4. int32 改成 string 属于哪一层的不兼容?
  5. JDK 原生序列化的 serialVersionUID 不显式声明会带来什么后果?
  6. 什么情况下 gzip 压缩小报文反而让传输变大?
S06-X13 · 序列化与协议:JSON、Protobuf 与 Hessian
https://www.clxhxhhr.top/posts/5986/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。