2、Schema Registry核心概念:Avro/Protobuf/JSON Schema对比、Schema Registry架构、兼容性类型详解
好,咱们进入第二章。上一章聊了为什么CDC需要Schema治理,这一章咱们把工具本身拆开看看。
Schema Registry这东西,说白了就是一个「元数据中心」。它管着所有消息的「身份证」——也就是Schema。没有它,生产者和消费者之间全靠默契,一旦一方改了格式,整个管道就崩了。我在项目里见过太多次这种事故了。
2.1 三大序列化格式:Avro / Protobuf / JSON Schema
选哪个格式?这是每个团队都要做的决定。我个人的经验是:没有银弹,但场景决定选择。
| 特性 | Avro | Protobuf | JSON Schema |
|---|---|---|---|
| 序列化结果 | 二进制(紧凑) | 二进制(紧凑) | 文本(JSON) |
| Schema定义语言 | JSON / IDL | .proto 文件 | JSON |
| 是否需要Schema | 必须(强依赖) | 必须(编译时) | 可选(可校验) |
| 向前/向后兼容 | 原生支持 | 需手动配置 | 需手动配置 |
| 生态成熟度 | Kafka生态首选 | 微服务/ gRPC 标配 | Web API 友好 |
| 性能(吞吐) | 高 | 高 | 中 |
我的建议:如果团队主要用Kafka做CDC,Avro是首选。它和Confluent Schema Registry配合得最好,兼容性策略也最成熟。Protobuf更适合跨语言微服务调用。JSON Schema嘛……适合那些不想引入额外编译步骤的团队,但序列化开销大,高吞吐场景慎用。
2.1.1 Avro 示例
{
"type": "record",
"name": "User",
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"},
{"name": "email", "type": ["null", "string"], "default": null}
]
}
注意那个 ["null", "string"],这是Avro的联合类型。我刚开始用的时候老忘加 default,结果升级Schema时老报错。嗯,这里要注意:Avro的兼容性检查非常严格,字段必须有默认值才能做向后兼容。
2.1.2 Protobuf 示例
syntax = "proto3";
message User {
int32 id = 1;
string name = 2;
optional string email = 3;
}
Protobuf用字段编号(1,2,3)来标识,而不是字段名。这意味着你不能随便改编号。我曾经见过一个团队重构时把字段编号全改了,结果所有消费者都反序列化失败……那场面,真是灾难。
2.1.3 JSON Schema 示例
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string"}
},
"required": ["id", "name"]
}
JSON Schema的好处是人类可读,调试方便。但坏处也很明显——序列化后的消息体积大,解析慢。如果你做的是低频CDC同步,比如每天一次,那没问题。但要是实时流,我建议你还是用Avro。
2.2 Schema Registry 架构
咱们来看看Schema Registry到底长什么样。说白了,它就是一个RESTful服务,后端挂着一个Kafka topic(叫 _schemas)来存储所有Schema。
你看这个图,流程其实很清晰。Producer发消息前,先找Schema Registry注册Schema,拿到一个全局唯一的Schema ID。然后把这个ID塞到消息头里,再发到Kafka。Consumer那边反过来,先取ID,再拿Schema,最后反序列化。
一个小技巧:Schema Registry本身也支持高可用部署。我建议至少部署3个节点,配合Kafka的 _schemas topic做数据同步。这样即使一个节点挂了,整个服务还能正常运转。
2.3 兼容性类型详解
兼容性策略,这是Schema治理里最容易被忽视但又最关键的部分。你想想看,如果生产者和消费者用的Schema不一致,那数据就乱套了。
Confluent Schema Registry支持以下几种兼容性类型:
| 类型 | 说明 | 典型场景 |
|---|---|---|
| NONE | 不检查兼容性 | 开发环境、快速迭代 |
| BACKWARD | 新Schema可以读旧数据 | 消费者先升级 |
| FORWARD | 旧Schema可以读新数据 | 生产者先升级 |
| FULL | 同时支持BACKWARD和FORWARD | 生产者和消费者同时升级 |
| BACKWARD_TRANSITIVE | 新Schema兼容所有历史版本 | 严格兼容要求 |
| FORWARD_TRANSITIVE | 旧Schema兼容所有未来版本 | 严格兼容要求 |
| FULL_TRANSITIVE | 所有版本之间完全兼容 | 最严格模式 |
2.3.1 BACKWARD 兼容(向后兼容)
这是我最常用的策略。说白了就是:新Schema能读旧数据。比如你给User表加了一个 phone 字段,只要给了默认值,老的Consumer用旧Schema也能正常反序列化。
避坑指南:我曾经在一个项目里用了BACKWARD兼容,但忘了给新字段加默认值。结果Consumer一读到旧数据就报错,整个管道堵了半小时。记住:新增字段必须有默认值,否则BACKWARD兼容形同虚设。
2.3.2 FORWARD 兼容(向前兼容)
这个正好反过来:旧Schema能读新数据。比如你删了一个字段,但Consumer的旧Schema里还有这个字段的定义。只要Producer不发送这个字段,Consumer就能正常处理。
什么时候用FORWARD?当你的生产者先升级,消费者后升级的时候。比如CDC场景里,源端数据库先改了表结构,目标端还没跟上,这时候FORWARD兼容就派上用场了。
2.3.3 FULL 兼容(完全兼容)
FULL = BACKWARD + FORWARD。也就是说,新Schema能读旧数据,旧Schema也能读新数据。这是最安全的策略,但也是限制最多的。你只能添加有默认值的字段,或者删除有默认值的字段。说白了,改动空间很小。
2.3.4 TRANSITIVE 变体
带 _TRANSITIVE 后缀的,意思是传递性兼容。比如 BACKWARD_TRANSITIVE 要求新Schema不仅兼容上一个版本,还要兼容所有历史版本。这个很严格,但能保证你在长期演进中不出问题。
我个人习惯是:生产环境用 FULL_TRANSITIVE。虽然限制多,但一旦跑起来,基本不会出兼容性问题。开发环境可以用 NONE 或者 BACKWARD,方便快速迭代。
2.4 兼容性检查的底层逻辑
你可能会问:Schema Registry是怎么判断两个Schema是否兼容的?
其实原理不复杂。它会把新旧Schema都解析成抽象语法树,然后逐字段对比。比如Avro,它会检查:
- 字段是否存在
- 字段类型是否一致
- 默认值是否提供
- 联合类型的顺序是否变化
如果发现不兼容,Schema Registry会直接拒绝注册,并返回错误信息。嗯,这里要注意:错误信息有时候不够直观。我遇到过好几次,报错说「Schema不兼容」,但没告诉我是哪个字段的问题。这时候只能手动对比两个Schema文件,逐行排查。
我的经验:在CDC场景里,我建议把兼容性策略设为 BACKWARD_TRANSITIVE。原因很简单:CDC的消费者(比如目标数据库)通常比生产者(源数据库)升级慢。BACKWARD保证消费者能读新数据,TRANSITIVE保证你不会因为跳过某个版本而出问题。