Schema 注册中心原理:Avro/Protobuf/JSON Schema 对比、Schema Registry 架构、版本管理机制

好,咱们进入第二章。上一章聊了为什么需要 Schema 治理,这一章咱们直接深入底层——Schema 注册中心到底是个什么东西?它怎么工作的?

说白了,Schema Registry 就是一个中央仓库。它存着所有消息的“身份证”——也就是 Schema 本身。生产者和消费者在发消息、读消息之前,都得先来这里查一下:哎,这个 topic 用的 Schema 长什么样?

我刚开始接触这个的时候,觉得不就是存个 JSON 吗?后来踩了坑才发现,版本管理、兼容性检查、序列化格式差异……每一个都是坑。咱们一个一个说。

Avro / Protobuf / JSON Schema 对比

先解决一个最基础的问题:市面上这么多 Schema 格式,我该选哪个?

我个人的习惯是:先看生态,再看性能,最后看团队熟悉度。下面这张表是我自己整理的对比,你直接拿去用。

维度 Avro Protobuf JSON Schema
序列化格式 二进制(紧凑) 二进制(紧凑) 文本(JSON)
Schema 定义语言 JSON / IDL .proto 文件 JSON
是否需要 IDL 编译器 可选(动态模式不需要) 必须(protoc) 不需要
Schema 演进(兼容性) 强(内置兼容性规则) 强(需手动配置) 中等(依赖工具)
性能(序列化/反序列化) 快 非常快 慢(文本解析)
Schema Registry 集成 Confluent 原生支持 需额外适配 需额外适配
典型场景 Kafka 生态、大数据 微服务 gRPC、高性能场景 REST API、配置文件

我的建议:如果你在用 Kafka,无脑选 Avro。Confluent Schema Registry 对 Avro 的支持是最成熟的,版本管理、兼容性检查都是开箱即用。Protobuf 更适合 gRPC 场景。JSON Schema 嘛……说实话,我只有在做 API 文档校验时才用它。

为什么会这样?你想想看,Avro 的 Schema 是自描述的——消息里不存 Schema 本身,只存一个 Schema ID。消费者拿到 ID 后去 Registry 里查。这样消息体非常小,性能也好。Protobuf 虽然也二进制,但它需要双方提前编译好 .proto 文件,动态性差一些。

Schema Registry 架构

好,选定了格式,咱们看看 Registry 本身长什么样。

我画了一张架构图,你一看就明白。

Schema Registry 核心架构 生产者 消费者 Kafka Broker Schema Registry 存储后端(Kafka / DB) 流程说明: 1. 生产者发送消息前,先向 Registry 注册 Schema,获取 Schema ID。 2. 消息体只携带 Schema ID,不携带完整 Schema。消费者从 Registry 拉取 Schema 后反序列化。

你看这个图,核心就三个角色:生产者、消费者、Registry。Kafka Broker 只是消息管道,它不关心 Schema 是什么。

生产者发消息前,先问 Registry:“我这个 topic 的 Schema 是啥?给我注册一下。” Registry 返回一个全局唯一的 ID。生产者把这个 ID 塞到消息头里,然后发到 Kafka。消费者收到消息,先读 ID,再去 Registry 查 Schema,最后反序列化。

一个小技巧:Registry 本身也是用 Kafka topic 来存 Schema 的(叫 _schemas)。这样做的好处是——高可用、持久化、天然支持事件回溯。我遇到过有人用 MySQL 做后端存储,结果 Schema 一多,查询性能直线下降。Kafka 做存储,稳得很。

版本管理机制

这是 Schema Registry 最核心的能力,也是坑最多的地方。

你想想看,一个 topic 的 Schema 不可能一成不变。今天加个字段,明天改个类型,后天删个字段……如果消费者和生产者的 Schema 不一致,反序列化直接报错。

Registry 怎么解决?版本管理 + 兼容性检查。

每次你注册一个新 Schema,Registry 会检查它和已有版本的兼容性。兼容性策略有几种:

  • BACKWARD(向后兼容):新 Schema 能读旧数据。这是最常用的。比如你加了一个 optional 字段,旧数据没有这个字段,新消费者读的时候给个默认值就行。
  • FORWARD(向前兼容):旧 Schema 能读新数据。比如你删了一个字段,旧消费者读新数据时忽略它就行。
  • FULL(完全兼容):同时满足 BACKWARD 和 FORWARD。说白了就是双向兼容。
  • NONE(不检查):随便改,后果自负。我建议你永远不要在生产环境用这个。

我曾经踩过的坑:有一次我改了一个字段的类型,从 int 改成 string,以为加了兼容性检查就没事了。结果 BACKWARD 检查直接报错——因为旧数据是 int,新 Schema 是 string,反序列化时没法自动转换。后来我学乖了:改类型一定要加新字段,废弃旧字段,而不是直接改。

版本管理的具体流程是这样的:

  1. 生产者提交新 Schema 到 Registry。
  2. Registry 根据配置的兼容性策略,检查新 Schema 和最新版本(或所有版本)的兼容性。
  3. 如果兼容,Registry 生成一个新版本号(比如从 1 变成 2),返回 Schema ID。
  4. 如果不兼容,Registry 拒绝注册,返回错误。

这里有个细节:Schema ID 和版本号是两回事。ID 是全局唯一的,用于消息头;版本号是 topic 级别的,用于管理演进。同一个 Schema 在不同 topic 里可能有不同的版本号,但 ID 是唯一的。

我举个例子你就明白了:

// 版本 1:原始 Schema
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"}
  ]
}

// 版本 2:加了 email 字段(optional)
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": ["null", "string"], "default": null}
  ]
}

版本 2 对版本 1 是 BACKWARD 兼容的——因为新 Schema 能读旧数据(email 字段有 default null)。Registry 会允许注册,版本号变成 2。

但如果你把 id 的类型从 int 改成 long:

// 版本 3:改了 id 类型(不兼容)
{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "long"},  // 从 int 改成 long
    {"name": "name", "type": "string"}
  ]
}

Registry 会直接拒绝——因为旧数据是 int,新 Schema 是 long,反序列化时没法自动转换。嗯,这就是我前面说的那个坑。

核心原则:Schema 只能加不能改,只能弃不能删。加字段时一定要给 default 值。改类型?想都别想,老老实实加新字段。

最后说一句,版本管理不是 Registry 单方面的事。你需要在生产者和消费者两端都做好兼容性设计。比如消费者要能处理未知字段,生产者要避免发送不兼容的数据。Registry 只是帮你检查,真正跑起来还得靠代码。