3. Avro Schema实战:从基础语法到兼容性模式

Avro 这东西,说白了就是数据序列化的"通用语"。我最早接触它是在做 Kafka 数据管道的时候,那时候团队还在用 JSON 传消息,结果字段一多、类型一改,消费端就炸成一片。后来换了 Avro,配合 Schema Registry,才算真正治好了"数据乱码"的顽疾。

这一章,咱们就手把手把 Avro Schema 的核心用法过一遍。我会从最基础的语法讲起,然后深入到复杂类型,最后重点聊聊兼容性模式——这部分是生产环境最容易踩坑的地方。

3.1 Avro 基础语法:JSON 之上的 Schema 语言

Avro Schema 本身是用 JSON 写的。嗯,你没看错,它的元数据就是 JSON。但别被这个表象迷惑了——它比普通 JSON 严谨得多。

一个最简单的 Avro Schema 长这样:

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

这里有几个关键点:

  • type:顶层必须是 record,表示这是一个记录类型
  • name:记录的名字,全限定名由 namespace + name 组成
  • fields:字段列表,每个字段必须有 name 和 type
  • default:可选,给字段一个默认值——这在兼容性里非常重要
我的习惯:只要字段有可能在未来被读取方跳过,就一定要给 default。哪怕给个 null 或者空字符串,也比没有强。我在项目中见过太多因为缺 default 导致的序列化异常。

Avro 支持的基础类型有:null、boolean、int、long、float、double、bytes、string。注意,没有 date 或 timestamp 这种高级类型——这些需要用逻辑类型(logical type)来扩展。

3.2 复杂类型定义:从数组到联合类型

实际业务中,数据结构很少是扁平的。Avro 提供了几种复杂类型来应对真实场景。

3.2.1 数组(Array)

{
  "name": "tags",
  "type": {
    "type": "array",
    "items": "string"
  }
}

数组里的元素类型必须一致。如果你想存不同类型的元素,那就得用联合类型。

3.2.2 联合类型(Union)

联合类型是 Avro 最强大的特性之一。它表示字段可以是多种类型中的一种:

{
  "name": "phone",
  "type": ["null", "string"]
}

这个写法表示 phone 可以是 null 或者 string。注意,null 必须放在第一位——这是 Avro 的硬性规定。为什么?因为 Avro 在序列化时用联合类型的索引来标记实际类型,null 的索引是 0,这样默认值就是 null,方便做向前兼容。

我曾经踩过的坑:有一次我把联合类型写成 ["string", "null"],结果旧数据反序列化时,Avro 认为索引 0 是 string,但实际数据是 null,直接抛异常。记住:null 永远放第一位。

3.2.3 枚举(Enum)

{
  "type": "enum",
  "name": "Status",
  "symbols": ["ACTIVE", "INACTIVE", "PENDING"]
}

枚举在数据治理里很有用——它限制了字段的可选值,相当于在 Schema 层面做了校验。不过要注意,枚举的 symbols 一旦发布,就不能删除,只能追加。否则会破坏兼容性。

3.2.4 嵌套 Record

Record 可以嵌套,形成树形结构:

{
  "type": "record",
  "name": "Order",
  "fields": [
    {"name": "orderId", "type": "string"},
    {
      "name": "customer",
      "type": {
        "type": "record",
        "name": "Customer",
        "fields": [
          {"name": "id", "type": "int"},
          {"name": "name", "type": "string"}
        ]
      }
    }
  ]
}

嵌套 Record 的 name 必须是全局唯一的。如果两个 Record 重名,Avro 会报错。所以命名空间(namespace)一定要规划好。

3.3 Schema 兼容性模式:生产环境的必修课

这部分是实战的重头戏。你想想看,数据生产者和消费者往往不是同一个团队,甚至不是同一个时间部署的。如果 Schema 变了,消费者怎么办?

Avro 定义了三种兼容性模式,配合 Schema Registry 使用。我直接用一个表格来对比:

模式 方向 规则 典型场景
BACKWARD 新 Schema 可以读旧数据 新 Schema 只能删除字段或添加有默认值的字段 消费者先升级,生产者后升级
FORWARD 旧 Schema 可以读新数据 新 Schema 只能添加字段或删除有默认值的字段 生产者先升级,消费者后升级
FULL 双向兼容 同时满足 BACKWARD 和 FORWARD 生产者和消费者同时升级,但不确定谁先

下面我分别展开讲。

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

这是最常用的模式。说白了,就是消费者先升级到新 Schema,然后生产者再慢慢升级。消费者用新 Schema 去读旧数据,不能报错。

规则很简单:

  • 可以删除字段
  • 可以添加字段,但必须有默认值
  • 不能修改已有字段的类型

举个例子:

// 旧 Schema
{"name": "name", "type": "string"}

// 新 Schema(BACKWARD 兼容)
{"name": "name", "type": "string"},
{"name": "email", "type": ["null", "string"], "default": null}

新 Schema 加了 email 字段,但给了默认值 null。消费者用新 Schema 读旧数据时,email 字段自动填充 null,不会报错。

实战建议:我一般推荐团队默认使用 BACKWARD 兼容。因为消费者升级通常比生产者容易——消费者只需要更新 Schema 定义,而生产者可能涉及多个上游系统的改造。

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

这个模式正好反过来。生产者先升级,消费者后升级。消费者用旧 Schema 去读新数据,不能报错。

规则:

  • 可以添加字段(旧 Schema 会忽略它)
  • 可以删除有默认值的字段
  • 不能修改已有字段的类型

举个例子:

// 旧 Schema
{"name": "name", "type": "string"},
{"name": "email", "type": ["null", "string"], "default": null}

// 新 Schema(FORWARD 兼容)
{"name": "name", "type": "string"}

新 Schema 删除了 email 字段。消费者用旧 Schema 读新数据时,email 字段用默认值 null 填充。

FORWARD 兼容在什么场景下用?我记得有一次,数据源团队临时加了一个调试字段,但消费端来不及改。我们就用了 FORWARD 模式,生产者先发新数据,消费者等下周再升级。嗯,这种场景挺常见的。

3.3.3 FULL 兼容:双向通吃

FULL 兼容要求同时满足 BACKWARD 和 FORWARD。这意味着:

  • 只能添加有默认值的字段
  • 只能删除有默认值的字段
  • 不能修改已有字段的类型

说白了,FULL 兼容就是"加字段必须有默认值,删字段也必须有默认值"。这样无论谁先升级,数据都能正常读写。

FULL 兼容是最安全的,但也是最严格的。我个人的经验是:如果团队发布节奏不可控,或者生产者和消费者由不同部门维护,就用 FULL。虽然约束多,但能避免很多线上事故。

3.4 兼容性模式选择决策图

下面这张图是我自己总结的决策流程,帮你快速判断该用哪种模式:

Avro 兼容性模式选择决策图 Avro 兼容性模式选择决策 Schema 需要变更 谁先升级?生产者还是消费者? 消费者先 BACKWARD 兼容 生产者先 FORWARD 兼容 不确定 FULL 兼容 BACKWARD 规则 • 可删字段 • 加字段必须有默认值 • 不改类型 FULL 规则 • 加字段必须有默认值 • 删字段必须有默认值 • 不改类型 FORWARD 规则 • 可加字段 • 删字段必须有默认值 • 不改类型 核心原则:不改类型,加/删字段必须有默认值 默认值是兼容性的"安全垫"

3.5 实战中的避坑指南

最后,分享几个我在项目中踩过的坑,希望能帮你少走弯路。

坑一:枚举类型不能删 symbol
我曾经在枚举里删了一个 symbol,结果所有用这个 symbol 的旧数据反序列化时直接报错。枚举的兼容性规则是:只能追加,不能删除,不能重排。
坑二:默认值类型必须匹配
比如字段类型是 ["null", "string"],默认值必须是 null,不能是空字符串 ""。Avro 对类型检查很严格,类型不匹配直接抛异常。
我的建议:在 Schema Registry 里开启兼容性校验。每次注册新 Schema 时,Registry 会自动检查是否兼容。如果不兼容,直接拒绝注册。这样就把问题扼杀在开发阶段,而不是等到线上才暴露。

好了,这一章的内容就到这里。Avro Schema 的语法和兼容性模式是数据契约治理的基石。你只要把基础类型、复杂类型、联合类型和三种兼容模式搞明白,后面讲 Schema Registry 和版本管理时就会轻松很多。