文章目录
- 背景
- [解决Poison Pill消息](#解决Poison Pill消息)
-
- Spring的ErrorHandlingDeserializer
- [Avro Schema](#Avro Schema)
- 把消息结构从Json格式迁移到Avro
-
- [将Java类迁移到Avro Schema](#将Java类迁移到Avro Schema)
-
- [Avro Schema中使用嵌套结构](#Avro Schema中使用嵌套结构)
- 通过schema文件生成对应的Java类
- 使用Apicurio管理Schema
-
- [Docker Compose文件](#Docker Compose文件)
- [Apicurio Web Console 页面介绍](#Apicurio Web Console 页面介绍)
- 工作原理
- 生产者发送消息
- 消费者消费消息
- Kafbat-ui中查看Avro类型消息
- 保障Schema的兼容性
- 备注
背景
在Kafka(二)将邮件发送从业务系统中解耦之消息系统设计这篇博文中,介绍了我们的邮件系统从业务系统中拆分的设计思路。随着邮件系统上线,并且越来越多的发邮件的场景集成到邮件系统中,出现了下面的问题
- 随着不断的重构,发现统一的JSON消息格式可能需要微调,例如加几个字段,业务系统和邮件系统会使用同一套JSON消息格式。由于当时相应的消息格式Java类使用了很严格的JSON注解,没有添加
@JsonIgnoreProperties(ignoreUnknown = true)注解,并且邮件系统更新了代码后,即拿到了最新的消息格式Java类,但是没有及时部署到产品环境中 - 同时业务系统某些使用了新消息格式的服务器的生产者已经发送了新的消息到分区A中
- 此时邮件系统作为消费者去poll消息时,反序列化报错,导致当前poll消息的线程因为异常退出
- 某个消费者线程退出后,由于此时消费者线程数< 分区数,所以必然有1个消费者被重新assign两个分区,其中一个分区就是包含了多余字段的分区。下一个消费者线程又会去poll这个分区A的消息,同样反序列化报错,同样退出
- 最后,导致把所有订阅了这个topic的消费者线程都退出了,最终邮件系统瘫痪,消息积压在kafka中
在消费者线程异常退出时,我们设置了发送报警邮件给对应人员。幸好问题及时发现,发现根本原因后立刻为邮件系统部署了最新的代码并重启修复了此问题。
同时在日常开发中,我发现测试环境也会出现上述类似问题。由于我们正计划开始规模化的把其他合适场景也使接入kafka,所以在测试环境不同的团队在测试时,有的开发人员直接把测试的消息写入了给邮件系统准备的topic中,这也导致了邮件系统因为反序列化失败导致消费者线程逐一退出的问题
两个问题都是一类问题:都是由于消息格式未严格约束或兼容性处理没做好,导致产生了毒药消息-Poison Pill. 即一条消费者无论怎么处理都无法成功的消息,并且它会反复被消费、反复失败,从而阻塞正常消息处理,严重情况下导致所有消费者线程退出,消息系统瘫痪,消息积压,即使多次重启系统也无效
解决Poison Pill消息
在消息系统最初上线之时,我们就设计了异常重试+重试有限这种方案,但是这种方案对毒药消息不适用。因为它发生在消费者poll()方法内部反序列化时,这种时候抛出异常,代码中也拿不到关键信息,例如消息id等,就无法跳过这条毒药消息,无法把毒药消息从topic中移除
When a deserializer fails to deserialize a message, Spring has no way to handle the problem, because it occurs before the poll() returns
上述英文引用自spring-kafka官方文档
Spring的ErrorHandlingDeserializer
对于毒药消息,spring-kafka给出的解决方案是自定义了一个ErrorHandlingDeserializer。其大概思路是它在反序列化前包了一层
shell
byte[]
│
▼
ErrorHandlingDeserializer
│
└──────► JsonDeserializer
│
▼
ObjectMapper
│
▼
User
用来捕获反序列化的错误,如果失败了,则做一些处理,返回一个空对象等,不会让消费者线程由于反序列失败直接退出
java
public User deserialize(
String topic,
Headers headers,
byte[] data) {
try {
return delegate.deserialize(topic, headers, data);
}
catch (Exception e) {
// 处理反序列化异常
// 将异常信息放到 headers
return null;
}
}
Avro Schema
spring-kafka这个解决方案固然不错,但是尴尬的是我们技术栈并没有使用任何spring框架,所以spring生态和我们无缘。一开始我是打算自己实现1个简单版的ErrorHandlingDeserializer,后来继续调查发现,大数据生态中早已有类似的解决方案,那就是通过提前定义好消息格式-这一行为叫做约束-schema,并在schema发生变化时通过Schema Registry的一些策略做兼容性处理、检查等,这2种措施能大幅降低毒药消息的发生概率
使用消息格式约束,对未来其他团队互相之间消费消息的确有约束作用。避免维护某个topic的团队A忽然改了数据格式,而团队B也在使用同一topic的数据,团队B无法及时更新代码,导致团队B业务出现问题这种场景。
其中Avro和kafka又比较密切,所以最终选择Avro作为消息格式约束的框架。至于引入Avro后,由于schema的存在,消息体由原来的JSON结构本身会减小很多从而优化了kafka服务器磁盘空间这一点,目前消息还没达到巨量的地步,所以这一步提升我们目前倒不是非常关心
把消息结构从Json格式迁移到Avro
将Java类迁移到Avro Schema
第一步就是把原来使用的代表消息的Java Class转为.avsc文件,这一步可以使用Avro 的Java库的相关代码直接生成一个初级版本,生成之后再看下有没有需要调整的部分
java
Schema schema = ReflectData.get().getSchema(UserInfo.class);
System.out.println(schema.toString(true));
关于Avro有哪些具体类型,可参考官方文档
Avro Schema中使用嵌套结构
对于复杂的类,我们可能会把它拆成几个类组合使用,那么在avro schema中也可以使用,例如
json
{
"type": "record",
"name": "CallbackMetaData",
"namespace": "com.message.common.dto",
"doc": "回调消息元数据, 消息系统消费email消息成功后, 通过此消息回调业务系统",
"fields": [
{
"name": "messageId",
"type": ["null", "string"],
"default": null
},
{
"name": "serverId",
"type": ["null", "string"],
"default": null,
"doc": "业务服务器的hostname, InetAddress.getLocalHost().getHostName()"
},
{
"name": "className",
"type": ["null", "string"],
"default": null,
"doc": "回调目标类的全限定名"
},
{
"name": "instanceJsonStr",
"type": ["null", "string"],
"default": null,
"doc": "className对应实例的JSON字符串, 由Jackson序列化生成"
},
{
"name": "methodName",
"type": ["null", "string"],
"default": null,
"doc": "回调目标方法名"
},
{
"name": "arguments",
"type": {"type": "array", "items": "string"},
"default": [],
"doc": "回调方法参数列表, Avro不支持任意类型, 每个元素是一个参数经Jackson序列化后的JSON字符串"
}
]
}
和
json
{
"type": "record",
"name": "UserDTO",
"namespace": "com.message.common.dto",
"doc": "email消息体, 业务系统生产, 消息系统消费",
"fields": [
{
"name": "messageId",
"type": ["null", "string"],
"default": null,
"doc": "消息唯一id, 同时作为kafka消息的key, 保证同一消息进入同一分区"
},
{
"name": "userName",
"type": ["null", "string"],
"default": null
},
{
"name": "password",
"type": ["null", "string"],
"default": null
},
{
"name": "callbackMetaData",
"type": ["null", "com.message.common.dto.CallbackMetaData"],
"default": null,
"doc": "消费成功后的回调元数据, 可选"
}
]
}
在type中指定CallbackMetaData的全路径名即可,把namespace当做Java中的包名即可
通过schema文件生成对应的Java类
Avro 只需要定义schema文件即可,其对应的Java类是通过Maven 插件在编译期间生成的。对于具有嵌套结构的复杂类,要确保被引用的类的avsc文件在主类之前,例如上面文件对应的Maven配置如下
xml
<!-- 根据src/main/avro下的.avsc生成Avro SpecificRecord类(UserDTO/CallbackMetaData) -->
<plugin>
<groupId>org.apache.avro</groupId>
<artifactId>avro-maven-plugin</artifactId>
<version>1.12.1</version>
<executions>
<execution>
<phase>generate-sources</phase>
<goals>
<goal>schema</goal>
</goals>
<configuration>
<sourceDirectory>${project.basedir}/src/main/avro</sourceDirectory>
<stringType>String</stringType>
<!-- UserDTO.avsc引用了CallbackMetaData, 需要先import; 同时exclude掉避免重复生成类 -->
<imports>
<import>${project.basedir}/src/main/avro/CallbackMetaData.avsc</import>
</imports>
<excludes>
<exclude>**/CallbackMetaData.avsc</exclude>
</excludes>
</configuration>
</execution>
</executions>
</plugin>
使用Apicurio管理Schema
有了schema明确了消息格式之后,生产、消费消息时,发送到kafka的消息中除了消息本身,就不携带消息格式了,只会携带一个消息格式的id,schema一般会保存到另外的地方进行统一管理。同时为了解决前文的内容,我们还需要一个管理各个版本的schema以保证其兼容性不会有问题,这就是schema registry的作用,其中Apicurio是一个开源的schema registry,兼容Confluent Schema Registry
Docker Compose文件
在Kafka(一)使用Docker Compose安装单机Kafka博文的基础上集成apicurio,同时将kafak镜像替换为官方docker镜像最新版,以及kafbat-ui最新版
yaml
services:
kafka:
image: apache/kafka:4.2.1
container_name: kafka
ports:
- "9092:9092"
- "9093:9093"
- "9095:9095"
volumes:
- type: volume
source: kafka_data
target: /var/lib/kafka/data
read_only: false
- type: bind
source: ./jmx_prometheus_javaagent-1.6.0.jar
target: /mnt/shared/jmx/jmx_prometheus_javaagent-1.6.0.jar
read_only: true
- type: bind
source: ./kafka-kraft-3_0_0.yml
target: /mnt/shared/jmx/kafka-jmx-exporter.yml
read_only: true
- type: bind
source: ./monitoring/client-metrics-reporter.jar
target: /opt/kafka/libs/client-metrics-reporter.jar
read_only: true
- type: bind
source: ./monitoring/client-metrics-reporter-config.yml
target: /mnt/shared/config/client-metrics-reporter-config.yml
read_only: true
environment:
- KAFKA_HEAP_OPTS=-Xmx2048m -Xms2048m
- KAFKA_NODE_ID=1
- KAFKA_PROCESS_ROLES=broker,controller
- KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER
- KAFKA_LISTENERS=CONTROLLER://:9094,BROKER://:9092,EXTERNAL://:9093
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,BROKER:PLAINTEXT,EXTERNAL:PLAINTEXT
- KAFKA_ADVERTISED_LISTENERS=BROKER://kafka:9092,EXTERNAL://localhost:9093
- KAFKA_INTER_BROKER_LISTENER_NAME=BROKER
- KAFKA_CONTROLLER_QUORUM_VOTERS=1@localhost:9094
- KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1
- KAFKA_LOG_DIRS=/var/lib/kafka/data
# KIP-714: 注册客户端指标reporter插件, 将客户端遥测指标转发到otel-collector
- KAFKA_METRIC_REPORTERS=com.instaclustr.kafka.KafkaClientMetricsReporter
- KAFKA_CLIENT_METRICS_CONFIG_PATH=/mnt/shared/config/client-metrics-reporter-config.yml
# JMX remote,供 kafka-ui 抓取基础指标(容器网络内)
- JMX_PORT=9998
- KAFKA_JMX_OPTS=-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Djava.rmi.server.hostname=kafka -Dcom.sun.management.jmxremote.rmi.port=9998
# 集成Prometheus JMX Exporter 1.6.0
- KAFKA_OPTS=-javaagent:/mnt/shared/jmx/jmx_prometheus_javaagent-1.6.0.jar=9095:/mnt/shared/jmx/kafka-jmx-exporter.yml
# 限制容器整体使用4G内存
deploy:
resources:
limits:
memory: 4G
apicurio-registry:
container_name: apicurio-registry
image: apicurio/apicurio-registry:3.3.1
ports:
# web console与REST API都在8080端口, 映射到宿主机8081避免与Tomcat冲突
# web console: http://localhost:8081, REST API: http://localhost:8081/apis/registry/v3
- "8081:8080"
depends_on:
- kafka
# 首次启动时若kafkasql topic刚创建完成可能因UnknownTopicOrPartition退出, 自动重启即可恢复
restart: on-failure
environment:
# schema存储到kafka中(kafkasql存储), 数据保存在kafka的kafkasql-journal topic里
- APICURIO_STORAGE_KIND=kafkasql
- APICURIO_KAFKASQL_BOOTSTRAP_SERVERS=kafka:9092
# web console是浏览器端SPA, 会直接跨域调用API, 放开CORS
- QUARKUS_HTTP_CORS_ORIGINS=*
# 允许通过REST API/web console删除artifact(默认禁用)
- APICURIO_REST_DELETION_ARTIFACT_ENABLED=true
# 允许删除空group(默认禁用)
- APICURIO_REST_DELETION_GROUP_ENABLED=true
apicurio-registry-ui:
container_name: apicurio-registry-ui
image: apicurio/apicurio-registry-ui:3.3.1
ports:
# web console: http://localhost:8082
- "8082:8080"
depends_on:
- apicurio-registry
environment:
# SPA在浏览器中运行, 该URL必须是浏览器可达的地址, 即API的宿主机映射地址
- REGISTRY_API_URL=http://localhost:8081/apis/registry/v3
kafka-ui:
container_name: kafka-ui
image: ghcr.io/kafbat/kafka-ui:latest
ports:
- "9080:8080"
depends_on:
- kafka
environment:
KAFKA_CLUSTERS_0_NAME: kafka-stand-alone
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
KAFKA_CLUSTERS_0_METRICS_PORT: 9998
# 接入Apicurio Registry(其Confluent兼容端点), 使消息列表能把Avro value反序列化显示
KAFKA_CLUSTERS_0_SCHEMAREGISTRY: http://apicurio-registry:8080/apis/ccompat/v7
# 默认value反序列化器设为SchemaRegistry, 打开消息列表即按Avro解码(否则默认String需手动切换)
KAFKA_CLUSTERS_0_DEFAULTVALUESERDE: SchemaRegistry
SERVER_SERVLET_CONTEXT_PATH: /kafkaui
AUTH_TYPE: "LOGIN_FORM"
SPRING_SECURITY_USER_NAME: admin
SPRING_SECURITY_USER_PASSWORD: adb-1234
DYNAMIC_CONFIG_ENABLED: 'true'
volumes:
kafka_data:
driver: local
Apicurio Web Console 页面介绍
Apicurio除了可以接受REST API管理schema,还提供了UI工具,所以在测试环境我一般使用UI工具,修改、查看schema

这里使用默认的group,artifact的名字格式为topic名字-value后缀
TopicIdStrategy
Default strategy that uses the topic name and
keyorvaluesuffix.
还有其他几种策略,见ArtifactReferenceResolverStrategy文档
点击Version中的某个版本号,可以跳转到某个版本的具体信息,切换到Content即可查看该版本的schema的详细信息

更多其他使用细节可参考官方 web console文档
工作原理

上图引用自官方文档
在下面的发送消息和消费消息中有更详细的说明
生产者发送消息
只需要把Kafka(三)生产者发送JSON消息+使用统一序列化器中kafka生产者的序列化格式由Jaso切换为Avro,并设置schema registry 地址即可
java
result.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, AvroKafkaSerializer.class.getName());
result.put(SchemaResolverConfig.REGISTRY_URL, registryUrl());
// 关闭自动向registry注册schema
result.put(SchemaResolverConfig.AUTO_REGISTER_ARTIFACT, Boolean.FALSE.toString());
// artifact命名策略: TopicIdStrategy(默认值, 这里显式写出), artifactId=<topic>-value, group=default.
// 注意: kafbat kafka-ui反序列化时, 主schema按消息内嵌的contentId调ccompat /schemas/ids/{id}获取(不受group限制),
// 但引用(references)按裸subject名(不带group前缀)调 /subjects/{subject}/versions/{n} 解析, 只命中default group;
// 自定义group会导致带引用的schema引用解析失败(消息显示原始字节), 因此保持默认.
result.put(SchemaResolverConfig.ARTIFACT_RESOLVER_STRATEGY, TopicIdStrategy.class.getName());
通过Schema查询contentId
个人觉得上图左侧Producer的流程有点误导人,正确步骤为,序列化消息的第一步要拿到这个消息的schema 对应的ID,这个ID可以是contentId也可以是globalId,默认是contentId
By default, the schema is retrieved from Apicurio Registry by the deserializer using a content ID
可以通过apicurio.registry.use-id 修改为globalId
那么它是如何找到contentId的呢?
简单来说:它是通过消息对象的.avsc文件字符串+上述配置的ARTIFACT_RESOLVER_STRATEGY通过REST API请求请求Apicurio服务器查到的

但是发消息时只有Java对象,.avsc的字符串又来自于哪呢?
我们通过maven 生成.avsc对于的Java类时,其内部都有一个getSchema方法,这个方法返回的就是我们之前定义的.avsc文件的内容

拿到contentId之后,会把它放到message body的前面
shell
# ...
[MAGIC_BYTE]
[CONTENT_ID]
[MESSAGE DATA]
When locating the content ID in the message payload, the format of the data begins with a magic byte, used as a signal to consumers, followed by the content ID, and the message data as normal
更详细的调用栈如下
KafkaProducer.send(record)
└─ AvroKafkaSerializer.serialize("email", headers, userDTO)
└─ KafkaSerializer.serializeData → AbstractSerializer.serializeData
│
├─ 1) DefaultSchemaResolver.resolveSchema(Record)
│ Record = KafkaSerdeMetadata{topic="email", isKey=false, headers}
│ │
│ ├─ a. SchemaParser.getSchemaFromData(record, resolveDereferenced)
│ │ Avro: 从 SpecificRecord.getSchema() 取编译期 schema
│ │ → ParsedSchema(原始 .avsc 字节 + references)
│ │
│ ├─ b. AbstractSchemaResolver.resolveArtifactReference(record, parsedSchema, ...)
│ │ └─ TopicIdStrategy.artifactReference(record, parsedSchema)
│ │ artifactId = String.format("%s-%s", topic, isKey?"key":"value")
│ │ = "email-value" (groupId=null, version=null)
│ │ 再经 builder 合并显式配置 (EXPLICIT_ARTIFACT_* 未配置 → 全用策略值)
│ │ → ArtifactReference{groupId=null, artifactId="email-value", version=null}
│ │
│ ├─ c. getSchemaFromCache(reference, parsedSchema)
│ │ ERCache 按 globalId/contentId/contentHash 三个索引查
│ │ 此时 reference 里三者皆 null → miss
│ │
│ └─ d. getSchemaFromRegistry(parsedSchema, record, reference) → 分支(按序):
│ autoCreateArtifact=false → 跳过 handleAutoCreateArtifact ★ 我的配置
│ findLatest=false → 跳过 resolveSchemaByCoordinates(g, a, "latest")
│ supportsExtractSchemaFromData()=true (Avro)
│ → handleResolveSchemaByContent ★ 走这里
│ (仅当解析器无法从数据取 schema 时才落到最后一支:
│ resolveSchemaByCoordinates(g, a, version) 按显式坐标取)
│
├─ 2) handleResolveSchemaByContent(parsedSchema, reference)
│ ├─ content = parsedSchema.getReferencelessRawSchema() → 字符串
│ ├─ schemaCache.getByContent(ContentWithReferences(content), loader)
│ │ ← 内容级缓存; 命中则整个 3)-4) 都不发生, 零 HTTP
│ └─ miss → loader lambda:
│ artifactType = schemaParser.artifactType() = "AVRO"
│ coordsList = clientFacade.searchVersionsByContent(
│ content, canonicalHash, reference, dereferenced)
│ ★ coordsList 为空 → 抛 RuntimeException
│ "Could not resolve artifact reference by content: <schema内容>"
│ 否则取 list.get(0) → 进 4)
│
├─ 3) RegistryClientFacadeImpl.searchVersionsByContent [Kiota HTTP client]
│ contentType = ArtifactTypeToContentType.toContentType("AVRO")
│ POST {APICURIO_REGISTRY_URL}/search/versions?groupId=default&artifactId=email-value
│ body = schema 原始内容
│ │
│ │ ★ 服务端做的事: 按 canonical content hash 在该 artifact 的版本里找
│ │ 内容一致者(只查不建)。contentId 的源头在服务端数据模型:
│ │ content 按 canonical hash 全局去重存储, 内容首次落库时分配 contentId,
│ │ 内容相同的多个版本共享同一 contentId, 且 contentId 全局唯一(不受 group 限)
│ │
│ │ HTTP 响应: VersionSearchResults.versions[], 每个 SearchedVersion 的 JSON
│ │ 自带元数据: globalId, contentId, groupId, artifactId, version, state
│ │
│ ├─ 过滤: state != DISABLED
│ └─ 映射(lambda$searchVersionsByContent$3): 每个 SearchedVersion →
│ RegistryVersionCoordinates.create(getGlobalId(), getContentId(),
│ getGroupId(), getArtifactId(), getVersion())
│ ★ contentId 从响应 JSON 进入客户端对象就是这一步
│
├─ 4) AbstractSchemaResolver.loadFromVersionCoordinates(coords, parsedSchema, refs)
│ SchemaLookupResult.builder()
│ .globalId(coords.getGlobalId())
│ .contentId(coords.getContentId()) ★ contentId 落进解析结果
│ .groupId(coords.getGroupId()==null?"default":...) 等
│ .parsedSchema(parsedSchema) ← schema 手头已有, 不再发 HTTP 取内容
│ .build()
│ → 回填 ERCache, 返回 SchemaLookupResult
│
└─ 5) AbstractSerializer 写消息
├─ executeContractRulesForWrite(lookupResult, data) ← 3.x 契约规则检查(无规则则空转)
├─ reference = lookupResult.toArtifactReference()
│ ★ 把 lookupResult 的 globalId/contentId/groupId/artifactId/version
│ 重新打包成 ArtifactReference
├─ getIdHandler() = Default4ByteIdHandler
│ (KafkaSerializer 构造时 setIdHandler; idOption 默认 contentId,
│ 读自 SerdeConfig.useIdOption(), 默认常量 = IdOption.contentId.name())
│ writeId(reference, out):
│ reference.getContentId().intValue()
│ → ByteBuffer.allocate(4).putInt(contentId) ★ 4 字节 contentId 写入 payload 头部
└─ serializeData(parsedSchema, userDTO, out) ← AvroSerializer 追加 Avro 二进制
→ 最终字节: [4字节 contentId][Avro payload]
消费者消费消息
同理只需要把Kafka(四)消费者消费JSON消息+使用统一反序列化器的对应配置做对应调整即可
java
// value使用Apicurio提供的Avro反序列化器, 根据消息payload中的schema id从registry获取schema
result.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, AvroKafkaDeserializer.class.getName());
result.put(SchemaResolverConfig.REGISTRY_URL, registryUrl());
// 反序列化为schema对应的SpecificRecord类(即生成的com.message.common.dto.UserDTO/CallbackMetaData), 而不是GenericRecord
result.put(AvroSerdeConfig.USE_SPECIFIC_AVRO_READER, Boolean.TRUE.toString());
上图右侧Consumer流程没有误导性,详细的调用栈如下
KafkaConsumer.poll → ValueDeserializer.deserialize("email", headers, bytes)
└─ AvroKafkaDeserializer.deserialize
└─ KafkaDeserializer(构造时已 setIdHandler(new Default4ByteIdHandler()))
└─ AbstractDeserializer.deserializeData
│
├─ 1) 解析消息头里的 contentId
│ ByteBuffer.wrap(bytes)
│ idHandler.readId(buffer) → Default4ByteIdHandler.readId:
│ buffer.getInt() 读 payload 头部 4 字节
│ idOption=contentId → ArtifactReference{contentId=N}(其余字段全 null)
│ ★ 生产端 writeId 的逆操作, 消息的"schema 指针"就是这 4 字节
│
├─ 2) getCacheKey(reference) → 反序列化器本地 schema 缓存
│ 命中则直接跳到 5) 复用 ParsedSchema, 零 HTTP
│
├─ 3) DefaultSchemaResolver.resolveSchemaByArtifactReference(reference)
│ 按优先级取 id: contentId != null → resolveSchemaByContentId(N)
│ (否则 contentHash → globalId 依次兜底)
│ └─ resolveSchemaByContentId → ERCache.getByContentId(N, loader)
│ ★ resolver 级缓存, 同 contentId 只发一次 HTTP
│ miss → loader(lambda$resolveSchemaByContentId$1):
│ │
│ ├─ a. clientFacade.getSchemaByContentId(N)
│ │ GET {APICURIO_REGISTRY_URL}/ids/contentIds/{N}
│ │ ★ 按 contentId 全局取 schema 内容(UserDTO 的 .avsc 文本),
│ │ 不需要 group/artifact ------ 与坑位10kafbat的取法同源
│ │
│ ├─ b. clientFacade.getReferencesByContentId(N)
│ │ GET .../ids/contentIds/{N}/references
│ │ → [RegistryArtifactReference{groupId="default",
│ │ artifactId="com.message.common.dto.CallbackMetaData",
│ │ version="1", name="com.message.common.dto.CallbackMetaData"}]
│ │
│ ├─ c. resolveReferences(refs) ------ 逐个解析引用(UserDTO 的嵌套 record)
│ │ 每个 ref → ArtifactCoordinates(groupId:null则"default", artifactId, version)
│ │ → referenceCache(ConcurrentHashMap) 先查
│ │ → miss: 按 GAV 拉取该引用 schema 内容并 parse
│ │ (getSchemaByGAV → GET /groups/default/artifacts/{artifactId}/versions/{version}/content)
│ │ → 引用自身还有引用则递归(hasReferences)
│ │ → Map<引用名, ParsedSchema>
│ │ ★ 引用按注册时存的 GAV 坐标取, 所以引用必须在 default group 可解析
│ │
│ └─ d. schemaParser.parseSchema(content.bytes, referencesMap)
│ → ParsedSchema<avro.Schema>(主schema + 已链接的引用)
│
├─ 4) 组装 SchemaLookupResult(parsedSchema, contentId, globalId, ...)
│ 回填 ERCache + 反序列化器本地缓存
│
└─ 5) 解码数据
idHandler.idSize(reference, buffer) = 4 → 定位 payload 起点
readData(parsedSchema, buffer, start, length)
└─ AvroDeserializer → DefaultAvroDatumProvider
(configure 时读 AvroSerdeConfig, USE_SPECIFIC_AVRO_READER=true)
createDatumReader:
writerSchema = 3) 从 registry 拉回的 schema
readerSchema = 本地 classpath 里 UserDTO.getSchema()(编译期生成类)
→ SpecificDatumReader(writer, reader) ★ Avro 的 schema resolution
在这里发生: 写读 schema 不一致时按 Avro 规则做投影/默认值填充
→ 二进制解码 → 直接物化成 UserDTO 实例(嵌套 CallbackMetaData)
Kafbat-ui中查看Avro类型消息
在kafbat-ui中只要配置了schema registry的地址即可显示Avro类型的消息,具体配置见上述docker compose文件

保障Schema的兼容性
Schema 的兼容性是站在消费者角度看的
BACKWARD
解决的是 拿到新的Schema的消费者 能不能读取使用旧的Schema的生产者发送的数据?
FORWARD
解决的是 还在使用旧的Schema的消费者 能不能读取使用新的Schema的生产者发送的数据?
实际使用过程中,应尽量避免修改avsc文件的类型
在开发/测试阶段即使发现不兼容的Schema
可以使用apicurio-registry-maven-plugin在编译阶段就探测不兼容的schema
xml
<!-- 测试阶段对 schema 做兼容性 dryRun 校验: 以 registry 中已发布版本为准执行 BACKWARD 规则,
不兼容的改动直接构建失败, 提前于部署暴露. 前提: registry 已启动且已创建全局 BACKWARD 规则
(见 docs/deployment.md). registry 不可用时可用 -DskipRegister=true 跳过 -->
<plugin>
<groupId>io.apicurio</groupId>
<artifactId>apicurio-registry-maven-plugin</artifactId>
<version>3.3.1</version>
<executions>
<execution>
<id>schema-compatibility-check</id>
<phase>test</phase>
<goals>
<goal>register</goal>
</goals>
<configuration>
<registryUrl>${apicurio.url}</registryUrl>
<!-- dryRun=true 只校验不落库; 注意插件实现里只有配置了 ifExists 时 dryRun 才会真正生效 -->
<dryRun>true</dryRun>
<artifacts>
<!-- CallbackMetaData 自身演进校验(它在registry中以独立artifact承载版本序列) -->
<artifact>
<groupId>default</groupId>
<artifactId>com.message.common.dto.CallbackMetaData</artifactId>
<artifactType>AVRO</artifactType>
<file>${project.basedir}/src/main/avro/CallbackMetaData.avsc</file>
<ifExists>FIND_OR_CREATE_VERSION</ifExists>
</artifact>
<!-- UserDTO 对应 email-value(TopicIdStrategy命名); 引用不带file, 指向registry中已存在的版本构造引用,
服务端据此解析 UserDTO.avsc 中的具名引用再做兼容性校验.
注意: version 需保持为 registry 中真实存在的版本; CallbackMetaData 演进出新版本后需同步更新此值 -->
<artifact>
<groupId>default</groupId>
<artifactId>email-value</artifactId>
<artifactType>AVRO</artifactType>
<file>${project.basedir}/src/main/avro/UserDTO.avsc</file>
<ifExists>FIND_OR_CREATE_VERSION</ifExists>
<references>
<reference>
<!-- name 必须与 UserDTO.avsc 中的具名引用一致 -->
<name>com.message.common.dto.CallbackMetaData</name>
<groupId>default</groupId>
<artifactId>com.message.common.dto.CallbackMetaData</artifactId>
<version>1</version>
</reference>
</references>
</artifact>
</artifacts>
</configuration>
</execution>
</executions>
</plugin>