《Java 消息中间件·可靠消息与消费治理》8.2 schema 演进与兼容性

加字段、删字段、改类型、改字段名,四种变更分别能不能兼容?本篇用同一份 JSON payload,让老消费者读新消息、新消费者读旧消息,八个方向逐个跑,贴出 ClassCastException 与 NullPointerException 的原文,并给出双写双读的发布顺序、回滚判据与七条上线前检查清单。

兼容性要先说清方向

「这个变更兼容吗」不是一个是非题。至少要拆成两个方向:

  • 向后兼容(backward):新版本的消费者能读旧版本生产者写的数据。
  • 向前兼容(forward):旧版本的消费者能读新版本生产者写的数据。

两个都成立叫双向兼容,也叫全兼容。只成立一个的变更很常见,发布顺序因此变得重要:向后兼容的变更可以先升级消费者,向前兼容的变更可以先升级生产者。

本篇的「老」「新」都指代码版本,不是部署顺序。实验里让两个版本的读取代码同时存在,同一份字节分别喂给它们,看谁成功、谁抛什么异常。

四类变更的预期

先给结论,再用实测验证。表里的「宽容消费者」指忽略未知字段、对缺失字段给默认值或明确报错的消费者。

变更向后(新读旧)向前(旧读新)结论
加可选字段兼容兼容最安全,优先选它
加必填字段不兼容兼容旧数据没有该字段,新消费者读不了
删可选字段兼容兼容前提是没人依赖它
删必填字段兼容不兼容旧消费者仍按必填读
改字段类型不兼容不兼容两个方向都炸
改字段名不兼容不兼容等价于删旧字段加新字段
必填改可选兼容兼容放宽约束
可选改必填不兼容兼容旧数据可能缺这个字段

「加必填字段向前兼容」有一个前提:旧消费者必须容忍未知字段。如果它见到不认识的字段就报错,加任何字段都会破坏向前兼容。这条前提比表里其他结论更容易被忽略。

JSON 为什么让问题变隐蔽

用 JSON 传事件时,运行时没有 schema。字段少了,映射器给你 null;字段多了,宽容的映射器直接忽略;类型变了,可能抛异常,也可能悄悄转成字符串。三种结果对应三种故障:

  • null 流到下游:memberId 缺失,映射层不报错,直到某个 Objects.requireNonNull 或数据库非空约束才炸,栈里已经看不到消息来源。
  • 未知字段被静默丢弃:老消费者读新消息不报错,但新字段承载的业务含义永远丢失,直到某个对账日才发现。
  • 类型被隐式转换:"1001" 和 1001 都可能被某些映射器接受,等到参与计算时才出现精度或格式问题。

所以「JSON 很灵活」不是免费的。灵活性把错误从反序列化时刻推迟到了业务逻辑深处,排查成本更高。

三个容易漏掉的细节

整数和小数的类型不同。 本实验的解析器把 1795072800000 读成 Long,把 1795072800000.0 读成 Double。有些映射器更激进:只要目标字段是 long,"1001" 这样的字符串也能转。同一个字段在不同语言、不同映射器下可能得到不同类型,契约里必须写死「整数、不分单位、不允许字符串」。

null 和字段缺失不是一回事。 {"memberId":null} 与 {} 在 Java 映射后都可能是 null,但语义不同:前者是「明确置空」,后者是「对方根本没发」。如果业务需要区分,就不能只靠映射结果,要在契约层规定「可空字段必须显式出现」。

字段顺序不承载语义。 JSON 对象无序,消费者不能依赖字段先后。需要顺序的场景(比如多值字段的优先级)要用数组或显式序号,不要靠文本顺序。

实验设置

环境:Temurin 25.0.5+8,javac --release 25,无第三方依赖。

实验不使用 broker。生产端用字符串拼出四种 v2 payload(等价于序列化结果,没有引入序列化库),消费端用另一版本的读取代码反序列化。兼容性只取决于字节和映射代码,broker 原样搬运字节,不改变结论。这样也避免了把「schema 兼容」和「集群配置」两件事混在一起。

为了让实验可复现,写了一个约 140 行的最小 JSON 解析器,只支持本实验用到的对象、字符串、整数和 null。它不是生产建议,只是为了遵守全书「只用 JDK 标准库」的约束。生产上你会用 Jackson、Avro 或 Protobuf,异常类名不同,语义相同。

v1 的 payload:

{"eventId":"11111111-1111-4111-8111-111111111111","occurredAt":"2026-10-29T07:20:00Z","loanId":"L-1001","bookId":"B-42","memberId":"M-7","borrowedAt":"2026-10-29T07:20:00Z","dueAt":"2026-11-19T07:20:00Z"}

四个 v2 变体只改一处:

新增字段  ... ,"branchId":"BR-3"
删除字段  ... 去掉 "memberId":"M-7"
类型变更  "dueAt":"2026-11-19T07:20:00Z" -> "dueAt":1795072800000
重命名字段 "memberId":"M-7" -> "borrowerId":"M-7"

老读者的核心代码如下,str 在字段缺失时抛 NullPointerException,在类型不符时做强制转换:

record BookBorrowedV1(UUID eventId, Instant occurredAt, String loanId, String bookId,
                      String memberId, Instant borrowedAt, Instant dueAt) {
    static BookBorrowedV1 read(Map<String, Object> m) {
        return new BookBorrowedV1(
                UUID.fromString(str(m, "eventId")),
                Instant.parse(str(m, "occurredAt")),
                str(m, "loanId"),
                str(m, "bookId"),
                str(m, "memberId"),
                Instant.parse(str(m, "borrowedAt")),
                Instant.parse(str(m, "dueAt")));
    }
}

static String str(Map<String, Object> m, String key) {
    return (String) Objects.requireNonNull(m.get(key), "missing field: " + key);
}

新读者按每个变体分别实现:新增字段版多读一个可空的 branchId,删除字段版不读 memberId,类型变更版用 (Long) 读 dueAt,重命名版读 borrowerId。

老消费者读新消息

命令与输出(为排版,略去「生成四条 v2 消息」那一段,toString 里的完整 UUID 用 ... 省略,其余原样):

java -cp out com.plume.mq.lab.SchemaEvolutionLab
== A. 老读者(v1)读新生产者的消息 ==
OK   A1 新增字段 / 宽容解析                         -> BookBorrowedV1[eventId=11111111-..., loanId=L-1001, bookId=B-42, memberId=M-7, borrowedAt=2026-10-29T07:20:00Z, dueAt=2026-11-19T07:20:00Z]
FAIL A1 新增字段 / 严格解析                         -> java.lang.IllegalArgumentException: unknown field: branchId
FAIL A2 删除字段                                -> java.lang.NullPointerException: missing field: memberId
FAIL A3 类型变更 dueAt: string -> long          -> java.lang.ClassCastException: class java.lang.Long cannot be cast to class java.lang.String (java.lang.Long and java.lang.String are in module java.base of loader 'bootstrap')
FAIL A4 字段重命名 memberId -> borrowerId        -> java.lang.NullPointerException: missing field: memberId

四个结果对应四条结论:

  1. 加字段本身不破坏向前兼容,严格映射器才会。 宽容解析成功,严格解析在 branchId 处失败。失败原因不是「多了字段」这件事,而是「映射器被配置成拒绝未知字段」。
  2. 删字段对老消费者是硬故障。 memberId 从 payload 消失,读取层拿到 null,requireNonNull 抛出 missing field: memberId。
  3. 改类型在两个方向都失败。 老读者期望字符串,拿到 Long,强制转换抛 ClassCastException。
  4. 改字段名等价于删字段。 老读者找 memberId,payload 里只有 borrowerId,结果与删除字段完全一样。名字变了,数据其实还在,但契约上就是没了。

注意 A3 的异常消息:class java.lang.Long cannot be cast to class java.lang.String。这不是「JSON 解析失败」,JSON 本身完全合法;失败发生在「把解析结果映射到 Java 类型」这一步。很多线上问题就卡在这个区分上:日志显示反序列化成功,业务却拿到错误类型。

新消费者读旧消息

== B. 新读者读旧消息(向前兼容) ==
OK   B1 新增可选字段                              -> BookBorrowedAdd[... branchId=null]
OK   B2 删除字段                                -> BookBorrowedRemoved[...]
FAIL B3 类型变更 dueAt: long                    -> java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Long (java.lang.String and java.lang.Long are in module java.base of loader 'bootstrap')
FAIL B4 字段重命名(只认新名)                         -> java.lang.NullPointerException: missing field: borrowerId
OK   B4 字段重命名(新名优先、老名兜底)                    -> BookBorrowedRenamedTolerant[... borrowerId=M-7 ...]

四条结论:

  • 加可选字段是双向兼容的。 新读者读旧消息时 branchId 为 null,只要业务允许 null,就能正常处理。这里要把「可空」写进契约,而不是等 NPE 来提醒。
  • 删字段对新读者没有影响。 新代码根本不读 memberId,旧消息里有没有它都无所谓。真正的风险在别处:如果还有别的消费者依赖 memberId,删除就是破坏性的。
  • 类型变更反向也失败。 新读者期望 Long,旧消息给的是字符串,ClassCastException。
  • 重命名可以通过「双读」过渡。 只认新名的版本读旧消息失败;新名优先、老名兜底的版本成功。这是重命名唯一安全的落地方式:先让新代码同时认两个名字,等所有旧消息过期或回灌完成,再删掉老名字。

双读的代码如下,关键是「新字段存在就用新字段,否则回退老字段」:

static BookBorrowedRenamedTolerant read(Map<String, Object> m) {
    String borrower = m.containsKey("borrowerId")
            ? str(m, "borrowerId") : str(m, "memberId");
    return new BookBorrowedRenamedTolerant(
            UUID.fromString(str(m, "eventId")),
            Instant.parse(str(m, "occurredAt")),
            str(m, "loanId"),
            str(m, "bookId"),
            borrower,
            Instant.parse(str(m, "borrowedAt")),
            Instant.parse(str(m, "dueAt")));
}

八个方向的总表

把两组输出合成一张表,OK 是成功,FAIL 后面是实际抛出的异常:

变更老读新(向前)新读旧(向后)
加可选字段OK(宽容)/ FAIL IllegalArgumentException: unknown field(严格)OK,字段为 null
加必填字段同「加可选」,取决于旧消费者是否严格FAIL NullPointerException: missing field
删可选字段OKOK
删必填字段FAIL NullPointerException: missing fieldOK
改类型FAIL ClassCastExceptionFAIL ClassCastException
改字段名FAIL NullPointerException: missing fieldFAIL NullPointerException: missing field;双读则 OK

「加必填字段」这一行没有单独跑,因为它在字节层面与「加可选字段」完全一样,差别只在读取代码是否把该字段标为必填。把读取代码里的 optional 改成 required,B 方向就会从 OK 变成 NullPointerException。这是代码约束,不是 payload 差异,所以不重复贴输出。

消费者必须容忍未知字段

向前兼容的核心不是「别加字段」,而是「旧消费者别见到新字段就死」。三条落地要求:

  1. 映射器关闭「未知字段报错」。 若用 Jackson,对应 FAIL_ON_UNKNOWN_PROPERTIES=false;本实验里对应不调用严格解析。注意:这条配置未在本机用 Jackson 执行,仅作概念对照。
  2. 未知字段不能静默影响业务。 忽略不等于丢弃语义。新字段如果承载关键信息,老消费者读不懂就应该走降级逻辑,而不是假装它不存在。可以在消息头放一个「最低消费版本」,低于它的消费者拒收并告警。
  3. 测试里要反向验证。 单元测试不能只测「当前版本读当前版本」,还要构造一条「未来版本」的 payload,断言当前消费者能读、能忽略未知字段、能对缺失的可选字段给默认值。

严格模式不是一无是处。它适合放在契约测试里:CI 用严格模式验证「生产者只发契约里声明的字段」,防止拼写错误和多发字段。运行时用宽容模式,测试时用严格模式,两个配置各司其职。

发布顺序:扩容再收缩

兼容性表决定发布顺序。把变更分成「先做安全的部分,再做危险的部分」:

加字段

先升级生产者,发可选字段;消费者可以慢慢升级。等所有消费者都能读新字段,再把它改成必填。不要一步到位发必填字段,旧消费者和旧数据都会挡路。

删字段

先确认没有消费者读它。做法是先加监控:统计每个字段的读取次数,或者让消费者在读到该字段时打点。确认零访问后,先升级所有消费者停止读取,再升级生产者停止发送,最后才从契约里删除。顺序反了,旧消费者会在新消息上 NPE。

改类型

不要原地改。加一个新字段(新名字、新类型),双写一段时间,消费者双读,等旧字段零访问后删除。原地改类型在两个方向都不兼容,没有安全的滚动窗口。

改字段名

同「改类型」:新名双写、消费者双读、旧名零访问后删除。双读代码要保留到所有旧消息过期,保留期至少覆盖主题的 retention.ms,否则回放历史数据时还会遇到旧名字。

回滚

回滚不是「把代码退回去」这么简单。如果新版本已经写入了旧版本读不懂的消息,回滚消费者会立刻失败。所以每次 schema 变更都要回答:新消息在旧代码上能不能读? 不能,就不能只回滚代码,还要准备数据回灌或双读兼容层。这是 8.2 和 14 章演练的交界:变更前先做一次回滚演练。

变更窗口期要盯三个指标,而不是只看错误日志:

  • 消费失败率按异常类型分组:ClassCastException、NullPointerException、未知字段错误分开统计,才能判断是哪类变更出的问题。
  • x-schema-version 的分布:还有多少生产者或消费者停在旧版本,决定什么时候可以收缩兼容层。
  • 未知字段与缺失字段的计数:前者说明消费者在静默丢信息,后者说明契约和代码不一致,两者都不会立刻报错,但都会积累数据问题。

监控不比日志精确,但能在大面积故障前给出趋势。日志告诉你「这一条为什么失败」,指标告诉你「还有多少条会失败」。

本机实测的边界

这次实测覆盖了 JSON 的四种字段变更和两个读取方向,没有覆盖:

  • Avro / Protobuf:它们有显式 schema,未知字段和缺失字段的行为由 schema 解析规则决定,不完全等同于 JSON 映射器。
  • Schema Registry 的服务端兼容性检查:8.3 节用自管脚本模拟,不引入第三方客户端。
  • 跨语言消费者:Java 读 JSON 的行为不能代表 Go、Python 的映射器。
  • broker 端配置:message.max.bytes、压缩、header 传递都不影响本实验结论,但会影响真实链路的可用性。

这些边界不影响四类变更的定性结论:加可选字段最安全,改类型和改字段名最危险,删字段要看方向,未知字段必须容忍。

本次实验是单次运行,结果只用于判断「成功/失败」与「异常类型」,不用于性能或吞吐结论。异常消息里的类型名和模块名来自 Temurin 25 的 java.base,换成别的 JDK 大版本,措辞可能略有差异,但异常类别不会变。

兼容性检查清单

发布前逐条回答:

  1. 这次变更属于八种里的哪一种?对照表里两个方向分别是什么结果?
  2. 是否新增了必填字段?旧数据怎么办,补默认值还是禁止旧数据?
  3. 是否删除了字段?所有消费者都停止读取了吗,保留期覆盖了吗?
  4. 是否改了类型或名字?有没有双写、双读、回灌三件套?
  5. 旧消费者会不会因为未知字段报错?运行时映射器的未知字段策略是什么?
  6. 回滚时旧代码能不能读新消息?不能的话,回滚方案是什么?
  7. 契约测试是否构造了「未来版本」的 payload?

七条里任何一条答不上来,这次变更就不应该上线。

本节判据

  1. 向后兼容和向前兼容分别指什么? 答:向后是新消费者读旧数据,向前是旧消费者读新数据。
  2. 加字段一定兼容吗? 答:加可选字段双向兼容;加必填字段向后不兼容,因为旧数据没有该字段。
  3. 删字段什么时候安全? 答:删可选字段且确认无人依赖时安全;删必填字段对旧消费者是硬故障。
  4. 改字段名为什么等价于删字段? 答:老消费者按旧名字查找,新 payload 里只有新名字,结果与字段缺失相同;必须双读过渡。
  5. 为什么运行时宽容、测试时严格? 答:运行时宽容保证向前兼容,测试严格防止生产者多发或拼错字段。

阅读导航:上一节 8.1 领域事件建模 | 下一节 8.3 Schema Registry 的取舍

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收