Skip to content

上游加了一个字段,下游全部反序列化失败 ​

订单服务在消息里加了一个 giftWrap 字段,只加不改,自认为是兼容的变更。发布后,消费这条消息的几个服务开始成批报错,其中一个根本用不到这个字段。

发送方和接收方很少同时升级。一条消息从写出到读入,两端的数据结构可能差一个版本:多一个字段、少一个字段、枚举多了一个值。这时候会发生什么,由序列化格式和库的默认配置决定,而且默认配置在不同的库、同一个库的不同大版本之间并不一样。

本文用 Jackson 2.22.3、Jackson 3.2.3 和 protobuf-java 4.36.2 把这几种情况各跑一遍。实验见文末。

上游(新版本)发出id = A1, quantity = 2, gift_wrap = true中间的服务(旧版本)读入时把数量改成 3 后转发出去JSON,Jackson 2 默认抛出异常(没有转发)UnrecognizedPropertyException:整条消息处理失败JSON,忽略未知字段读成只有两个字段的对象id = A1, quantity = 3gift_wrap 没有了,下游以为上游没传Protobuf两个已知字段 + 未知字段 3id = A1, quantity = 3, 3: true下游按新版本解析:gift_wrap = true「能读」只是兼容的一半;会转发、会回写的中间环节,还要保证不认识的字段不被丢掉
图 1 · 实测:上游在消息里加了一个字段,中间一个还没升级的服务读入、改了数量、再转发给下游。Jackson 2 的默认配置直接拒绝这条消息;配置成忽略未知字段(Jackson 3 的默认)后能读,但对象里没有地方放这个字段,转发出去就丢了;Protobuf 把不认识的字段原样保留在消息里,转发时带着。

一、接收方没见过的字段 ​

上游发出 {"id":"A1","quantity":2,"giftWrap":true},下游的类里只有 id 和 quantity:

text
发送方多了一个 giftWrap 字段,Jackson 2 默认:抛出 UnrecognizedPropertyException
同一条消息,Jackson 3 默认:OrderV1[id=A1, quantity=2]

Jackson 2 的 ObjectMapper 默认遇到不认识的字段就失败,Jackson 3 把这个默认值改成了忽略。开头的事故就是前一种:下游用的是不加配置的 Jackson 2。Spring Boot 自动配置的 ObjectMapper 是关掉这个检查的,但代码里自己 new ObjectMapper() 的地方不受它管,一个项目里两种行为并存很常见。

对于跨服务的消息和接口,「加字段」必须是兼容的,否则上游每加一个字段都要所有下游先发版。所以消费方应当忽略不认识的字段。严格模式适合另一类场景:配置文件、对内的管理接口,拼错的字段名应该立刻报错。

Protobuf 的行为是协议本身规定的:

text
旧版本解析新版本的消息:quantity = 2,不认识的字段编号 [3] 留在 unknown fields 里

不认识的字段不报错,也不丢弃,保留在消息对象里。

二、读出来、改一下、再写回去 ​

忽略未知字段解决了「读」。但很多服务不只是读:网关转发、把消息读出来补充几个字段再发到下一个主题、从缓存读出对象改一个属性再写回。

text
按旧版本的类读入、数量改成 3、再写出:{"id":"A1","quantity":3}(giftWrap 没有了)

旧版本的类里没有地方放 giftWrap,读入时被忽略,写出时自然没有。下游的新版本服务读到这条消息,以为上游没有传这个字段。没有任何一环报错,数据就这样在中间被抹掉了,排查时很难想到是一个「没改过代码」的中间服务造成的。

text
类上用 @JsonAnySetter / @JsonAnyGetter 收下不认识的字段:{"id":"A1","quantity":3,"giftWrap":true}

需要转发或回写的服务,要么用树模型(JsonNode)只改自己关心的部分,要么在类上留一个兜底的 Map 接住不认识的字段。

Protobuf 在这一点上是默认做对的:

text
旧版本把数量改成 3 再发出去,新版本读到:quantity = 3,gift_wrap = true

未知字段跟着消息一起被重新序列化。

三、少了字段,和字段是 null ​

情况Jackson 2 默认Jackson 3 默认
消息里没有 quantity(int)00
quantity 是 null0抛出 MismatchedInputException

缺少的基本类型字段被填成 0。「数量是 0」和「上游没传数量」在对象里无法区分,对金额、数量、开关这类字段很危险:一个旧版本上游没有传 discountRate,下游按 0 处理,或者没有传 enabled,下游按 false 处理。需要区分时用包装类型,并且对必填字段显式校验。

「没传」和「传了 null」是另一组需要区分的状态,典型场景是部分更新:

text
「没传 nickname」与「nickname 传了 null」读进对象之后:null 与 null,分不出来
读成树:没传时 has("nickname") = false;传了 null 时 has = true,isNull = true

客户端想表达「不修改昵称」和「清空昵称」,绑定到普通对象后是同一个 null。部分更新的接口要么用树模型判断字段是否出现,要么换一种表达方式(例如显式列出要修改的字段名)。

Protobuf 里对应的问题是 0 与没填:

text
proto3 的 int32 quantity = 2:填 0 与不填,序列化结果相同 = true(都是 4 字节),字段能否判断有没有填 = false
改成 optional int32 quantity = 2:填 0 是 6 字节、不填是 4 字节;hasField 分别是 true 与 false

proto3 的普通标量字段不记录「有没有填」,值等于默认值时根本不出现在线上数据里。需要区分的字段声明为 optional。

四、接收方没见过的枚举值 ​

text
发送方新增了枚举值 REFUNDING:Jackson 2 抛出 InvalidFormatException;Jackson 3 抛出 InvalidFormatException

两个大版本都失败。加一个枚举值看上去和加一个字段一样无害,实际上它是不兼容的变更:所有还没升级的消费方都读不了带新值的消息。

可选的做法有三种,都需要在加新值之前就准备好:

  • 消费方给枚举配置一个兜底值(@JsonEnumDefaultValue 加上对应的反序列化特性),不认识的值落到 UNKNOWN。
  • 消息里用字符串传状态,消费方自己决定怎么处理不认识的值。
  • 先发布能识别新值的消费方,再让生产方开始发送。

Protobuf 取决于语法版本:

text
proto3:旧版本读到 status = UNKNOWN_ENUM_VALUE_Status_2(数值 2),unknown fields [];原样转发后新版本读到 REFUNDING
proto2:旧版本读到 status = CREATED(数值 0),unknown fields [2];原样转发后新版本读到 REFUNDING

proto3 的枚举是开放的,不认识的数值原样保留,代码里看到的是一个「未识别」的值。proto2 的枚举是封闭的,读到的是默认值 CREATED,原始数值放在未知字段里。后一种更隐蔽:旧版本的代码会把一笔退款中的订单当成刚创建的来处理。两种情况下转发都不丢信息。不论哪种,业务代码里对枚举的 switch 都要有处理未知值的分支。

五、Protobuf 的字段编号 ​

Protobuf 在线上只传字段编号和类型,不传字段名。由此得到两条性质:

text
编号 2 只改名为 mail_address;读旧数据:a@example.com

改名是兼容的(只要不用它的 JSON 映射,JSON 里出现的是字段名)。

反过来,编号一旦用过就不能给别的字段:

text
编号 2 原来是 email(string),删掉后给了 nickname(string);读旧数据:nickname = a@example.com
编号 2 给了 age(int32);读旧数据:age = 0,编号 [2] 进了 unknown fields,不报错
编号 3 从 int64 改成 int32;原值 5000000000 读成 705032704
  • 新字段的类型与旧字段相同时,旧数据里的邮箱被当成昵称读出来,显示在页面上。
  • 类型不同时不报错,新字段读到默认值,旧数据进了未知字段。
  • 把 int64 改成 int32,超出范围的值被截断成一个看上去正常的数。
同一份旧数据,按新定义读2: email (string)旧数据2: nickname (string)新定义nickname = a@example.com邮箱被当成昵称显示2: email (string)旧数据2: age (int32)新定义age = 0原值进 unknown fields,不报错3: int64 5000000000旧数据3: int32新定义705032704截断成一个看上去正常的数只改字段名、编号不变是兼容的(二进制格式);编号一旦用过就不能给别的字段
图 2 · 实测(protobuf-java 4.36.2):线上只传编号和类型,不传字段名。编号 2 从 email 换给同为 string 的 nickname,旧数据里的邮箱被当成昵称读出;换给 int32 的 age,不报错,读到 0,原值进了未知字段;编号 3 从 int64 改成 int32,5000000000 读成 705032704。删字段时用 reserved 占住编号与名字

删除字段时用 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 的一方:

text
Jackson 3 读 Jackson 2 写的:2026-10-10T08:00:00Z;Jackson 2 读 Jackson 3 写的:2026-10-10T08:00:00Z

Jackson 自己两种写法都能读。但前端、其他语言写的服务、把 JSON 存进数据库再用 SQL 查的报表,原来拿到的是数字,升级后拿到的是字符串。对外的 JSON 格式要显式配置,不依赖库的默认值;升级序列化库的大版本时,把「同一个对象序列化出来的字节是否变了」作为一项测试。

读成 Map 时还有一个与版本无关的问题:

text
读成 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 编号与名字;不改已有字段的类型。
  • 对外的日期、数字格式显式配置。
  • 契约有兼容性测试:用上一个版本的样例数据喂给新代码,用新版本的样例数据喂给旧代码。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权