4、Avro Schema实战:定义你的第一个Avro Schema,兼容性规则详解

好,咱们开始动手写Schema。前面讲了那么多理论,今天就来点实在的。我会带你从零定义一个Avro Schema,然后把那四个兼容性规则——BACKWARD、FORWARD、FULL、NONE——掰开揉碎讲清楚。

说实话,我见过太多团队在Schema兼容性上栽跟头。有的是上线前一天才发现新Schema把老数据全搞崩了,有的是为了兼容性把Schema写得跟天书一样。嗯,咱们今天就把这些坑填平。

4.1 定义你的第一个Avro Schema

Avro Schema有两种写法:JSON格式和IDL(接口定义语言)。我个人习惯用JSON,因为它在Kafka、Hadoop这些生态里是原生支持的。咱们先看一个最简单的例子。

{
  "namespace": "com.example.user",
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": "string"}
  ]
}

这个Schema定义了一个叫User的记录,有三个字段。你想想看,它其实就是一个数据契约——告诉所有生产者和消费者:数据长这样,别乱来。

小提示:namespace的作用是避免命名冲突。如果你的公司有多个部门,建议用com.公司名.部门名的格式。我在项目中见过两个团队都定义了Order,结果序列化时串了,排查了半天。

咱们再加点料。实际业务中,字段往往有默认值,或者允许为空。看这个升级版:

{
  "namespace": "com.example.user",
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string", "default": ""},
    {"name": "email", "type": ["null", "string"], "default": null},
    {"name": "age", "type": "int", "default": 0}
  ]
}

这里有几个关键点:

  • default:给字段一个默认值。当读取旧数据时,如果新Schema多了字段,就用默认值填充。
  • 联合类型:["null", "string"]表示这个字段可以是null或者string。注意,null要写在第一位,这是Avro的约定。

为什么要加默认值?说白了,就是为了兼容性。没有默认值,新Schema读旧数据就会直接报错。我曾经在一个支付系统里,就因为忘了给新加的discount字段设默认值,导致凌晨的批处理任务全挂了……嗯,从那以后我再也不敢漏掉default了。

4.2 兼容性规则:四个选项,四种策略

Avro的兼容性规则,说白了就是控制Schema怎么变、变多大。咱们用一张图来理解:

Avro Schema 兼容性规则全景图 兼容性规则 BACKWARD 新Schema可读旧数据 FORWARD 旧Schema可读新数据 FULL 双向兼容 NONE 不兼容,随便改 💡 核心原则: • 生产者用新Schema写,消费者用旧Schema读 → 需要FORWARD兼容 • 生产者用旧Schema写,消费者用新Schema读 → 需要BACKWARD兼容

这张图把四个规则的关系讲清楚了。咱们一个一个来看。

4.3 BACKWARD 兼容:新Schema能读旧数据

这是最常用的规则。场景是这样的:你先用旧Schema写了一批数据,然后升级了Schema。BACKWARD保证新Schema能正常读取旧数据。

怎么做到?说白了就是:新Schema只能删字段,或者加有默认值的字段。不能加必填字段。

看个例子:

// 旧Schema
{
  "type": "record",
  "name": "Order",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "amount", "type": "double"}
  ]
}

// 新Schema(BACKWARD兼容)
{
  "type": "record",
  "name": "Order",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "amount", "type": "double"},
    {"name": "discount", "type": "double", "default": 0.0}
  ]
}

新Schema加了discount字段,但有默认值0.0。读旧数据时,没有discount字段,就用0.0填充。完美。

注意:如果新Schema删了一个字段,旧数据里还有这个字段的值,读的时候会直接忽略。但如果你删的是旧Schema里没有默认值的字段,而新Schema又没定义它……嗯,那就会报错。所以删字段要谨慎。

4.4 FORWARD 兼容:旧Schema能读新数据

这个规则正好反过来。场景是:消费者还没升级Schema,但生产者已经用新Schema写数据了。FORWARD保证旧Schema能正常读取新数据。

怎么做?旧Schema只能忽略新加的字段,或者给新字段设默认值。但注意,这里有个关键点:旧Schema必须为所有它不认识的字段提供默认值处理机制。

Avro的FORWARD兼容其实是通过字段ID和默认值实现的。每个字段在Schema里都有一个隐式的ID(按顺序排列),新加的字段ID更大。旧Schema读到不认识的ID时,就跳过。

看例子:

// 旧Schema
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"}
  ]
}

// 新Schema(FORWARD兼容)
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string", "default": ""}
  ]
}

旧Schema只有id字段。读到新数据时,遇到name字段,它不认识,就跳过。但注意,如果新Schema的name没有默认值,旧Schema读到这条数据时,因为缺少name字段,就会报错。所以FORWARD兼容要求:新加的字段必须有默认值。

我建议你在设计Schema时,尽量给每个字段都设默认值。哪怕默认值是null或者空字符串,也比没有强。这样以后改起来灵活得多。

4.5 FULL 兼容:双向兼容

FULL就是BACKWARD + FORWARD。新Schema能读旧数据,旧Schema也能读新数据。说白了,就是两边都能互相理解。

这要求很严格:

  • 只能加有默认值的字段
  • 只能删有默认值的字段
  • 不能改字段类型
  • 不能改字段名

看个例子:

// Schema V1
{
  "type": "record",
  "name": "Product",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "price", "type": "double", "default": 0.0}
  ]
}

// Schema V2(FULL兼容)
{
  "type": "record",
  "name": "Product",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "price", "type": "double", "default": 0.0},
    {"name": "stock", "type": "int", "default": 0}
  ]
}

V2加了stock字段,有默认值。V1读V2数据时,忽略stock;V2读V1数据时,stock用默认值0填充。双向都通。

实战建议:如果你的系统是流式处理,比如Kafka,我强烈建议用FULL兼容。因为生产者和消费者可能在不同时间升级,FULL能保证中间状态不崩。我在一个实时风控系统里就用FULL,三年没出过兼容性问题。

4.6 NONE 兼容:没有规则就是规则

NONE就是没有兼容性要求。你想怎么改就怎么改,加字段、删字段、改类型,随便来。

但代价是什么?所有用旧Schema的消费者,在读到新数据时都会报错。除非你同时升级所有生产者和消费者。

什么时候用NONE?

  • 开发环境,数据不重要
  • 一次性批处理,跑完就删
  • 所有客户端都能同时升级(比如内部微服务,用同一个发布流水线)

我个人几乎不用NONE。哪怕在开发环境,我也习惯用BACKWARD。因为有一次我在测试环境用了NONE,结果忘了通知下游团队,他们消费数据时直接崩了……嗯,从那以后我就长记性了。

4.7 四种规则对比

咱们用一张表总结一下:

规则 新Schema读旧数据 旧Schema读新数据 允许操作 推荐场景
BACKWARD ✅ 支持 ❌ 不支持 加有默认值的字段、删字段 消费者先升级,生产者后升级
FORWARD ❌ 不支持 ✅ 支持 加有默认值的字段 生产者先升级,消费者后升级
FULL ✅ 支持 ✅ 支持 加有默认值的字段、删有默认值的字段 生产者和消费者独立升级
NONE ❌ 不支持 ❌ 不支持 任意修改 开发环境、一次性任务

你想想看,选哪个规则其实取决于你的发布策略。如果你们是蓝绿部署,消费者先切流量,那就用BACKWARD。如果是灰度发布,生产者先写新数据,那就用FORWARD。如果两边独立迭代,那就用FULL。

4.8 避坑指南

最后,分享几个我踩过的坑:

  • 默认值不是万能药:加了默认值,旧数据读出来就是默认值。但业务逻辑可能不认这个默认值。比如discount默认0.0,但业务上0.0表示没有折扣,而实际旧数据可能有折扣只是没存……嗯,这就要靠业务对齐了。
  • 枚举类型要小心:Avro的enum类型,加新值默认是FORWARD不兼容的。除非你给enum设默认值。但说实话,我建议少用enum,用string加校验代替。
  • 字段改名是大忌:Avro没有字段别名机制。改名相当于删一个字段再加一个字段,兼容性很难保证。我一般用@deprecated注解标记旧字段,等所有消费者都迁移了再删。
  • 测试!测试!测试!:每次改Schema,一定要用兼容性检查工具跑一遍。Avro官方提供了SchemaCompatibility类,可以写单元测试。别偷懒,我见过太多上线前才发现不兼容的案例了。

好了,这一章的内容就到这。你先把第一个Schema写出来,跑通序列化和反序列化,然后试着改一改字段,看看不同兼容性规则的效果。动手才是最好的学习方式。