先说结论
序列化是把对象转换为可传输或保存的数据格式,反序列化执行逆过程。业务系统通常根据可读性、体积、性能、跨语言和兼容性选择协议,不应默认使用 JDK 原生序列化。
JDK 序列化依赖 Serializable,serialVersionUID 用于版本校验,transient 字段默认不参与序列化。它与 Java 类型绑定较深,且历史上存在危险的反序列化调用链。
关键机制
JSON 可读且生态成熟,但类型和体积控制较弱;Protobuf 使用 schema 和字段编号,体积小、跨语言,新增可选字段通常容易兼容,但修改字段语义或复用已删除编号会破坏协议。
常见方案对比
| 方案 | 可读性 | 跨语言 | 体积与速度 | 演进方式 | 常见场景 |
|---|---|---|---|---|---|
| JDK 原生 | 低 | 差 | 体积较大 | 依赖 Java 类结构 | 受控的短期内部数据,通常不推荐做公共协议 |
| JSON | 高 | 好 | 体积较大、解析成本中等 | 依靠字段约定 | HTTP API、配置、调试友好链路 |
| Protobuf | 低 | 好 | 紧凑且高效 | schema + 字段编号 | RPC、消息、跨语言高吞吐链路 |
| Avro | 中 | 好 | 紧凑 | schema 解析与注册 | 数据平台、事件流、批处理 |
选型不能只跑一次序列化 benchmark。链路中通常还包含压缩、网络、对象分配、Schema Registry 和灰度兼容,端到端指标才有意义。
协议演进规则
一个生产者和多个不同版本消费者会长期共存,协议必须允许滚动升级。常见原则包括:
- 新字段提供默认语义,旧消费者可忽略未知字段。
- 不改变已有字段编号、类型和业务含义。
- 删除的 Protobuf 字段编号标记为 reserved,永不复用。
- 枚举新增值时,消费者必须能处理未知值,而不是进入非法状态。
- 结构性大改使用新事件类型或主版本,避免一个布尔字段改变整条消息解释。
例如把金额从“元的浮点数”改成“分的整数”即使字段类型兼容,语义也不兼容。正确做法是新增 amount_cent,双写双读完成迁移,再逐步废弃旧字段。
反序列化安全
反序列化不是单纯的数据解析,它可能实例化类型、调用钩子或消耗巨大资源。外部入口应限制消息大小、嵌套深度、集合长度和可实例化类型,依赖库及时升级,并避免对不可信字节使用 JDK 原生对象反序列化。
JSON 也并非天然安全。开启任意多态类型解析可能让攻击者指定危险类;超深 JSON、超大数字和压缩炸弹也会造成 CPU 或内存耗尽。
事件设计案例
订单事件建议传递稳定事实,而不是直接序列化 ORM 实体:
{
"event_id": "evt_01",
"event_type": "order.paid.v1",
"occurred_at": "2026-07-22T10:00:00Z",
"order_id": "O1001",
"amount_cent": 9900,
"currency": "CNY"
}
事件 ID 用于幂等,类型与版本确定解释方式,时间采用明确时区,金额避免浮点误差。消费者只依赖事件契约,不依赖生产者内部 Java 类名。
实际用时要注意什么
- 网络入口只接收白名单类型和受控协议。
- 消息与缓存内容必须携带版本,支持灰度期间新旧消费者共存。
- 不把数据库实体直接当长期外部协议,避免内部字段变化外溢。
容易踩坑的地方
序列化成功不代表协议可长期演进;性能也不能只看编码耗时,还要看数据体积、网络、GC 和 schema 管理成本。
常见问题
追问:serialVersionUID 不一致会怎样?
JDK 反序列化会进行版本检查,不一致通常抛出 InvalidClassException。显式声明只能控制版本标识,不能自动解决字段语义不兼容。
追问:transient 字段一定不会被序列化吗?
它不会参与默认 JDK 序列化,但类可通过自定义 writeObject/readObject 处理数据;其他 JSON 或二进制框架也有自己的忽略规则,不能把 transient 当通用协议注解。
追问:为什么不直接把数据库实体发送到 Kafka?
实体包含持久化细节、懒加载关系和频繁变化字段,会让消费者与生产者内部模型强耦合。独立事件 DTO 更容易做最小披露、版本演进和长期治理。
追问:压缩应该放在序列化前还是后?
先序列化得到字节,再对批次或消息压缩。批量压缩通常比单条压缩效果好,但要限制解压后大小并评估 CPU 与延迟。