兼容性要先说清方向
「这个变更兼容吗」不是一个是非题。至少要拆成两个方向:
- 向后兼容(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
四个结果对应四条结论:
- 加字段本身不破坏向前兼容,严格映射器才会。 宽容解析成功,严格解析在
branchId处失败。失败原因不是「多了字段」这件事,而是「映射器被配置成拒绝未知字段」。 - 删字段对老消费者是硬故障。
memberId从 payload 消失,读取层拿到 null,requireNonNull抛出missing field: memberId。 - 改类型在两个方向都失败。 老读者期望字符串,拿到
Long,强制转换抛ClassCastException。 - 改字段名等价于删字段。 老读者找
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 |
| 删可选字段 | OK | OK |
| 删必填字段 | FAIL NullPointerException: missing field | OK |
| 改类型 | FAIL ClassCastException | FAIL ClassCastException |
| 改字段名 | FAIL NullPointerException: missing field | FAIL NullPointerException: missing field;双读则 OK |
「加必填字段」这一行没有单独跑,因为它在字节层面与「加可选字段」完全一样,差别只在读取代码是否把该字段标为必填。把读取代码里的 optional 改成 required,B 方向就会从 OK 变成 NullPointerException。这是代码约束,不是 payload 差异,所以不重复贴输出。
消费者必须容忍未知字段
向前兼容的核心不是「别加字段」,而是「旧消费者别见到新字段就死」。三条落地要求:
- 映射器关闭「未知字段报错」。 若用 Jackson,对应
FAIL_ON_UNKNOWN_PROPERTIES=false;本实验里对应不调用严格解析。注意:这条配置未在本机用 Jackson 执行,仅作概念对照。 - 未知字段不能静默影响业务。 忽略不等于丢弃语义。新字段如果承载关键信息,老消费者读不懂就应该走降级逻辑,而不是假装它不存在。可以在消息头放一个「最低消费版本」,低于它的消费者拒收并告警。
- 测试里要反向验证。 单元测试不能只测「当前版本读当前版本」,还要构造一条「未来版本」的 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 大版本,措辞可能略有差异,但异常类别不会变。
兼容性检查清单
发布前逐条回答:
- 这次变更属于八种里的哪一种?对照表里两个方向分别是什么结果?
- 是否新增了必填字段?旧数据怎么办,补默认值还是禁止旧数据?
- 是否删除了字段?所有消费者都停止读取了吗,保留期覆盖了吗?
- 是否改了类型或名字?有没有双写、双读、回灌三件套?
- 旧消费者会不会因为未知字段报错?运行时映射器的未知字段策略是什么?
- 回滚时旧代码能不能读新消息?不能的话,回滚方案是什么?
- 契约测试是否构造了「未来版本」的 payload?
七条里任何一条答不上来,这次变更就不应该上线。
本节判据
- 向后兼容和向前兼容分别指什么? 答:向后是新消费者读旧数据,向前是旧消费者读新数据。
- 加字段一定兼容吗? 答:加可选字段双向兼容;加必填字段向后不兼容,因为旧数据没有该字段。
- 删字段什么时候安全? 答:删可选字段且确认无人依赖时安全;删必填字段对旧消费者是硬故障。
- 改字段名为什么等价于删字段? 答:老消费者按旧名字查找,新 payload 里只有新名字,结果与字段缺失相同;必须双读过渡。
- 为什么运行时宽容、测试时严格? 答:运行时宽容保证向前兼容,测试严格防止生产者多发或拼错字段。
阅读导航:上一节 8.1 领域事件建模 | 下一节 8.3 Schema Registry 的取舍
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。