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:可选,给字段一个默认值——这在兼容性里非常重要
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,不会报错。
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 兼容性模式选择决策图
下面这张图是我自己总结的决策流程,帮你快速判断该用哪种模式:
3.5 实战中的避坑指南
最后,分享几个我在项目中踩过的坑,希望能帮你少走弯路。
我曾经在枚举里删了一个 symbol,结果所有用这个 symbol 的旧数据反序列化时直接报错。枚举的兼容性规则是:只能追加,不能删除,不能重排。
比如字段类型是
["null", "string"],默认值必须是 null,不能是空字符串 ""。Avro 对类型检查很严格,类型不匹配直接抛异常。
好了,这一章的内容就到这里。Avro Schema 的语法和兼容性模式是数据契约治理的基石。你只要把基础类型、复杂类型、联合类型和三种兼容模式搞明白,后面讲 Schema Registry 和版本管理时就会轻松很多。