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。

Schema Registry 核心架构 Producer Consumer Schema Registry REST API Kafka Cluster _schemas topic 注册Schema 获取Schema 持久化 发送数据 消费数据 💡 工作流程: 1. Producer 先向 Schema Registry 注册 Schema → 2. 获取 Schema ID → 3. 将 Schema ID 嵌入消息头 → 4. 发送到 Kafka 5. Consumer 从消息头提取 Schema ID → 6. 从 Schema Registry 获取 Schema → 7. 反序列化数据

你看这个图,流程其实很清晰。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保证你不会因为跳过某个版本而出问题。