4. Schema as a Contract:使用 Avro/Protobuf 定义强类型契约

好,咱们进入第四讲。前面几章我们聊了数据契约是什么、为什么需要它。今天要聊的,是契约最硬核的部分——Schema 即契约。

说白了,就是把数据格式的定义,变成代码里强制执行的规则。你想想看,如果只是口头约定“这个字段是字符串”,那早晚会有人往里塞个 JSON 对象或者干脆传个 null。我见过太多因为字段类型不匹配导致管道半夜崩掉的案例了。

所以,我们需要一个强类型 Schema。目前最主流的两个选择:Avro 和 Protobuf。今天我就带你看看,怎么用它们把数据契约焊死在代码里。

4.1 为什么需要强类型 Schema?

先问个问题:你平时怎么定义数据格式?写个 JSON 示例?还是 Excel 文档?

这些方式都有个通病——无法被机器强制执行。你文档里写“age 是整数”,但上游系统发来一个 "age": "twenty",下游消费端直接崩溃。

强类型 Schema 的好处很明显:

  • 编译期检查:字段类型不对,代码根本编译不过
  • 序列化/反序列化自动完成:不用手写解析逻辑
  • 向前/向后兼容:Schema 演化有规则可循
  • 跨语言互通:Java 写的 Producer,Python 写的 Consumer,用同一份 Schema 定义

核心观点:强类型 Schema 不是文档,是代码。它把“约定”变成了“强制”。

4.2 Avro vs Protobuf:怎么选?

这两个我都用过。先说说我的感受。

Avro 是 Hadoop 生态的亲儿子。如果你在用 Kafka、Spark、Hive 这套,Avro 是天然选择。它的 Schema 是 JSON 格式,可读性好,而且支持动态类型。

Protobuf 是 gRPC 的标配。如果你做微服务间 RPC 调用,或者对性能要求极高,Protobuf 更合适。它的二进制编码更紧凑,序列化速度也更快。

我做个对比表,你一看就明白:

特性 Avro Protobuf
Schema 格式 JSON .proto 文件
序列化方式 二进制(自带 Schema) 二进制(需外部 Schema)
性能 中等 高
Schema 演化 强(支持多种规则) 强(有明确规则)
典型场景 Kafka、Hadoop、Spark gRPC、微服务、移动端
跨语言支持 Java、Python、C++ 等 几乎所有主流语言

我的建议:如果数据要进数据湖或数仓,选 Avro。如果是在服务间高频调用,选 Protobuf。两个都学也不亏,核心思想是相通的。

4.3 用 Avro 定义数据契约

好,咱们上手写代码。先看 Avro 的 Schema 长什么样。

假设我们要定义一个用户信息的数据契约:

{
  "type": "record",
  "name": "User",
  "namespace": "com.datacontract.user",
  "fields": [
    {"name": "user_id", "type": "string"},
    {"name": "name", "type": "string"},
    {"name": "age", "type": "int"},
    {"name": "email", "type": ["null", "string"], "default": null},
    {"name": "created_at", "type": "long", "logicalType": "timestamp-millis"}
  ]
}

注意几个关键点:

  • namespace:类似 Java 包名,避免命名冲突
  • 联合类型:["null", "string"] 表示这个字段可以为 null
  • default:给字段设置默认值,这是 Schema 演化的基础
  • logicalType:让 Avro 知道这个 long 其实是时间戳

有了 Schema,生成 Java 类:

// 用 Maven 插件或命令行工具
// avro-tools compile schema user.avsc ./

// 生成的代码可以直接用
User user = User.newBuilder()
    .setUserId("U001")
    .setName("张三")
    .setAge(28)
    .setEmail("zhangsan@example.com")
    .setCreatedAt(System.currentTimeMillis())
    .build();

// 序列化到 Kafka
byte[] bytes = user.toByteBuffer().array();

我在项目中遇到过一个问题:有人把 age 字段类型从 int 改成了 long,结果下游老版本的 Consumer 直接反序列化失败。这就是 Schema 演化没处理好。

4.4 用 Protobuf 定义数据契约

再看 Protobuf 的写法。同样的用户信息:

syntax = "proto3";

package com.datacontract.user;

option java_multiple_files = true;

message User {
  string user_id = 1;
  string name = 2;
  int32 age = 3;
  optional string email = 4;
  int64 created_at = 5;  // 毫秒时间戳
}

Protobuf 有几个特点:

  • 字段编号:每个字段有个唯一编号(1, 2, 3...),这是二进制编码的关键
  • optional:proto3 里用 optional 表示可空字段
  • package:命名空间,避免冲突

生成代码后使用:

// 编译 .proto 文件
// protoc --java_out=. user.proto

// 构建对象
User user = User.newBuilder()
    .setUserId("U001")
    .setName("张三")
    .setAge(28)
    .setEmail("zhangsan@example.com")
    .setCreatedAt(System.currentTimeMillis())
    .build();

// 序列化
byte[] bytes = user.toByteArray();

注意:Protobuf 的字段编号 1-15 用 1 个字节编码,16 以上用 2 个字节。高频字段尽量用小编号,能省带宽。我曾经见过有人把 200 多个字段全用编号 1-200,结果序列化体积大了好几倍。

4.5 Schema 演化:契约的版本管理

数据契约不是一成不变的。业务在变,字段在加,类型在改。怎么保证演化过程中上下游不崩?

Avro 和 Protobuf 都有一套演化规则。我总结一下:

演化操作 Avro Protobuf
新增字段 必须设置 default 新字段用 optional,老代码忽略
删除字段 保留字段名,标记为废弃 保留字段编号,用 reserved 标记
修改类型 仅允许兼容类型(int→long) 不允许直接改,建议新增字段
重命名字段 允许(按字段名匹配) 不允许(按编号匹配)

这里有个坑。我曾经在项目中用 Protobuf,有个同事直接删了一个字段,没保留编号。结果下游老代码读到那个编号的数据,解析成了另一个字段,数据全乱了。嗯,从那以后我定了个规矩:删除字段必须用 reserved 保留编号。

// 正确的做法
message User {
  reserved 4;  // 保留被删除的 email 字段编号
  string user_id = 1;
  string name = 2;
  int32 age = 3;
  int64 created_at = 5;
}

4.6 Schema Registry:契约的中央仓库

光有 Schema 还不够。你得有个地方统一管理这些契约,让 Producer 和 Consumer 都能拿到同一份定义。

这就是 Schema Registry 的作用。以 Confluent Schema Registry 为例:

  • 注册 Schema:Producer 在发送数据前,先注册 Schema
  • 版本管理:每次变更生成新版本,保留历史
  • 兼容性检查:新 Schema 必须通过兼容性规则才能注册
  • 自动分发:Consumer 从 Registry 拉取 Schema,反序列化数据

我画个流程图,帮你理解整个链路:

Schema Registry 数据流 Schema Registry 数据流 Producer Schema Registry 版本管理 / 兼容性检查 Consumer Kafka Topic 序列化后的二进制数据 注册 Schema 返回 Schema ID 数据 + Schema ID 拉取数据 请求 Schema 返回 Schema 核心流程:Producer 注册 Schema → Registry 校验兼容性 → 返回 Schema ID → 数据携带 ID 写入 Kafka → Consumer 根据 ID 拉取 Schema → 反序列化

实践建议:在 Kafka 生态里,我强烈建议用 Confluent Schema Registry。它支持 Avro、Protobuf、JSON Schema 三种格式。你只需要在 Producer 和 Consumer 的配置里加上 Registry 地址,序列化/反序列化自动完成。

4.7 兼容性策略:向前还是向后?

Schema Registry 支持几种兼容性策略。选错了,演化时会很痛苦。

策略 说明 适用场景
BACKWARD 新 Schema 可以读旧数据 Consumer 先升级
FORWARD 旧 Schema 可以读新数据 Producer 先升级
FULL 双向兼容 最安全,推荐
NONE 不检查兼容性 开发环境,别上生产

我个人习惯用 FULL 策略。虽然限制多一点,但能避免很多线上事故。你想想看,如果 Producer 先升级了,Consumer 还没升级,数据还能正常消费吗?FULL 策略保证双向兼容,谁先升级都行。

避坑指南:我曾经在一个项目里用了 NONE 策略,觉得“反正我们团队小,不会出问题”。结果上线第二天,有人改了字段类型没通知,下游消费端全崩了。从那以后,生产环境我坚决不用 NONE。

4.8 小结

好,这一章的内容就到这。总结几个关键点:

  • 强类型 Schema 是数据契约的基石,把约定变成代码
  • Avro 适合数据管道,Protobuf 适合 RPC,根据场景选
  • Schema 演化要遵守规则,新增字段设默认值,删除字段保留编号
  • Schema Registry 是契约的中央仓库,统一管理版本和兼容性
  • 兼容性策略选 FULL,双向兼容最安全