上游加了一个字段,下游全部反序列化失败
订单服务在消息里加了一个
giftWrap字段,只加不改,自认为是兼容的变更。发布后,消费这条消息的几个服务开始成批报错,其中一个根本用不到这个字段。
发送方和接收方很少同时升级。一条消息从写出到读入,两端的数据结构可能差一个版本:多一个字段、少一个字段、枚举多了一个值。这时候会发生什么,由序列化格式和库的默认配置决定,而且默认配置在不同的库、同一个库的不同大版本之间并不一样。
本文用 Jackson 2.22.3、Jackson 3.2.3 和 protobuf-java 4.36.2 把这几种情况各跑一遍。实验见文末。
一、接收方没见过的字段
上游发出 {"id":"A1","quantity":2,"giftWrap":true},下游的类里只有 id 和 quantity:
发送方多了一个 giftWrap 字段,Jackson 2 默认:抛出 UnrecognizedPropertyException
同一条消息,Jackson 3 默认:OrderV1[id=A1, quantity=2]Jackson 2 的 ObjectMapper 默认遇到不认识的字段就失败,Jackson 3 把这个默认值改成了忽略。开头的事故就是前一种:下游用的是不加配置的 Jackson 2。Spring Boot 自动配置的 ObjectMapper 是关掉这个检查的,但代码里自己 new ObjectMapper() 的地方不受它管,一个项目里两种行为并存很常见。
对于跨服务的消息和接口,「加字段」必须是兼容的,否则上游每加一个字段都要所有下游先发版。所以消费方应当忽略不认识的字段。严格模式适合另一类场景:配置文件、对内的管理接口,拼错的字段名应该立刻报错。
Protobuf 的行为是协议本身规定的:
旧版本解析新版本的消息:quantity = 2,不认识的字段编号 [3] 留在 unknown fields 里不认识的字段不报错,也不丢弃,保留在消息对象里。
二、读出来、改一下、再写回去
忽略未知字段解决了「读」。但很多服务不只是读:网关转发、把消息读出来补充几个字段再发到下一个主题、从缓存读出对象改一个属性再写回。
按旧版本的类读入、数量改成 3、再写出:{"id":"A1","quantity":3}(giftWrap 没有了)旧版本的类里没有地方放 giftWrap,读入时被忽略,写出时自然没有。下游的新版本服务读到这条消息,以为上游没有传这个字段。没有任何一环报错,数据就这样在中间被抹掉了,排查时很难想到是一个「没改过代码」的中间服务造成的。
类上用 @JsonAnySetter / @JsonAnyGetter 收下不认识的字段:{"id":"A1","quantity":3,"giftWrap":true}需要转发或回写的服务,要么用树模型(JsonNode)只改自己关心的部分,要么在类上留一个兜底的 Map 接住不认识的字段。
Protobuf 在这一点上是默认做对的:
旧版本把数量改成 3 再发出去,新版本读到:quantity = 3,gift_wrap = true未知字段跟着消息一起被重新序列化。
三、少了字段,和字段是 null
| 情况 | Jackson 2 默认 | Jackson 3 默认 |
|---|---|---|
消息里没有 quantity(int) | 0 | 0 |
quantity 是 null | 0 | 抛出 MismatchedInputException |
缺少的基本类型字段被填成 0。「数量是 0」和「上游没传数量」在对象里无法区分,对金额、数量、开关这类字段很危险:一个旧版本上游没有传 discountRate,下游按 0 处理,或者没有传 enabled,下游按 false 处理。需要区分时用包装类型,并且对必填字段显式校验。
「没传」和「传了 null」是另一组需要区分的状态,典型场景是部分更新:
「没传 nickname」与「nickname 传了 null」读进对象之后:null 与 null,分不出来
读成树:没传时 has("nickname") = false;传了 null 时 has = true,isNull = true客户端想表达「不修改昵称」和「清空昵称」,绑定到普通对象后是同一个 null。部分更新的接口要么用树模型判断字段是否出现,要么换一种表达方式(例如显式列出要修改的字段名)。
Protobuf 里对应的问题是 0 与没填:
proto3 的 int32 quantity = 2:填 0 与不填,序列化结果相同 = true(都是 4 字节),字段能否判断有没有填 = false
改成 optional int32 quantity = 2:填 0 是 6 字节、不填是 4 字节;hasField 分别是 true 与 falseproto3 的普通标量字段不记录「有没有填」,值等于默认值时根本不出现在线上数据里。需要区分的字段声明为 optional。
四、接收方没见过的枚举值
发送方新增了枚举值 REFUNDING:Jackson 2 抛出 InvalidFormatException;Jackson 3 抛出 InvalidFormatException两个大版本都失败。加一个枚举值看上去和加一个字段一样无害,实际上它是不兼容的变更:所有还没升级的消费方都读不了带新值的消息。
可选的做法有三种,都需要在加新值之前就准备好:
- 消费方给枚举配置一个兜底值(
@JsonEnumDefaultValue加上对应的反序列化特性),不认识的值落到UNKNOWN。 - 消息里用字符串传状态,消费方自己决定怎么处理不认识的值。
- 先发布能识别新值的消费方,再让生产方开始发送。
Protobuf 取决于语法版本:
proto3:旧版本读到 status = UNKNOWN_ENUM_VALUE_Status_2(数值 2),unknown fields [];原样转发后新版本读到 REFUNDING
proto2:旧版本读到 status = CREATED(数值 0),unknown fields [2];原样转发后新版本读到 REFUNDINGproto3 的枚举是开放的,不认识的数值原样保留,代码里看到的是一个「未识别」的值。proto2 的枚举是封闭的,读到的是默认值 CREATED,原始数值放在未知字段里。后一种更隐蔽:旧版本的代码会把一笔退款中的订单当成刚创建的来处理。两种情况下转发都不丢信息。不论哪种,业务代码里对枚举的 switch 都要有处理未知值的分支。
五、Protobuf 的字段编号
Protobuf 在线上只传字段编号和类型,不传字段名。由此得到两条性质:
编号 2 只改名为 mail_address;读旧数据:a@example.com改名是兼容的(只要不用它的 JSON 映射,JSON 里出现的是字段名)。
反过来,编号一旦用过就不能给别的字段:
编号 2 原来是 email(string),删掉后给了 nickname(string);读旧数据:nickname = a@example.com
编号 2 给了 age(int32);读旧数据:age = 0,编号 [2] 进了 unknown fields,不报错
编号 3 从 int64 改成 int32;原值 5000000000 读成 705032704- 新字段的类型与旧字段相同时,旧数据里的邮箱被当成昵称读出来,显示在页面上。
- 类型不同时不报错,新字段读到默认值,旧数据进了未知字段。
- 把
int64改成int32,超出范围的值被截断成一个看上去正常的数。
删除字段时用 reserved 把编号(和名字)占住,编译器会阻止以后有人重用。存量数据在消息队列、数据库、缓存里可以留存很久,「旧数据已经没有了」这个判断通常不成立。
六、同一个库,两个大版本的默认值
从 Jackson 2 升级到 Jackson 3,不改任何代码,下面这些行为就变了:
| 场景 | Jackson 2.22.3 默认 | Jackson 3.2.3 默认 |
|---|---|---|
| 不认识的字段 | 抛异常 | 忽略 |
| 基本类型字段的值是 null | 填 0 | 抛异常 |
| 合法 JSON 后面跟着多余内容 | 正常读入 | 抛异常 |
写 Instant | 抛异常;注册 JavaTimeModule 后写成 1791619200.000000000 | 写成 "2026-10-10T08:00:00Z" |
时间的写法变化影响的是所有读这份 JSON 的一方:
Jackson 3 读 Jackson 2 写的:2026-10-10T08:00:00Z;Jackson 2 读 Jackson 3 写的:2026-10-10T08:00:00ZJackson 自己两种写法都能读。但前端、其他语言写的服务、把 JSON 存进数据库再用 SQL 查的报表,原来拿到的是数字,升级后拿到的是字符串。对外的 JSON 格式要显式配置,不依赖库的默认值;升级序列化库的大版本时,把「同一个对象序列化出来的字节是否变了」作为一项测试。
读成 Map 时还有一个与版本无关的问题:
读成 Map 之后各个值的类型:small → Integer big → Long id → Long price → Double
(Long) map.get("small"):抛出 ClassCastException没有类型信息时,整数按大小被解析成 Integer 或 Long。测试数据里的 ID 都很大时强制转换成 Long 没有问题,遇到一个小的 ID 就抛异常;金额则变成了 Double。这类取值统一用 ((Number) v).longValue(),金额不要经过无类型的 Map,见对账差了一分钱。
七、哪些变更是兼容的
| 变更 | JSON(消费方忽略未知字段) | Protobuf |
|---|---|---|
| 加一个可选字段 | 兼容;中间环节读改写会丢 | 兼容;转发时保留 |
| 删一个字段 | 消费方不依赖它才兼容 | 兼容,编号要 reserved |
| 改字段名 | 不兼容 | 兼容(二进制格式) |
| 改字段类型 | 不兼容 | 多数不兼容,有的悄悄截断 |
| 加一个枚举值 | 不兼容,除非消费方有兜底 | 不报错,代码要处理未知值 |
| 把可选改成必填 | 不兼容 | proto3 没有必填 |
「兼容」分两个方向:新代码读旧数据(升级消费方时),旧代码读新数据(升级生产方时)。滚动发布期间两个方向同时存在,回滚时方向反过来。只要数据会落盘或进队列,旧数据就会在升级之后很久仍被读到。数据库结构变更里同样的顺序问题,见能重新部署旧版本,不等于能回滚。
八、检查清单
- 跨服务的消息与接口,消费方忽略不认识的字段;项目里所有的
ObjectMapper是同一套配置。 - 会转发、回写的环节不丢未知字段。
- 需要区分「没传」的字段用包装类型或
optional;部分更新能区分「没传」与「清空」。 - 加枚举值之前,确认所有消费方能处理不认识的值。
- Protobuf 删字段时
reserved编号与名字;不改已有字段的类型。 - 对外的日期、数字格式显式配置。
- 契约有兼容性测试:用上一个版本的样例数据喂给新代码,用新版本的样例数据喂给旧代码。
配套实验
- codesphere-labs/engineering/serialization-compatibility:Jackson 2.22.3 与 3.2.3 的默认配置、protobuf-java 4.36.2 的
DynamicMessage(JDK 25.0.4.1):未知字段、缺失与 null、未知枚举值、读改写、字段编号重用、0 与没填、两个大版本默认值的差别(验证记录)
参考资料