4、Schema兼容性规则:向前兼容、向后兼容、完全兼容、破坏性变更的识别

Schema 兼容性,说白了就是「改了字段定义后,老数据还能不能读?老程序还能不能跑?」。

我见过太多团队,一上来就搞 Avro、Protobuf,结果上线第一天就因为改了字段类型导致全线崩溃。嗯,这里要讲清楚。

核心原则:Schema 兼容性不是技术问题,是契约问题。你改的不是代码,是上下游之间的信任。

4.1 四种兼容性定义

先给个全景图。我习惯把这四种规则画成一张表,方便对照:

Schema 兼容性规则全景 向后兼容 新 Schema 可以读老数据 只能加可选字段 / 放宽类型 消费者无需升级 例:新增 nullable 字段 向前兼容 老 Schema 可以读新数据 只能删字段 / 收紧类型 生产者无需升级 例:删除废弃字段 完全兼容 向后 + 向前同时满足 双向读写无差异 最严格,适合核心契约 例:仅加 optional 字段 破坏性变更 任何一方无法正常读写 必须同步升级所有消费者 需要版本号 + 迁移窗口 例:删字段 / 改类型 双向 双向 ⚠ 注意:完全兼容 ≠ 无变更,而是变更后双向读写仍然成立。

4.2 向后兼容(Backward Compatibility)

这是最常用的规则。新 Schema 能读老数据,消费者不用改代码。

我在项目中遇到过:一个订单系统,老版本只有必填字段,新版本加了 optional 的优惠券字段。消费者没升级,照样能消费老数据——因为新 Schema 读到老数据时,优惠券字段自动填 null。

实操建议:向后兼容允许的操作:

  • 新增 optional / nullable 字段
  • 给已有字段设置默认值
  • 放宽字段类型(如 int → long)
  • 新增 enum 值(部分序列化框架支持)

避坑指南:我曾经见过一个团队,在 Protobuf 里把字段从 optional 改成 required,自以为是「小改动」。结果老数据反序列化直接抛异常,线上 P0 事故。记住:收紧约束永远是破坏性变更。

4.3 向前兼容(Forward Compatibility)

老 Schema 能读新数据。说白了,生产者可以先升级,消费者慢慢跟。

你想想看,在微服务架构里,这招特别有用。比如数据平台要废弃一个字段,先让生产者停写,消费者那边老 Schema 读到新数据时,遇到不认识字段直接忽略——这就是向前兼容。

操作 向后兼容 向前兼容 完全兼容
新增 optional 字段 ✅ ❌ ❌
删除 optional 字段 ❌ ✅ ❌
新增 optional + 默认值 ✅ ✅ ✅
修改字段类型(收紧) ❌ ❌ ❌
修改字段类型(放宽) ✅ ❌ ❌

4.4 完全兼容(Full Compatibility)

向后 + 向前同时满足。这是最严格的规则,适合核心业务契约。

我个人习惯在「订单状态」「用户等级」这类跨团队共享的 Schema 上启用完全兼容。代价是变更成本高,但换来的是上下游零协调。

完全兼容的典型操作:

  • 新增 optional 字段并带默认值
  • 新增 enum 值(需框架支持)
  • 给已有字段加 doc / description

除此之外,任何操作都会破坏完全兼容。

4.5 破坏性变更的识别

破坏性变更,就是改了之后,老数据读不了,或者老程序跑不动。识别它其实有套路:

  1. 删字段——老数据里还有这个字段,新 Schema 不认了,反序列化直接挂。
  2. 改字段类型——比如 string → int,老数据是 "abc",新 Schema 解析不了。
  3. 收紧约束——optional → required,nullable → non-null。
  4. 改字段名——除非框架支持别名映射,否则必挂。
  5. 改 enum 值名称——序列化存的是 ordinal 或 name,改了就对不上了。

避坑指南:我曾经在 Kafka 的 Schema Registry 上踩过坑。一个同事把 Avro 字段名从 user_id 改成 userId,觉得「只是改个命名风格」。结果下游所有消费者反序列化失败,因为 Avro 按字段名匹配,不是按位置。嗯,那次回滚花了 40 分钟。

4.6 实战:如何选择兼容性策略

没有银弹。我一般按场景选:

  • 核心业务契约(订单、支付、用户):完全兼容。变更必须走审批 + 灰度。
  • 内部数据管道(ETL、日志):向后兼容。生产者先改,消费者慢慢跟。
  • 事件溯源 / CDC:向前兼容。老消费者必须能读新事件。
  • 快速迭代模块:破坏性变更 + 版本号 + 迁移窗口。但要提前通知所有消费者。

一个小技巧:用 Schema Registry 做自动兼容性检查。每次提交新 Schema,CI 里跑一次兼容性校验,不通过直接阻断发布。我团队已经靠这个拦截了至少 10 次潜在的线上事故。

4.7 代码示例:Avro Schema 兼容性检查

以 Avro 为例,看看怎么用代码判断兼容性:

// 老 Schema
String oldSchemaStr = "{"
    + "\"type\": \"record\","
    + "\"name\": \"Order\","
    + "\"fields\": ["
    + "  {\"name\": \"id\", \"type\": \"string\"},"
    + "  {\"name\": \"amount\", \"type\": \"double\"}"
    + "]}";

// 新 Schema(加了 optional 的 coupon 字段)
String newSchemaStr = "{"
    + "\"type\": \"record\","
    + "\"name\": \"Order\","
    + "\"fields\": ["
    + "  {\"name\": \"id\", \"type\": \"string\"},"
    + "  {\"name\": \"amount\", \"type\": \"double\"},"
    + "  {\"name\": \"coupon\", \"type\": [\"null\", \"string\"], \"default\": null}"
    + "]}";

Schema oldSchema = new Schema.Parser().parse(oldSchemaStr);
Schema newSchema = new Schema.Parser().parse(newSchemaStr);

// 检查向后兼容
boolean backwardCompatible = SchemaCompatibility
    .checkReaderWriterCompatibility(newSchema, oldSchema)
    .getResult() == SchemaCompatibility.SchemaCompatibilityType.COMPATIBLE;

System.out.println("向后兼容: " + backwardCompatible); // true

为什么这样写?因为 Avro 的 checkReaderWriterCompatibility 方法,第一个参数是 reader(消费者),第二个是 writer(生产者)。新 Schema 作为 reader 去读老数据,能读通就是向后兼容。

总结一句话:兼容性规则不是理论,是线上血的教训。选对规则,比写对代码更重要。