《Java 消息中间件·可靠消息与消费治理》8.3 Schema Registry 的取舍

集中式 schema 管理能给你版本查找、兼容性门禁和跨语言契约,代价是多一个服务、多一次依赖和一套客户端耦合。本篇不引入任何第三方 registry 客户端,只用消息头、schema 文件和 CI 脚本,搭一个能跑的最小自管方案,并给出该不该上 registry 的判定表。

Registry 解决的是哪三个问题

「Schema Registry」经常被当成一个产品名,其实它先是一组职责。拆开看只有三件事:

  1. 存 schema:每个主题、每个版本的结构放在哪里,谁能改。
  2. 查版本:生产者和消费者拿到一条消息,怎么知道该用哪一版解析。
  3. 卡兼容性:新版本发布前,谁来判断它会不会打断现有消费者。

集中式 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 个团队,同一技术栈多团队、多语言
格式统一 JSONAvro / 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。

与本书自管方案的对应关系:

概念自管方案里的对应物
subjecttopic + 后缀(-value)
version消息头 x-schema-version + 文件名里的版本
id无。版本号直接当查找键,省一次间接层
compatibilityCI 里的 SchemaCheck 双向判定
服务端强制无。靠 CI 和发布流程

少了 id 这一层,代价是消息里带的是版本号而不是全局 ID,跨 subject 复用 schema 时要自己处理;好处是链路更短,排障时一眼能看出「这条消息是 v2」。要不要这一层,取决于是否需要在多个主题间共享同一份 schema。

落地清单

  1. 版本头必带,缺头、非数字、未知版本一律拒收。
  2. schema 文件进仓库,命名 <topic>.<version>.schema,review 与代码同流程。
  3. CI 对保留期内所有旧版本跑兼容检查,破坏性变更必须显式批准。
  4. 运行时先校验再绑定,必填缺失、类型不符在进入业务逻辑前失败。
  5. 导出链路单独处理版本,header 会丢,导出信封要自带版本。
  6. 双写双读的重命名要有截止日期,否则兼容层会永久留下。
  7. 定期清理过期 schema 文件,但清理前确认没有消费者还在读。

本节判据

  1. registry 的三项核心职责是什么? 答:存 schema、按版本查找、发布时做兼容性门禁。
  2. 版本号放消息头的主要风险是什么? 答:桥接、导出、跨系统复制时 header 容易丢,导出格式必须自带版本。
  3. 为什么解析失败要 fail closed? 答:用错误布局继续读业务数据,错误会延迟到下游,比直接拒收更难排查。
  4. CI 检查为什么要对多个旧版本跑? 答:消费者可能落后多个版本,只比相邻版本会放过跨版本不兼容。
  5. 什么时候该从自管切到集中式 registry? 答:主题和团队数量增长到文件同步与人工 review 撑不住,且需要强制门禁、多语言、集中审计时。

阅读导航:上一节 8.2 schema 演进与兼容性 | 下一节 9.1 Saga:编排与协同

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收