未记录事实核验 · 状态:维护中
记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。
warning 适用边界与维护说明
本文适合已有 Kafka 与 Java 基础的读者学习 Confluent Avro 建模、兼容演进及 Java/REST 集成,不是可直接部署的生产方案;本次仅作全文结构审阅,未执行代码或进行外部事实核验。当前 REST 注释虽称使用独立 Subject,实际所有相关命令仍使用 order-events-value,与 Java 默认 Subject 重合,而两组 OrderEvent 的全名及字段类型不同,不能作为隔离练习在同一 Registry 中直接串行执行,尤其不可对 Java 正在使用的 Subject 执行删除示例。注册返回 ID 1、按 ID 1 查询及兼容性检查返回 true 均须结合实际注册结果和 Subject 状态理解;整 Subject 删除与单版本删除应作为独立操作示例。演进片段仅展示字段差异,不是可直接注册的完整 Schema;OrderEvent.avsc 可由正文完整示例自行保存。5 字节头部限定于文中 Confluent KafkaAvroSerializer 的非 null Value,普通兼容模式不保证所有历史版本可读;末尾统一推荐生产使用 FULL 应结合前文升级顺序及历史回放需求判断,而非普遍要求。 已取消无法确定目标的可点击引用「18-Kafka Connect深度实战」,保留原提及;Missing link target: 18-Kafka Connect深度实战 已取消无法确定目标的可点击引用「02-Kafka架构原理」,保留原提及;Missing link target: 02-Kafka架构原理 已取消无法确定目标的可点击引用「数据治理与数据标准」,保留原提及;Missing link target: 数据治理与数据标准 已取消无法确定目标的可点击引用「Avro序列化」,保留原提及;Missing link target: Avro序列化
Schema Registry - 数据模式管理与演进
abstract 本节概览
- 学习目标:理解 Schema Registry 的核心价值,掌握 Avro Schema 编写、兼容性管理和 Java 客户端集成
- 前置知识:Kafka Connect、Kafka 架构基础
- 预计用时:35 分钟
- 后续学习:KSQL 流式 SQL
假设一个数据契约失控场景:上游系统未经协调修改用户数据 Schema,下游 20 个消费者系统因不兼容变更全部报错,团队难以定位变更,也不敢轻易回滚。这里的系统数量与事故情节仅用于说明风险,不代表已记录的真实经历。Schema Registry 正是为解决这类"数据契约"失控问题而生。
Schema Registry 的核心价值
生产者与消费者解耦
没有 Schema Registry 时,生产者和消费者需要共享同一个 Schema 定义文件,用带外(out-of-band)方式同步。任何一方改动都可能导致另一方崩溃。
Schema Registry 引入了中央化的 Schema 存储:生产者注册 Schema 并获得一个数字 ID,消费者按 ID 从 Registry 拉取 Schema 自行解析。双方只依赖 Registry,不再直接耦合。
消息编码格式:Magic Byte + Schema ID
本文使用 Confluent KafkaAvroSerializer 序列化非 null Value 时,字节流采用以下 5 字节头部;这不是任意 Schema Registry 或 Kafka 消息的通用格式:
字节 0 : Magic Byte,固定值 0x00,标识 Confluent 序列化格式
字节 1-4 : Schema ID(4 字节大端整数)
字节 5+ : Avro 二进制编码的真实 Payload
消费者收到消息后,先读 Magic Byte 确认格式,再用 Schema ID 从 Registry 获取 Schema,最后反序列化 Payload。这意味着消息体自带"解析说明书"的索引,极大降低了解析复杂度。
note 为什么不直接在消息里嵌入完整 Schema?
一个典型 Avro Schema 文本有几百到几千字节。每条消息都嵌入 Schema 会造成极大的空间浪费。用 4 字节 Schema ID 代替完整 Schema 文本可减少逐条消息的元数据开销。例如仅比较 400 字节 Schema 文本与 4 字节 ID 时,减少比例为 99%;实际比例取决于 Schema 大小及计量口径。客户端可缓存已获取的 Schema,减少重复查询。
Confluent Schema Registry vs Apicurio Registry
| 维度 | Confluent Schema Registry | Apicurio Registry |
|---|---|---|
| 许可 | Confluent Schema Registry 使用 Confluent Community License,不能将 Community 等同于 Apache 2.0;具体发行包及商业功能须按对应许可区分 | Apache 2.0;具体发行版本及依赖仍需分别确认 |
| 背后公司 | Confluent | Red Hat |
| 支持格式 | Avro、JSON Schema、Protobuf | Avro、JSON Schema、Protobuf、OpenAPI 等 |
| 存储后端 | Kafka Topic(_schemas) |
内存、PostgreSQL、Kafka 等 |
| REST API 兼容性 | 原版 API | 兼容 Confluent API(需开启) |
| 适用场景 | Confluent 生态优先 | 多注册表格式、Red Hat 生态 |
本文示例以 Confluent Schema Registry 为基准,API 风格通用。
配置与启动
schema-registry.properties 核心参数
# 监听地址,8081 是默认端口
listeners=http://0.0.0.0:8081
# 关联的 Kafka 集群(Schema 持久化存储在 _schemas Topic)
kafkastore.bootstrap.servers=kafka-01:9092,kafka-02:9092,kafka-03:9092
# _schemas Topic 的副本数,生产环境建议与 Broker 数量对齐
kafkastore.topic.replication.factor=3
# 全局默认兼容性级别
schema.compatibility.level=BACKWARD
# 是否参与主节点选举(多实例部署时所有节点设为 true)
master.eligibility=true
# Kafka 存储初始化超时时间,单位毫秒
kafkastore.init.timeout.ms=60000
启动与健康检查
# 启动 Schema Registry(确保 Kafka 已运行)
bin/schema-registry-start config/schema-registry.properties &
# 检查服务是否就绪,返回空数组 [] 表示正常
curl http://localhost:8081/subjects
# 查看全局兼容性配置
curl http://localhost:8081/config
tip 生产部署建议
Schema Registry 本身是无状态服务(状态在 Kafka 的 _schemas Topic 中),可以横向扩展多个实例。建议至少部署 2 个实例做高可用,通过 Nginx 或内部 LB 做 VIP。
Avro Schema 编写
基础结构:Record、Field、Type
Avro Schema 是 JSON 文档,核心元素如下:
{
"type": "record",
"name": "User",
"namespace": "com.example.schema",
"doc": "用户基本信息",
"fields": [
{
"name": "id",
"type": "string",
"doc": "用户唯一标识,UUID 格式"
},
{
"name": "username",
"type": "string",
"doc": "用户名,3-20 个字符"
},
{
"name": "age",
"type": ["null", "int"],
"default": null,
"doc": "年龄;本例使用 null 默认值,并将 null 放在 union 首位"
},
{
"name": "created_at",
"type": {"type": "long", "logicalType": "timestamp-millis"},
"doc": "创建时间,毫秒时间戳"
}
]
}
常用 Avro 类型速查
| 分类 | 类型 | 示例 |
|---|---|---|
| 基本类型 | null / boolean / int / long / float / double / bytes / string |
"type": "string" |
| 逻辑类型 | date / time-millis / timestamp-millis / decimal |
"logicalType": "timestamp-millis" |
| 复杂类型-数组 | array |
{"type": "array", "items": "string"} |
| 复杂类型-Map | map |
{"type": "map", "values": "string"} |
| 复杂类型-枚举 | enum |
{"type": "enum", "name": "Status", "symbols": ["ACTIVE", "DELETED"]} |
| 复杂类型-嵌套 | record |
嵌套另一个 record 定义 |
| 联合类型 | union |
["null", "string"];为兼容要求默认值匹配 union 首个分支的实现,使用 null 默认值时将 null 放在首位 |
带嵌套结构的完整示例
{
"type": "record",
"name": "OrderEvent",
"namespace": "com.example.events",
"doc": "订单事件,用于 order-events Topic",
"fields": [
{"name": "order_id", "type": "string"},
{"name": "user_id", "type": "string"},
{
"name": "status",
"type": {
"type": "enum",
"name": "OrderStatus",
"symbols": ["CREATED", "PAID", "SHIPPED", "COMPLETED", "CANCELLED"]
},
"default": "CREATED"
},
{
"name": "items",
"type": {
"type": "array",
"items": {
"type": "record",
"name": "OrderItem",
"fields": [
{"name": "sku", "type": "string"},
{"name": "quantity", "type": "int"},
{"name": "price", "type": {"type": "bytes", "logicalType": "decimal",
"precision": 10, "scale": 2}}
]
}
},
"default": []
},
{
"name": "metadata",
"type": {"type": "map", "values": "string"},
"default": {},
"doc": "扩展属性,key-value 对"
},
{
"name": "event_time",
"type": {"type": "long", "logicalType": "timestamp-millis"}
}
]
}
Schema 兼容性:四种模式对比
兼容性规则定义了新版本 Schema 与旧版本之间的读写关系,是 Schema 演进的核心概念。
四种兼容性模式
| 模式 | 含义 | 新 Consumer 读旧数据 | 旧 Consumer 读新数据 | 典型场景 |
|---|---|---|---|---|
BACKWARD(默认) |
新 Schema 能读旧数据 | 可以 | 不保证 | 先升级消费者再升级生产者 |
FORWARD |
旧 Schema 能读新数据 | 不保证 | 可以 | 先升级生产者再升级消费者 |
FULL |
双向兼容(BACKWARD + FORWARD) | 可以 | 可以 | 滚动升级,最严格 |
NONE |
不做兼容性检查 | 不保证 | 不保证 | 开发/测试环境快速迭代 |
warning 兼容性选择建议
生产环境可根据消费者升级顺序、历史数据回放需求和字段演进要求选择兼容模式。FULL 检查双向兼容,但非 TRANSITIVE 模式通常只检查最新版本;需要约束所有历史版本时,应考虑对应的 TRANSITIVE 模式。注册检查不能保证应用逻辑或所有运行时解析均不失败。
Schema 修改规则清单
BACKWARD 兼容下允许的操作:
- 添加有
default值的新字段 - 删除旧字段:BACKWARD 下,新 reader 会忽略旧 writer 中已删除的字段,不要求该旧字段带 default;若同时要求 FORWARD,则需考虑旧 reader 的默认值
- 将
string类型扩展为["null", "string"]union
BACKWARD 兼容下禁止的操作:
- 添加没有
default的新字段(新 reader 读取旧数据时,无法为旧数据中不存在的新字段补值) - 删除没有
default的旧字段并不违反 BACKWARD;但旧 reader 读取新数据时可能因缺少默认值而违反 FORWARD - 不符合 Avro reader/writer 类型解析规则的类型变更;旧 writer 的 int 可以提升为新 reader 的 long,因此 int 改为 long 可满足 BACKWARD,但不保证 FORWARD
- 直接修改字段名称且未提供可用的 reader alias 或默认值;Avro 按名称及 reader alias 解析字段,改名需按具体读写方向检查
- 删除旧 writer 可能写出的枚举符号,且新 reader 没有可用的枚举默认值;仅调整 symbols 顺序并不必然破坏兼容性,解析会结合 writer schema 按符号名称匹配
Schema 演进三步示例:
// v1:初始 Schema
{"fields": [
{"name": "id", "type": "string"},
{"name": "username", "type": "string"}
]}
// v2:BACKWARD 兼容,新增可选字段(有 default)
{"fields": [
{"name": "id", "type": "string"},
{"name": "username", "type": "string"},
{"name": "email", "type": ["null", "string"], "default": null},
{"name": "version", "type": "int", "default": 2}
]}
// v3:FULL 兼容,删除有 default 的字段(email 被弃用)
{"fields": [
{"name": "id", "type": "string"},
{"name": "username", "type": "string"},
{"name": "version", "type": "int", "default": 2}
]}
Java 客户端集成
Maven 依赖
<dependency>
<groupId>io.confluent</groupId>
<artifactId>kafka-avro-serializer</artifactId>
<version>7.6.0</version>
</dependency>
生产者配置(KafkaAvroSerializer)
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-01:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
"org.apache.kafka.common.serialization.StringSerializer");
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
"io.confluent.kafka.serializers.KafkaAvroSerializer");
// Registry 地址,Serializer 会自动注册/查询 Schema
props.put("schema.registry.url", "http://schema-registry:8081");
// 幂等性,生产环境建议开启
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.ACKS_CONFIG, "all");
KafkaProducer<String, GenericRecord> producer = new KafkaProducer<>(props);
// 加载 Schema 并构造记录
Schema schema = new Schema.Parser().parse(
new File("src/main/avro/OrderEvent.avsc"));
GenericRecord record = new GenericData.Record(schema);
record.put("order_id", UUID.randomUUID().toString());
record.put("user_id", "user-123");
record.put("status", new GenericData.EnumSymbol(
schema.getField("status").schema(), "CREATED"));
record.put("event_time", Instant.now().toEpochMilli());
record.put("items", Collections.emptyList());
record.put("metadata", Collections.emptyMap());
producer.send(new ProducerRecord<>("order-events", record.get("order_id").toString(), record));
producer.flush();
消费者配置(KafkaAvroDeserializer)
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-01:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processor");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
"org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
"io.confluent.kafka.serializers.KafkaAvroDeserializer");
props.put("schema.registry.url", "http://schema-registry:8081");
// false = 返回 GenericRecord;true = 返回生成的 SpecificRecord 类
props.put("specific.avro.reader", false);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
KafkaConsumer<String, GenericRecord> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("order-events"));
while (true) {
ConsumerRecords<String, GenericRecord> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, GenericRecord> r : records) {
GenericRecord value = r.value();
System.out.printf("order_id=%s, status=%s%n",
value.get("order_id"), value.get("status"));
}
consumer.commitSync();
}
note GenericRecord vs SpecificRecord
GenericRecord:运行时动态访问字段,无需代码生成,灵活但类型不安全SpecificRecord:通过 Avro Maven 插件生成 Java 类,编译期类型检查,推荐生产使用
REST API 完整操作
注册 Schema
# REST 示例使用独立的 rest-order-events-value Subject,不与 Java 示例的 order-events Topic 混用
# 向 Subject 注册一个新版本的 Schema
# Subject 命名遵循策略,默认 TopicNameStrategy:<topic-name>-value
curl -X POST http://localhost:8081/subjects/order-events-value/versions \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
-d '{
"schema": "{\"type\":\"record\",\"name\":\"OrderEvent\",\"fields\":[{\"name\":\"order_id\",\"type\":\"string\"},{\"name\":\"status\",\"type\":\"string\"}]}"
}'
# 返回:{"id": 1}
查询 Schema
# 查询所有 Subject
curl http://localhost:8081/subjects
# 查询某 Subject 的所有版本号
curl http://localhost:8081/subjects/order-events-value/versions
# 查询最新版本的 Schema 详情(含 id、version、schema 字段)
curl http://localhost:8081/subjects/order-events-value/versions/latest
# 按 Schema ID 查询(跨 Subject 通用)
curl http://localhost:8081/schemas/ids/1
兼容性检查
# 检查新 Schema 是否与当前最新版本兼容(提交前预检)
curl -X POST \
http://localhost:8081/compatibility/subjects/order-events-value/versions/latest \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
-d '{"schema": "{\"type\":\"record\",\"name\":\"OrderEvent\",\"fields\":[{\"name\":\"order_id\",\"type\":\"string\"},{\"name\":\"status\",\"type\":\"string\"},{\"name\":\"note\",\"type\":[\"null\",\"string\"],\"default\":null}]}"}'
# 返回:{"is_compatible": true}
修改兼容性级别
# 修改全局兼容性级别
curl -X PUT http://localhost:8081/config \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
-d '{"compatibility": "FULL"}'
# 修改单个 Subject 的兼容性级别(覆盖全局设置)
curl -X PUT http://localhost:8081/config/order-events-value \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
-d '{"compatibility": "BACKWARD"}'
删除 Schema
# 软删除一个 Subject(数据保留,标记 deleted=true)
curl -X DELETE http://localhost:8081/subjects/order-events-value
# 硬删除(永久不可恢复,需先软删除)
curl -X DELETE "http://localhost:8081/subjects/order-events-value?permanent=true"
# 软删除某个特定版本
curl -X DELETE http://localhost:8081/subjects/order-events-value/versions/1
warning 删除 Schema 的风险
硬删除后,已有消息中的 Schema ID 将无法解析,会导致消费者抛出异常。生产环境应极少执行硬删除,通常只用于清理测试 Subject。
多租户:Subject 命名策略
Subject 是 Schema Registry 中的命名空间单位,有三种内置策略:
| 策略 | Subject 命名规则 | 适用场景 |
|---|---|---|
TopicNameStrategy(默认) |
<topic>-key / <topic>-value |
一个 Topic 只承载一种 Record 类型 |
RecordNameStrategy |
<namespace>.<RecordName> |
同一 Schema 被多个 Topic 复用 |
TopicRecordNameStrategy |
<topic>-<namespace>.<RecordName> |
一个 Topic 包含多种 Record 类型 |
配置生产者使用非默认策略:
// 切换为 RecordNameStrategy(按 Schema 的全限定名注册)
props.put("value.subject.name.strategy",
"io.confluent.kafka.serializers.subject.RecordNameStrategy");
tip 策略选择建议
大多数场景用 TopicNameStrategy 即可。只有在多类型 Topic(如将所有事件类型汇聚到一个 Topic)时才考虑 TopicRecordNameStrategy。RecordNameStrategy 适合 Schema 复用率高的平台型项目。
掌握检查
- [ ] 能说出 Schema Registry 5 字节头部格式(Magic Byte + Schema ID)的作用
- [ ] 理解 BACKWARD / FORWARD / FULL / NONE 四种兼容性的差异
- [ ] 能编写包含 nullable 字段、嵌套 record、enum 和 map 的 Avro Schema
- [ ] 能配置 Java 生产者/消费者使用 KafkaAvroSerializer / KafkaAvroDeserializer
- [ ] 能用 REST API 完成注册、查询、兼容性检查、删除操作
- [ ] 理解三种 Subject 命名策略的适用场景
练习题
- 设计一个
PaymentEventAvro Schema,包含:支付 ID、订单 ID、金额(decimal)、支付方式(enum: ALIPAY/WECHAT/CARD)、失败原因(nullable string),确保向 BACKWARD 兼容演进时可以安全新增字段。 - 当前 Schema 的某字段
phone类型是string,你需要改为["null", "string"],请说明这个修改在 BACKWARD 模式下是否兼容,并给出验证的 REST API 命令。 - 生产者使用 v2 Schema 写入消息时,分别分析本文 GenericRecord 消费者与使用 v1 SpecificRecord 的消费者如何反序列化。说明为什么“只注册了 v1”不必然导致报错,并分析 reader/writer Schema 不兼容时可能出现的错误及避免方法。
学习连接
前置知识: Kafka Connect - 连接器生态 下一步: KSQL 流式 SQL - 流式数据查询 相关概念: 数据治理、Avro 序列化原理
tip Schema 设计黄金原则
- 所有字段加
"doc"注释,Schema 即文档 - 新字段必须提供
"default",否则无法向后兼容 - Nullable 字段可写成
["null", "string"];为兼容要求默认值匹配 union 首个分支的实现,使用 null 默认值时将 null 放在首位 - 字段名和 Record 名一旦发布就不要改,改名等于加新字段+删旧字段
- 生产环境使用
FULL兼容性,在 CI/CD 流水线中加入兼容性检查步骤