Registry 解决的是哪三个问题
「Schema Registry」经常被当成一个产品名,其实它先是一组职责。拆开看只有三件事:
- 存 schema:每个主题、每个版本的结构放在哪里,谁能改。
- 查版本:生产者和消费者拿到一条消息,怎么知道该用哪一版解析。
- 卡兼容性:新版本发布前,谁来判断它会不会打断现有消费者。
集中式 registry 把这三件事做成一个服务:schema 存在服务端,客户端按 subject 和版本号拉取,发布时服务端按兼容级别拒绝破坏性变更。它解决得好,但每一项都带来依赖。
先明确本篇的边界:不引入任何第三方 registry 客户端,包括 Confluent 的客户端。8.3 只做三件事:概念对照、一个能跑的自管最小方案、把兼容性检查放进 CI 的脚本骨架。
能力与成本对照
| 能力 | 集中式 registry | 自管方案 |
|---|---|---|
| schema 存储 | 服务端统一存储,有 API 和 UI | 放代码仓库或配置中心,靠 review 管控 |
| 版本查找 | 客户端按 subject/version 拉取并缓存 | 版本号放消息头,schema 文件按约定命名 |
| 兼容性门禁 | 发布时服务端检查,可强制 | CI 脚本检查,可绕过,靠流程约束 |
| 跨语言 | 多语言客户端共享同一份契约 | 各语言各自读同一批文件,需要生成代码 |
| 可用性 | 多一个必须可用的服务 | 无额外运行时依赖 |
| 故障面 | registry 挂了,新客户端可能拉不到 schema | 文件随应用发布,不增加故障面 |
| 治理成本 | 需要运维、权限、备份恢复、升级 | 需要自己定目录规范、版本规则、review 流程 |
| 客户端耦合 | 绑定特定客户端与序列化格式 | 只依赖消息头和 JSON 解析 |
这张表没有「更好」的一列。团队小、主题少、格式统一时,自管的成本更低;团队大、主题多、多语言、有合规要求时,集中式 registry 的强制力值钱。
不引入 registry 的四种自管方式
版本号放消息头,schema 放代码仓库
最常见也最轻。schema 文件跟代码一起 review、一起发布,版本号写在消息头 x-schema-version。消费者先读头,再按 topic + version 找本地文件。
优点是没有运行时依赖,schema 变更走代码评审,历史版本跟着 Git 走。缺点是跨团队共享时,每个服务都要拿到同一批文件;文件同步靠发布流程,不靠服务端。
schema 放一个专门的 Kafka 主题
把 schema 当成事件发到 _schemas 主题,消费者本地缓存,启动时回放。好处是复用现有消息系统,不需要新服务;坏处是 schema 主题本身也要有保留策略、权限和顺序保证,做不好会变成「用消息系统存配置」的典型反模式。
schema 放配置中心
配置中心通常有权限、审计、灰度,比代码仓库更适合多团队共享。但它和消息系统是两套可用性域:配置中心挂了,消费者重启后可能拿不到 schema。要不要接受这个耦合,取决于你们对配置中心的定位。
schema 写进接口文档
最弱的一种。文档和代码容易脱节,版本号靠人记,兼容性靠自觉。只适合主题极少、变更极慢的内部系统,不适合作为长期方案。
本篇选第一种:版本号放头,schema 放文件,CI 做检查。它最小,也最容易在现有工程里落地。
版本号放头还是放 payload
| 位置 | 优点 | 缺点 |
|---|---|---|
| 消息头 | 不改 payload;消费端先读头再决定解析器;同一份 payload 可被不同信封包装 | 桥接、导出、跨系统复制时容易丢;需要额外的头处理代码 |
| payload 字段 | 随消息一起持久化,导出不丢 | 业务代码会看到 schemaVersion;信封和内容耦合;改版本字段本身也是 schema 变更 |
本书选消息头,同时在导出格式里把版本写进导出信封。这不是二选一,而是「运行时用头,落地时用信封」。只选一个,早晚会在一端丢版本。
头的键名固定为 x-schema-version,值是十进制整数字符串。不要放 v2、2.0、2026-10-29 这类格式,解析时要多做一次容错,且很容易出现两种写法并存。
最小方案一:schema 文件
不引入 JSON Schema、Avro 或 Protobuf 的解析器,定义一个三列的行格式:
# topic=library.loan.created version=1
eventId uuid required
occurredAt instant required
loanId string required
bookId string required
memberId string required
borrowedAt instant required
dueAt instant required
三个列:字段名、类型、是否必填。类型只保留 uuid、instant、string、int、long 五种,够表达本书的事件。生产上如果类型更多,应该换 Avro 或 Protobuf,而不是把这个格式越堆越复杂。
v2 加一个可选字段:
# topic=library.loan.created version=2
eventId uuid required
occurredAt instant required
loanId string required
bookId string required
memberId string required
borrowedAt instant required
dueAt instant required
branchId string optional
文件命名约定:<topic>.<version>.schema。目录按环境或团队分,不要按日期分。版本号单调递增,不复用,不跳号也可以接受,但要能一眼看出最新版。
最小方案二:按头解析
registry 的核心逻辑只有「读头、查表、找不到就拒绝」:
static final String VERSION_HEADER = "x-schema-version";
Schema resolve(String topic, Headers headers) {
Header h = headers.lastHeader(VERSION_HEADER);
if (h == null) throw new IllegalArgumentException("missing header: " + VERSION_HEADER);
int version;
try {
version = Integer.parseInt(new String(h.value(), StandardCharsets.UTF_8));
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("non-numeric " + VERSION_HEADER, ex);
}
Map<Integer, Schema> versions = byTopic.get(topic);
if (versions == null) throw new IllegalArgumentException("unknown topic: " + topic);
Schema s = versions.get(version);
if (s == null) {
throw new IllegalArgumentException("no schema for " + topic + " v" + version
+ " (known versions " + new TreeSet<>(versions.keySet()) + ")");
}
return s;
}
三个「拒绝」比三个「成功」更重要:
- 没有版本头就拒绝,不要猜「大概是 v1」。
- 版本号不是整数就拒绝,不要
parseInt失败后回退默认值。 - 版本不存在就拒绝,并在异常里列出已知版本,方便排障。
这三条叫 fail closed。schema 解析失败时继续处理,等于用错误的字段布局读业务数据,后果比停机更难查。
最小方案三:解析前校验
拿到 schema 后,先校验 payload,再绑定到 record。校验做三件事:必填字段存在、类型匹配、值能解析。
static int validate(Schema s, String json) {
Map<String, Object> m = Json.parseObject(json);
int checked = 0;
for (Map.Entry<String, Field> e : s.fields().entrySet()) {
String name = e.getKey();
Field f = e.getValue();
Object v = m.get(name);
if (v == null) {
if (f.required()) {
throw new IllegalArgumentException("missing required field: " + name);
}
continue;
}
switch (f.type()) {
case "uuid" -> UUID.fromString((String) v);
case "instant" -> Instant.parse((String) v);
case "string" -> requireType(v, String.class, name, "string");
case "long" -> requireType(v, Long.class, name, "long");
case "int" -> {
requireType(v, Long.class, name, "int");
long n = (Long) v;
if (n < Integer.MIN_VALUE || n > Integer.MAX_VALUE) {
throw new IllegalArgumentException("field " + name + " out of int range: " + n);
}
}
default -> throw new IllegalArgumentException("unsupported schema type: " + f.type());
}
checked++;
}
return checked;
}
这段代码里,uuid 和 instant 直接做强转,类型不符会抛 ClassCastException;string、long、int 先做 instanceof 检查,抛带字段名的 IllegalArgumentException。两种错误都能定位,但后者更容易读。生产实现应该统一成后者,本实验保留强转是为了让「头与 payload 版本不一致」的异常形态更直观。
本机实测:头、版本与校验
环境:Temurin 25.0.5+8,kafka-clients 4.3.0,只用 ProducerRecord 和 RecordHeaders,不连 broker。
javac --release 25 -cp kafka-clients-4.3.0.jar -d out $(find src -name '*.java')
java -cp out:kafka-clients-4.3.0.jar com.plume.mq.lab.HeaderLab
输出(原样):
header x-schema-version = 2 (1 字节)
schema v2 校验字段数=8
OK v2 消息 + header=2 -> 8 个字段通过
schema v2 校验字段数=7
OK v1 消息 + header=2(新增字段可选) -> 7 个字段通过
FAIL v2 消息 + header=1(按旧 schema 校验) -> java.lang.ClassCastException: class java.lang.Long cannot be cast to class java.lang.String (java.lang.Long and java.lang.String are in module java.base of loader 'bootstrap')
FAIL 缺必填字段 loanId + header=2 -> java.lang.IllegalArgumentException: missing required field: loanId
FAIL 没有版本头 -> java.lang.IllegalArgumentException: missing header: x-schema-version
FAIL 版本头=9 -> java.lang.IllegalArgumentException: no schema for library.loan.created v9 (known versions [1, 2])
六个结果逐个解释:
- v2 消息 + header=2:正常路径,8 个字段全部校验通过。
- v1 消息 + header=2:新增的
branchId是可选字段,缺失时跳过,校验 7 个字段。这是 v2 消费者读旧消息的正确形态。 - v2 消息 + header=1:头说 v1,payload 里的
dueAt却是数字。v1 schema 按instant强转,抛ClassCastException。这说明头与 payload 必须由同一个发布流程生成,不能手工改头。 - 缺必填字段:schema 校验先于业务逻辑拦住了它,异常里有字段名。
- 没有版本头:fail closed,不猜默认版本。
- 版本头=9:schema 不存在,异常里列出已知版本
[1, 2]。
最后两条是自管方案最容易被省略、也最该保留的检查。很多实现为了「兼容老消息」在缺头时默认 v1,结果一条真正损坏的消息被当成正常数据消费,错误延迟到下游才暴露。
最小方案四:CI 兼容性检查
自管方案没有服务端帮你卡变更,只能在 CI 里卡。检查器读两个 schema 文件,判断双向兼容,任何一边破坏就以非零退出码失败。
核心判定逻辑:
for (String name : newS.fields().keySet()) {
Field n = newS.fields().get(name);
Field o = oldS.fields().get(name);
if (o == null) {
if (n.required()) backward = false; // 旧数据没有该字段
continue;
}
if (!o.type().equals(n.type())) {
backward = false;
forward = false;
}
}
for (String name : oldS.fields().keySet()) {
if (!newS.fields().containsKey(name) && oldS.fields().get(name).required()) {
forward = false; // 旧消费者仍按必填读
}
}
对四类变更分别跑:
for v in add remove type rename; do
java -cp out com.plume.mq.lab.SchemaCheck \
schemas/library.loan.created.v1.schema \
schemas/library.loan.created.v2-$v.schema
echo "exit=$?"
done
输出(原样):
topic=library.loan.created v1 -> v2
+ branchId: string optional
backward(new<-old)=OK forward(old<-new)=OK => COMPATIBLE
exit=0
topic=library.loan.created v1 -> v2
- memberId: string required
backward(new<-old)=OK forward(old<-new)=BREAK => BREAKING
exit=1
topic=library.loan.created v1 -> v2
~ dueAt: instant -> long
backward(new<-old)=BREAK forward(old<-new)=BREAK => BREAKING
exit=1
topic=library.loan.created v1 -> v2
+ borrowerId: string required
- memberId: string required
backward(new<-old)=BREAK forward(old<-new)=BREAK => BREAKING
exit=1
四个结果与 8.2 的运行时实测一一对应:
- 加可选字段:
COMPATIBLE,退出码 0。 - 删必填字段:
backward=OK,forward=BREAK,退出码 1。新消费者能读旧数据,旧消费者读不了新数据。 - 改类型:两个方向都
BREAK。 - 改字段名:检查器把它识别成「删旧必填 + 加新必填」,两个方向都断。
检查器只做字段级判断,不做语义判断。memberId 改成 borrowerId 在语义上是同一个东西,但检查器不知道,只能按破坏性处理。这是刻意的保守:把「这次改名有双读兼容层」这类信息交给人工在 PR 里说明,而不是让脚本猜。
兼容性检查放在哪一层
三层各有位置,缺一层就有一段窗口期没人管:
| 层 | 时机 | 能拦住什么 | 拦不住什么 |
|---|---|---|---|
| 作者本地 | 提交前 | 明显的类型变更、删字段 | 忘记跑、本地环境差异 |
| CI | 合并前 | 契约文件与代码不一致、破坏性变更 | 绕过 CI 的手工发布 |
| 运行时 | 消费时 | 头缺失、版本不存在、字段缺失 | 语义错误、双写不一致 |
自管方案把「作者本地」和「CI」当主关口,「运行时」当兜底。集中式 registry 把「CI」搬到服务端,发布时强制检查,绕不过去;代价是发布链路多一个依赖。
一个容易忽略的点:CI 检查的基线不能只跟「上一个版本」比,而要跟所有仍在保留期内的版本比。消费者可能落后好几个版本,只比较相邻版本会放过「跨版本不兼容」的变更。做法是保留 v1..vN 全部文件,新版本对每一个旧版本各跑一次检查。
什么时候该引入 registry
| 条件 | 倾向自管 | 倾向集中式 |
|---|---|---|
| 主题数量 | 少于 20 个 | 超过 50 个,且持续增长 |
| 团队 | 1–2 个团队,同一技术栈 | 多团队、多语言 |
| 格式 | 统一 JSON | Avro / Protobuf 混用 |
| 合规 | 无强制审计要求 | 需要集中审计、权限、变更记录 |
| 运维能力 | 不想多养一个服务 | 有平台团队维护中间件 |
| 变更频率 | 每月几次 | 每周多次,跨团队并行 |
判定不是永久的。团队从 2 个长到 10 个、主题从 10 个长到 100 个时,自管方案的「文件同步」和「review 靠自觉」会先撑不住。反过来,registry 上线后发现只有三个主题在用,运维成本会显得很不划算。
与 Confluent Schema Registry 的概念对照
下面只做概念对照,未在本机执行,也不引入其客户端。公开资料里的模型大致是:
- subject:一组 schema 的逻辑名字,通常对应
<topic>-value或<topic>-key。 - version:subject 下的递增版本号。
- id:全局唯一的 schema ID,生产者在消息里带 ID,消费者按 ID 拉 schema。
- compatibility:subject 级别的兼容策略,常见有 BACKWARD、FORWARD、FULL、NONE。
与本书自管方案的对应关系:
| 概念 | 自管方案里的对应物 |
|---|---|
| subject | topic + 后缀(-value) |
| version | 消息头 x-schema-version + 文件名里的版本 |
| id | 无。版本号直接当查找键,省一次间接层 |
| compatibility | CI 里的 SchemaCheck 双向判定 |
| 服务端强制 | 无。靠 CI 和发布流程 |
少了 id 这一层,代价是消息里带的是版本号而不是全局 ID,跨 subject 复用 schema 时要自己处理;好处是链路更短,排障时一眼能看出「这条消息是 v2」。要不要这一层,取决于是否需要在多个主题间共享同一份 schema。
落地清单
- 版本头必带,缺头、非数字、未知版本一律拒收。
- schema 文件进仓库,命名
<topic>.<version>.schema,review 与代码同流程。 - CI 对保留期内所有旧版本跑兼容检查,破坏性变更必须显式批准。
- 运行时先校验再绑定,必填缺失、类型不符在进入业务逻辑前失败。
- 导出链路单独处理版本,header 会丢,导出信封要自带版本。
- 双写双读的重命名要有截止日期,否则兼容层会永久留下。
- 定期清理过期 schema 文件,但清理前确认没有消费者还在读。
本节判据
- registry 的三项核心职责是什么? 答:存 schema、按版本查找、发布时做兼容性门禁。
- 版本号放消息头的主要风险是什么? 答:桥接、导出、跨系统复制时 header 容易丢,导出格式必须自带版本。
- 为什么解析失败要 fail closed? 答:用错误布局继续读业务数据,错误会延迟到下游,比直接拒收更难排查。
- CI 检查为什么要对多个旧版本跑? 答:消费者可能落后多个版本,只比相邻版本会放过跨版本不兼容。
- 什么时候该从自管切到集中式 registry? 答:主题和团队数量增长到文件同步与人工 review 撑不住,且需要强制门禁、多语言、集中审计时。
阅读导航:上一节 8.2 schema 演进与兼容性 | 下一节 9.1 Saga:编排与协同
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。