4、Avro Schema基础:数据类型、Record定义、Schema演进与序列化

好,咱们进入Avro的世界。说实话,在众多序列化框架里,Avro是我个人最偏爱的一个。为什么?因为它把Schema放到了第一位。你想想看,没有Schema,数据就是一盘散沙,你根本不知道哪个字段是啥意思。我在做实时数仓项目时,团队一开始用JSON,后来数据量一上来,解析慢、体积大、字段名还经常拼错……那叫一个痛苦。后来切到Avro,这些问题才算真正解决。

4.1 Avro的数据类型体系

Avro的类型系统,说白了就两大类:基本类型和复杂类型。咱们一个一个看。

4.1.1 基本类型

Avro定义了8种基本类型,我列个表,你一眼就能看明白:

类型 说明 JSON示例
null 空值,没有数据 null
boolean 布尔值 true
int 32位有符号整数 42
long 64位有符号整数 1234567890L
float 单精度浮点数 3.14
double 双精度浮点数 3.1415926
bytes 字节序列 "\u00FF"
string UTF-8编码字符串 "hello"

嗯,这里要注意:int和long在序列化时用的是变长编码。值越小,占的字节越少。我见过有人把时间戳用int存,结果2038年问题就来了……所以时间戳我建议一律用long。

4.1.2 复杂类型

复杂类型才是Avro真正发力的地方。一共6种:

  • record:最核心的,相当于结构体或Java Bean
  • enum:枚举,固定值集合
  • array:数组,所有元素类型必须一致
  • map:字典,key必须是string,value可以是任意类型
  • union:联合类型,表示“可以是这些类型中的一种”
  • fixed:固定长度字节数组

我个人觉得,union是最容易被忽视但最有用的类型。它天然支持了“字段可能为空”的场景。比如一个用户的手机号,可能没填,那类型就是["null", "string"]。

我的习惯:所有可能为空的字段,一律用 ["null", "xxx"] 包裹。别用默认值代替null,那会埋坑。

4.2 Record定义——Schema的骨架

Record是Avro里最常用的复杂类型。一个Record包含:名称、命名空间、字段列表。每个字段又有名字、类型、默认值(可选)。

来看一个实际例子。我在做用户画像系统时,定义过这样一个Schema:

{
  "type": "record",
  "name": "UserProfile",
  "namespace": "com.example.profile",
  "fields": [
    {"name": "userId", "type": "string"},
    {"name": "userName", "type": "string"},
    {"name": "age", "type": "int", "default": 0},
    {"name": "email", "type": ["null", "string"], "default": null},
    {"name": "tags", "type": {"type": "array", "items": "string"}, "default": []}
  ]
}

这里有几个关键点:

  • name + namespace 构成了全限定名,类似Java的包名+类名
  • default 是给Schema演进用的,后面会细说
  • 复杂类型可以嵌套定义,比如tags字段的type就是一个内联的array定义
我曾经踩过的坑:字段名不要用Java/C++的保留字,比如class、interface。虽然Avro不限制,但生成代码时会编译报错。我那次改了几十个字段名,教训深刻。

4.3 Schema演进规则——向后兼容的艺术

数据是活的,Schema不可能一成不变。但怎么变才能不破坏已有的数据?Avro有一套明确的规则。我把它总结成一张图,你一看就懂:

Avro Schema演进规则总览 Schema演进 添加字段(必须有默认值) 删除字段(读者可忽略) 修改字段类型(有限制) 添加细则 新字段必须有default值 写者Schema可以没有该字段 删除细则 读者Schema可以没有该字段 写者Schema仍可包含该字段 修改细则 int→long/float/double 允许 string→bytes 允许 核心原则:写者Schema ≠ 读者Schema 只要类型兼容,数据就能正确读写

核心规则其实就一句话:写者Schema和读者Schema可以不同,只要它们兼容。什么意思?生产者写数据时用Schema A,消费者读数据时用Schema B,只要A和B满足演进规则,Avro就能自动完成字段映射。

具体规则我列一下:

  • 添加字段:新字段必须有默认值。这样旧数据读过来时,自动填默认值。
  • 删除字段:读者Schema可以没有某个字段,写者Schema有也没关系,读者直接忽略。
  • 修改字段类型:有限制。比如int可以升为long、float、double,但不能降级。字符串可以转bytes,反之不行。
  • 重命名字段:不支持直接改名。但可以用别名(aliases)做桥接。
  • 调整字段顺序:完全没问题。Avro通过字段名匹配,不依赖位置。
实际经验:我在做数据管道时,经常遇到上游加字段、下游没来得及更新的情况。因为Avro的演进规则,只要新字段有默认值,下游完全不用改代码。这省了多少上线审批啊!

4.4 序列化与反序列化——让数据流动起来

有了Schema,序列化就变得很优雅了。Avro的序列化结果包含两部分:Schema本身(或Schema的标识) + 二进制数据。数据部分只存字段值,不存字段名,所以体积小、速度快。

我拿Java代码举个例子,这是最常用的方式:

// 1. 定义Schema(可以从文件加载,也可以硬编码)
Schema schema = new Schema.Parser().parse(
    "{\"type\":\"record\",\"name\":\"User\",\"fields\":[" +
    "{\"name\":\"name\",\"type\":\"string\"}," +
    "{\"name\":\"age\",\"type\":\"int\"}]}"
);

// 2. 创建GenericRecord并赋值
GenericRecord user = new GenericData.Record(schema);
user.put("name", "张三");
user.put("age", 28);

// 3. 序列化:写入ByteArrayOutputStream
ByteArrayOutputStream out = new ByteArrayOutputStream();
DatumWriter<GenericRecord> writer = new GenericDatumWriter<>(schema);
Encoder encoder = EncoderFactory.get().binaryEncoder(out, null);
writer.write(user, encoder);
encoder.flush();
byte[] bytes = out.toByteArray();

// 4. 反序列化:从字节数组读回来
DatumReader<GenericRecord> reader = new GenericDatumReader<>(schema);
Decoder decoder = DecoderFactory.get().binaryDecoder(bytes, null);
GenericRecord result = reader.read(null, decoder);
System.out.println(result.get("name")); // 输出:张三

你看,整个过程非常对称。写的时候用DatumWriter,读的时候用DatumReader。中间那个byte[]就是你在网络上传输或磁盘上存储的数据。

我建议:生产环境不要每次都传完整Schema。可以用Schema Registry,只传一个Schema ID。这样每条记录只多4个字节,解析时去Registry拉Schema。Kafka Avro Serde就是这么干的。

4.3.1 序列化格式对比

Avro支持两种序列化格式:

格式 特点 适用场景
Binary 体积小、速度快、不可读 生产环境、高吞吐管道
JSON 可读性好、体积大、速度慢 调试、测试、小数据量

我个人习惯:开发调试用JSON格式,方便看数据;上线后切到Binary,性能差距能有5-10倍。

4.5 小结

Avro Schema是整个数据治理的基石。你掌握了数据类型、Record定义、演进规则和序列化方法,就等于拿到了数据一致性的第一把钥匙。记住:Schema不是束缚,而是契约。有了这个契约,生产者和消费者才能各司其职,数据才能在管道里顺畅流动。