4. Avro Schema详解:从基础语法到兼容性实战

Avro 的 Schema,说白了就是数据的「说明书」。没有它,二进制数据就是一串乱码。我在做流批一体项目时,吃过不少 Schema 的亏——版本对不上、字段类型不匹配,消费端直接报错。今天咱们就把 Avro Schema 从头捋一遍。

4.1 Avro Schema 基础语法

Avro Schema 用 JSON 描述。嗯,你没看错,就是 JSON。它有两种写法:

  • 简单类型:直接写类型名,比如 "string"、"int"
  • 复杂类型:用 JSON 对象,比如 {"type": "record", "name": "User", ...}

我个人习惯把 Schema 文件存为 .avsc 后缀,方便 IDE 识别。来看一个最简例子:

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

这里 namespace 类似 Java 的包名,用来避免重名。我在项目中遇到过两个团队都定义了 Order 类型,结果序列化时串了——加个 namespace 就解决了。

4.2 基本类型与复杂类型

Avro 的基本类型有 8 种,不多,但够用:

类型 说明 示例值
null 空值 null
boolean 布尔 true / false
int 32 位整数 42
long 64 位整数 1234567890L
float 32 位浮点 3.14
double 64 位浮点 3.1415926
bytes 字节序列 \u00FF
string UTF-8 字符串 "hello"

复杂类型才是重头戏。Avro 支持 6 种:

  • record:结构体,最常用
  • enum:枚举
  • array:数组
  • map:字典,key 必须是 string
  • union:联合类型,类似「或」
  • fixed:固定长度字节

你想想看,union 是不是很像 Java 的 Optional?没错,["null", "string"] 就表示这个字段可以为 null 也可以是字符串。我在做 Kafka 消息时,几乎每个字段都加了 null,避免数据缺失导致反序列化失败。

4.3 Record 与 Enum 的定义

Record 是 Avro 的核心。咱们定义一个复杂点的:

{
  "type": "record",
  "name": "Order",
  "namespace": "com.example.order",
  "doc": "订单记录",
  "fields": [
    {"name": "orderId", "type": "string", "doc": "订单号"},
    {"name": "userId", "type": "int"},
    {"name": "amount", "type": "double", "default": 0.0},
    {"name": "status", "type": {
      "type": "enum",
      "name": "OrderStatus",
      "symbols": ["PENDING", "PAID", "SHIPPED", "CANCELLED"]
    }},
    {"name": "items", "type": {
      "type": "array",
      "items": {
        "type": "record",
        "name": "OrderItem",
        "fields": [
          {"name": "productId", "type": "int"},
          {"name": "quantity", "type": "int", "default": 1}
        ]
      }
    }},
    {"name": "tags", "type": {"type": "map", "values": "string"}, "default": {}}
  ]
}

这里有几个要点:

  • doc 字段写注释,生成文档时很有用
  • default 给字段设默认值,读旧数据时不会崩
  • Enum 的 symbols 是枚举值列表,顺序不能乱改
  • Record 可以嵌套,OrderItem 定义在 Order 内部
我的习惯:所有字段都加 default,除非是业务上必填的。这样 Schema 演进时,旧数据也能正常读取。

4.4 Schema 兼容性规则

这部分是实战中的硬骨头。Avro 的兼容性分 4 种级别:

兼容性级别 规则 典型场景
BACKWARD 新 Schema 能读旧数据 消费者升级,生产者未升级
FORWARD 旧 Schema 能读新数据 生产者升级,消费者未升级
FULL 双向兼容 生产者和消费者独立升级
NONE 不检查兼容性 开发测试环境

具体规则有哪些?我总结了几条核心的:

  • 删除字段:只允许删除有默认值的字段(BACKWARD 下不允许删除)
  • 添加字段:必须带默认值(FORWARD 下允许)
  • 修改字段类型:只能做兼容性转换,比如 int→long 可以,string→int 不行
  • Enum 增减符号:只能在末尾添加,不能删除或重排
  • Union 顺序:默认值类型必须排在 union 的第一个位置
我曾经踩过的坑:在 Enum 中间插入了一个新符号,结果所有旧数据反序列化都报错。后来我规定:Enum 只能追加,不能插入或删除。这个规则写进了团队的 Schema 治理规范。

为什么会这样?Avro 序列化时,Enum 存的是索引(0, 1, 2...),不是字符串。你插在中间,索引全乱了。同理,删除符号也会导致索引错位。

4.5 兼容性实战:一个 Schema 演进案例

假设我们有个 User 表,v1 版本:

{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"}
  ]
}

现在要加一个 email 字段,同时废弃 phone 字段。怎么做才兼容?

我的做法是分两步走:

  1. v2 版本:添加 email(带默认值),phone 保留但标记 deprecated
  2. v3 版本:等所有消费者都升级后,再删除 phone
// v2 版本
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": ["null", "string"], "default": null},
    {"name": "phone", "type": ["null", "string"], "default": null, "doc": "deprecated"}
  ]
}

核心原则:Schema 演进要遵循「加字段不加索引,删字段不删定义」的策略。用 union + null 做可选字段,用 doc 标记废弃字段。

4.6 知识体系总览

下面这张图把 Avro Schema 的核心知识点串起来了:

Avro Schema 知识体系 Avro Schema 基础语法 基本类型 复杂类型 兼容性规则 null / boolean / int long / float / double bytes / string record / enum array / map union / fixed BACKWARD / FORWARD FULL / NONE 核心原则:加字段带默认值,删字段先标记废弃,Enum 只追加不插入 Union 类型中默认值类型必须排在第一位

这张图把 Avro Schema 的四个核心维度串在一起了。你从中心出发,往左是基本类型,往右是复杂类型,往下是兼容性规则。每个分支都有对应的子节点,方便你快速定位知识点。

4.7 避坑总结

最后,我把自己在项目中踩过的坑列出来,你直接拿去用:

  • Enum 不要改顺序:索引编码,改了就是灾难
  • Union 默认值放第一位:否则反序列化会选错类型
  • 删除字段要分两步:先标记 deprecated,等所有消费者升级后再删
  • Namespace 一定要加:避免不同团队的类型冲突
  • Schema Registry 要开启兼容性检查:别用 NONE,除非你在玩火
我的建议:生产环境用 FULL 兼容性,开发环境用 BACKWARD。这样既能保证数据不丢,又能灵活迭代。