AWS云上Glue Schema Registry在MSK实时数据流中的schema管理实践

本文档记录了基于 Amazon MSK(Managed Streaming for Apache Kafka)和 Amazon Glue Schema Registry (GSR) 的完整测试实践。通过实际操作验证了 GSR 如何实现 Schema 的自动注册、版本管理及兼容性控制,确保上游数据结构的演进不会破坏下游系统的稳定性。

核心概念与理论

Schema 是数据结构的版本化规范(versioned specification),定义了数据记录的结构和格式,用于可靠的数据发布、消费或存储。类似于数据库的表结构定义(DDL),但用于流数据场景。

Glue Schema Registry (GSR) 是 AWS Glue 的无服务器功能,它使你能够在不额外收费的情况下,针对 Apache Avro 模式验证和可靠地演进流数据。主要解决的问题参考官方blog

With the Glue Schema Registry, you can eliminate defensive coding and cross-team coordination, improve data quality, reduce downstream application failures, and use a registry that is integrated across multiple AWS services. Each schema can be versioned within the guardrails of a compatibility mode, providing developers the flexibility to reliably evolve schemas. Additionally, the Glue Schema Registry can serialize data into a compressed format, helping you save on data transfer and storage costs.

序列化格式

Glue Schema Registry (GSR) 支持三种序列化格式:

格式 支持版本 说明
AVRO v1.11.4 Apache Avro 二进制序列化格式
JSON Schema Draft-04/06/07 使用 Everit 库进行验证
Protocol Buffers proto2/proto3 不支持 extensions 和 groups

三种格式的算则逻辑如下

  • 选 AVRO:大数据/Kafka 生态、高吞吐、需要成熟的 schema 演进支持(Spark/Flink/Hive 原生支持 Avro)
  • 选 JSON Schema:数据量不大、需要人类可读、快速原型验证、前后端交互场景
  • 选 Protobuf:对性能和体积要求极高、已有 gRPC 微服务体系、跨语言调用多
维度 AVRO JSON Schema Protobuf
序列化大小 小(二进制) 大(文本) 最小(二进制)
序列化速度 最快
可读性 不可读 人类可读 不可读
Schema 演进 原生支持,最成熟 支持 支持
生态兼容 Kafka/Hadoop 生态首选 Web/REST API 友好 gRPC/微服务首选
学习成本 中等 较高(需写 .proto 文件)

兼容性模式

当向注册中心提交 Schema 新版本时,兼容性规则决定是否接受该变更:

模式 含义
NONE 不做任何兼容性检查,任何变更都接受
DISABLED 完全禁止提交新版本
BACKWARD 新版本消费者能读取上一个版本的数据
BACKWARD_ALL 新版本消费者能读取所有历史版本的数据
FORWARD 上一个版本消费者能读取新版本的数据
FORWARD_ALL 所有历史版本消费者都能读取新版本的数据
FULL 同时满足 BACKWARD + FORWARD
FULL_ALL 同时满足 BACKWARD_ALL + FORWARD_ALL

假设当前 Schema V1 为:

复制代码
{
  "type": "record",
  "name": "Person",
  "fields": [
    {
      "name": "name",
      "type": "string"
    },
    {
      "name": "age",
      "type": "int"
    }
  ]
}
BACKWARD 模式示例

场景:新消费者(V2)读取旧数据(V1 格式)

  • V1 数据: {name:Alice,age:30}

  • 允许的 V2 变更 --- 添加带 default 的字段:

    V2 Schema: {name, age, income default:null}

    V2 消费者读 V1 数据 → income 用 null 填充 → 正常工作

  • 拒绝的 V2 变更 --- 添加不带 default 的必填字段:

    V2 Schema: {name, age, income} (无 default)

    V2 消费者读 V1 数据 → income 无值可填 → 报错

FORWARD 模式示例

场景:旧消费者(V1)读取新数据(V2 格式)

  • V1 Schema: {name, age, email default:unknown}

  • 允许的 V2 变更 --- 删除带 default 的字段:

    V2 Schema: {name, age} (删除 email)

    V1 消费者读 V2 数据 → email 缺失,用 default unknown 填充 → 正常工作

  • 拒绝的 V2 变更 --- 删除不带 default 的字段:

    V2 Schema: {age, email} (删除 name,name 无 default)

    V1 消费者读 V2 数据 → name 缺失且无 default → 报错

FULL 模式示例

同时满足 BACKWARD + FORWARD,即:

  • 新增字段必须有 default(保证 BACKWARD)
  • 删除字段在旧 schema 中必须有 default(保证 FORWARD)

场景

  • V1: {name, age, email default:unknown}

  • V2: {name, age, phone default:} --- 删除 email(有default) + 添加 phone(有default)

  • V2: {name, age, phone} --- 添加 phone 无 default,违反 BACKWARD

  • V2: {age, email} --- 删除 name 无 default,违反 FORWARD

*_ALL 变体
  • BACKWARD 只检查与上一个版本的兼容性
  • BACKWARD_ALL 检查与所有历史版本的兼容性

GSR 工作原理

总体架构图如下
注册 Schema
查询 Schema
Kafka Producer

(Java + GSR Serializer)
Amazon MSK

Topic: t1
Glue Streaming Job

(PySpark Consumer)
Glue Schema Registry

Registry: default-registry

Schema: t1

Version2: {name:string,age:int}

Version3: 新增income:number

生产者端(序列化 + 注册)

应用程序

(Producer)
GlueSchemaRegistryKafka

Serializer
Kafka

Broker
Glue Schema Registry

(注册/查询 Schema)

步骤 1:Schema 查找或注册

  • 序列化器从数据对象中提取 Schema 定义(如 JsonDataWithSchema 中的 schema 字符串,或 GenericRecord 中的 Avro Schema)
  • 先检查本地缓存是否已有该 Schema 的 Version ID
  • 若缓存未命中,调用 glue:GetSchemaByDefinition 查询 GSR
  • 若 Schema 不存在且开启了自动注册(SCHEMA_AUTO_REGISTRATION_SETTING=true),调用 glue:RegisterSchemaVersion 注册新版本
  • 注册时 GSR 执行兼容性检查(根据 BACKWARD/FORWARD/FULL 等规则),不兼容则拒绝注册,返回 FAILURE 状态
  • 注册成功后,GSR 返回 Schema Version ID(UUID),序列化器将其缓存(默认 24 小时)

步骤 2:数据验证

  • 序列化器用 Schema 定义验证待发送的数据
  • JSON 格式:使用 Everit 库校验 JSON 数据是否符合 JSON Schema 定义
  • AVRO 格式:Avro 库自动校验 GenericRecord 是否符合 Schema 字段定义
  • 验证失败则抛出 AWSSchemaRegistryException,数据不会发送到 Kafka

步骤 3:序列化 + 添加 Header + 投递

  • 按数据格式序列化数据(JSON → UTF-8 字节,AVRO → 二进制编码)
  • 可选压缩(ZLIB)
  • 在序列化后的字节前面添加 18 字节 GSR Header
  • 将完整的 payload(Header + 数据)作为 Kafka 消息的 value 发送
消息在 Kafka 中的存储格式
复制代码
Kafka Message Value Layout:
Offset  Length  Field                     Description
0       1       Header Version            0x03
1       1       Compression Type          0=None, 5=ZLIB
2       16      Schema Version UUID       16 bytes
18      var     Serialized Data           Payload

GSR Header 结构(18 字节):

  • Byte 0:Header Version(当前为 3)
  • Byte 1:Compression 类型(0=无压缩,5=ZLIB)
  • Bytes 2-17:Schema Version UUID(16 字节,标识该消息对应的 Schema 版本)
消费者端(反序列化 + 查询)

Kafka Broker
GlueSchemaRegistryKafka

Deserializer
应用程序

(Consumer)
Glue Schema Registry

(查询 Schema by Version ID)

步骤 4:解析 Header

  • 反序列化器读取 payload 的前 18 字节
  • 识别 Header Version(magic byte),确认这是 GSR 编码的消息
  • 提取 Compression 类型和 Schema Version UUID

步骤 5:获取 Schema 定义

  • 先检查本地缓存是否已有该 Version ID 对应的 Schema
  • 若缓存未命中,调用 glue:GetSchemaVersion API,传入 Version ID
  • GSR 返回完整的 Schema 定义(JSON Schema 字符串或 Avro Schema 字符串)
  • 缓存该映射(默认 24 小时,最多 200 条)

步骤 6:反序列化数据

  • 若消息有压缩,先解压(ZLIB)
  • 用获取到的 Schema 定义反序列化 Bytes 18+ 的数据
  • JSON 格式:返回 JsonDataWithSchema 对象(包含 schema + payload)
  • AVRO 格式:返回 GenericRecord 或 SpecificRecord 对象
  • 将反序列化后的对象交给应用程序处理
缓存机制
缓存位置 缓存内容 默认 TTL 默认大小
Producer Schema Definition → Version ID 24 小时 200 条
Consumer Version ID → Schema Definition 24 小时 200 条

缓存减少了对 GSR API 的调用次数,提升吞吐量。可通过以下配置调整:

复制代码
props.put(AWSSchemaRegistryConstants.CACHE_TIME_TO_LIVE_MILLIS,  86400000); // 24h
props.put(AWSSchemaRegistryConstants.CACHE_SIZE, 200);

IAM 权限要求

生产者需要的权限:

  • glue:GetSchemaByDefinition --- 查找已有 Schema
  • glue:CreateSchema --- 创建新 Schema(自动注册时需要)
  • glue:RegisterSchemaVersion --- 注册新版本
  • glue:PutSchemaVersionMetadata --- 添加元数据

消费者需要的权限:

  • glue:GetSchemaVersion --- 根据 Version ID 获取 Schema 定义

测试环境配置

MSK 集群

复制代码
集群名称: glueschema-test
集群 ARN: arn:aws-cn:kafka:cn-north-1:123456789012:cluster/glueschema-test/<cluster-uuid>
Kafka 版本: 3.5.1
Broker 数量: 2
实例类型: kafka.t3.small
存储: 10 GB EBS per broker
Bootstrap Servers (TLS): 
  b-1.<msk-cluster>.kafka.cn-north-1.amazonaws.com.cn:9094
  b-2.<msk-cluster>.kafka.cn-north-1.amazonaws.com.cn:9094

Kafka Topic

复制代码
Topic 名称: t1
分区数: 1
副本因子: 2
创建命令:
  kafka-topics.sh --create --topic t1 \
    --bootstrap-server <bootstrap-servers>:9094 \
    --partitions 1 --replication-factor 2 \
    --command-config <(echo 'security.protocol=SSL')

Glue Connection

json 复制代码
{
  "Name": "glueschema-msk-connection",
  "ConnectionType": "KAFKA",
  "ConnectionProperties": {
    "KAFKA_BOOTSTRAP_SERVERS": "b-1.<msk-cluster>.kafka.cn-north-1.amazonaws.com.cn:9094,b-2.<msk-cluster>.kafka.cn-north-1.amazonaws.com.cn:9094",
    "KAFKA_SSL_ENABLED": "true"
  },
  "PhysicalConnectionRequirements": {
    "SubnetId": "subnet-0cccc3333private1a",
    "SecurityGroupIdList": ["sg-0xxxxxxxxxxexample"],
    "AvailabilityZone": "cn-north-1a"
  }
}

注意 :Glue Connection 的子网必须有 S3 VPC Endpoint 或 NAT Gateway,否则 Glue Job 启动时会报 VPC S3 endpoint validation failed 错误。

IAM Role

复制代码
角色名称: AWSGlueServiceRole-all
角色 ARN: arn:aws-cn:iam::123456789012:role/AWSGlueServiceRole-all
附加策略:
  - AWSGlueServiceRole (AWS 托管)
  - AmazonS3FullAccess (AWS 托管)
  - AmazonKinesisFullAccess (AWS 托管)
  - msk-gsr-access (自定义 inline policy)

自定义 IAM Policy (msk-gsr-access)策略是必需的,因为 AWSGlueServiceRole 托管策略只包含 Glue 服务本身的权限,不包含访问 MSK 和 Schema Registry 所需的权限:

权限组 作用 为什么需要
kafka-cluster:* 连接 MSK 集群、读取 Topic 数据 Glue Job 需要通过 IAM 认证连接 MSK 并消费消息
kafka:Describe*/GetBootstrapBrokers 获取 MSK 集群元数据 Glue Connection 需要解析集群的 Bootstrap 地址
glue:GetSchemaVersion/GetSchema/... 读取 Schema Registry 中的 Schema 反序列化器需要根据 Version ID 查询 Schema 定义
ec2:CreateNetworkInterface/... 管理 VPC 网络接口 Glue Job 运行在 VPC 内,需要创建 ENI 连接到 MSK 所在子网

注意: 缺少 kafka-cluster 权限会导致连接超时;缺少 glue:GetSchemaVersion 会导致 AccessDeniedException;缺少 ec2 网络权限会导致 ENI 创建失败。

json 复制代码
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "kafka-cluster:Connect",
        "kafka-cluster:DescribeTopic",
        "kafka-cluster:ReadData",
        "kafka-cluster:DescribeGroup",
        "kafka-cluster:AlterGroup"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["kafka:DescribeCluster", "kafka:GetBootstrapBrokers"],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "glue:GetSchemaVersion",
        "glue:GetSchema",
        "glue:ListSchemaVersions",
        "glue:GetRegistry",
        "glue:GetSchemaByDefinition"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ec2:CreateNetworkInterface",
        "ec2:DescribeNetworkInterfaces",
        "ec2:DeleteNetworkInterface",
        "ec2:DescribeVpcEndpoints",
        "ec2:DescribeSubnets",
        "ec2:DescribeSecurityGroups",
        "ec2:DescribeRouteTables",
        "ec2:DescribeVpcAttribute"
      ],
      "Resource": "*"
    }
  ]
}

Glue Streaming Job

复制代码
Job 名称: glueschema-kafka-consumer
Glue 版本: 4.0
Worker 类型: G.1X
Worker 数量: 2
脚本位置: s3://aws-glue-assets-123456789012-cn-north-1/scripts/glue-kafka-consumer.py
Connection: glueschema-msk-connection

测试实践

项目依赖配置

pom.xml:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>kafka-gsr-producer</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.apache.kafka</groupId>
            <artifactId>kafka-clients</artifactId>
            <version>3.5.1</version>
        </dependency>
        <dependency>
            <groupId>software.amazon.glue</groupId>
            <artifactId>schema-registry-serde</artifactId>
            <version>1.1.5</version>
        </dependency>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.10.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.avro</groupId>
            <artifactId>avro</artifactId>
            <version>1.11.4</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.5.1</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals><goal>shade</goal></goals>
                        <configuration>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>com.example.GsrProducer</mainClass>
                                </transformer>
                            </transformers>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

JSON 格式:Producer 发送与 Schema 自动注册

验证 GSR 自动注册 JSON Schema,发送两个版本的数据到 topic t1

GsrProducer.java:

java 复制代码
package com.example;

import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistryKafkaSerializer;
import com.amazonaws.services.schemaregistry.serializers.json.JsonDataWithSchema;
import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants;
import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.config.SslConfigs;
import org.apache.kafka.common.serialization.StringSerializer;
import software.amazon.awssdk.services.glue.model.DataFormat;
import software.amazon.awssdk.services.glue.model.Compatibility;
import java.util.Properties;

public class GsrProducer {
    private static final String TOPIC = "t1";
    private static final String REGION = "cn-north-1";

    private static final String SCHEMA_V1 = "{\"$schema\":\"http://json-schema.org/draft-07/schema#\","
        + "\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"integer\"}},"
        + "\"required\":[\"name\",\"age\"]}";

    private static final String SCHEMA_V2 = "{\"$schema\":\"http://json-schema.org/draft-07/schema#\","
        + "\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"integer\"},"
        + "\"income\":{\"type\":\"number\"}},\"required\":[\"name\",\"age\",\"income\"]}";

    public static void main(String[] args) throws Exception {
        String bootstrapServers = args[0];
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, GlueSchemaRegistryKafkaSerializer.class.getName());
        props.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SSL");
        props.put(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG, "/usr/lib/jvm/java-17-amazon-corretto.x86_64/lib/security/cacerts");
        props.put(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG, "changeit");
        props.put(AWSSchemaRegistryConstants.AWS_REGION, REGION);
        props.put(AWSSchemaRegistryConstants.DATA_FORMAT, DataFormat.JSON.name());
        props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, "true");
        props.put(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING, Compatibility.NONE.name());
        props.put(AWSSchemaRegistryConstants.REGISTRY_NAME, "default-registry");
        props.put(AWSSchemaRegistryConstants.SCHEMA_NAME, TOPIC);

        try (KafkaProducer<String, JsonDataWithSchema> producer = new KafkaProducer<>(props)) {
            JsonDataWithSchema v1 = JsonDataWithSchema.builder(SCHEMA_V1, "{\"name\":\"Alice\",\"age\":30}").build();
            producer.send(new ProducerRecord<>(TOPIC, "key1", v1)).get();
            System.out.println("V1 sent successfully");
        }
        try (KafkaProducer<String, JsonDataWithSchema> producer = new KafkaProducer<>(props)) {
            JsonDataWithSchema v2 = JsonDataWithSchema.builder(SCHEMA_V2, "{\"name\":\"Bob\",\"age\":25,\"income\":50000.0}").build();
            producer.send(new ProducerRecord<>(TOPIC, "key2", v2)).get();
            System.out.println("V2 sent successfully");
        }
    }
}

要点: 必须使用 JsonDataWithSchema 对象将 Schema 定义和数据绑定在一起,序列化器会用 Schema 验证数据后再发送。

⚠️ 注意: 如果直接发送 byte[] 而不是 JsonDataWithSchema 对象,会报错:AWSSchemaRegistryException: JSON data validation against schema failed. Caused by: ValidationException: #: expected type: JSONArray, found: String序列化器无法从 byte[] 中提取 JSON Schema 定义,必须用 JsonDataWithSchema.builder(schema, data).build() 包装。

执行日志:

复制代码
Sending V1: {"name":"Alice","age":30}
V1 sent successfully - Schema Version 1 registered
Sending V2: {"name":"Bob","age":25,"income":50000.0}
V2 sent successfully - Schema Version 2 registered
Done! Check Glue Schema Registry for schema versions.

**Schema Registry 验证结果:**V2 到V3 的演进体现了字段增加(新增 income),由于兼容性设为 NONE,直接接受。

版本 Version ID Schema 定义
V1 2f19deb7-2446-4e85-90d7-4193b5be0deb {"type":"array","items":{"type":"integer"}}
V2 7ab9823d-65eb-4682-bb54-6f0ce7b0f732 {"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"]}
V3 e2c1d04a-fde9-4b2b-8e5c-efabc0fb6fe0 {"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"},"income":{"type":"number"}},"required":["name","age","income"]}

JSON 格式:Java Consumer(GSR 原生反序列化)

验证 GlueSchemaRegistryKafkaDeserializer 自动从消息 header 中提取 Schema Version ID → 查询 GSR → 反序列化。

GsrConsumer.java:

java 复制代码
package com.example;

import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryKafkaDeserializer;
import com.amazonaws.services.schemaregistry.serializers.json.JsonDataWithSchema;
import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants;
import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.config.SslConfigs;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;

public class GsrConsumer {
    public static void main(String[] args) {
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, args[0]);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "gsr-consumer-test");
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, GlueSchemaRegistryKafkaDeserializer.class.getName());
        props.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SSL");
        props.put(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG, "/usr/lib/jvm/java-17-amazon-corretto.x86_64/lib/security/cacerts");
        props.put(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG, "changeit");
        props.put(AWSSchemaRegistryConstants.AWS_REGION, "cn-north-1");

        try (KafkaConsumer<String, JsonDataWithSchema> consumer = new KafkaConsumer<>(props)) {
            consumer.subscribe(Collections.singletonList("t1"));
            int emptyPolls = 0;
            while (emptyPolls < 3) {
                ConsumerRecords<String, JsonDataWithSchema> records = consumer.poll(Duration.ofSeconds(5));
                if (records.isEmpty()) { emptyPolls++; continue; }
                emptyPolls = 0;
                for (ConsumerRecord<String, JsonDataWithSchema> record : records) {
                    System.out.println("Key: " + record.key());
                    System.out.println("Schema: " + record.value().getSchema());
                    System.out.println("Payload: " + record.value().getPayload());
                }
            }
        }
    }
}

要点: 消费者只需配置 GlueSchemaRegistryKafkaDeserializerAWS_REGION,不需要预先知道 schema 定义。反序列化器自动从消息 header 中的 Version ID 查询 GSR。

执行日志:

复制代码
Consuming from topic: t1
---
Key: key1
Schema: {"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"]}
Payload: {"name":"Alice","age":30}
Offset: 0
---
Key: key2
Schema: {"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"},"income":{"type":"number"}},"required":["name","age","income"]}
Payload: {"name":"Bob","age":25,"income":50000.0}
Offset: 1
Done - no more records.

分析: 每条消息自带其对应版本的 schema。key1 返回 V2 schema(name+age),key2 返回 V3 schema(name+age+income)。消费者无需硬编码 schema,GSR 反序列化器全自动处理。

JSON 格式:Glue Streaming Job 消费(PySpark)

目标: 在 PySpark 环境中消费 GSR 编码的 Kafka 数据。

限制: Spark Structured Streaming 的 Kafka source 内部硬编码使用 ByteArrayDeserializer,不支持自定义 Kafka Deserializer。因此 PySpark 环境中无法直接使用 GlueSchemaRegistryKafkaDeserializer,需要手动处理 GSR header。

glue-kafka-consumer.py

python 复制代码
import sys
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from awsglue.job import Job
from pyspark.sql.functions import col, from_json, expr
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType

args = getResolvedOptions(sys.argv, ['JOB_NAME'])
sc = SparkContext()
glueContext = GlueContext(sc)
spark = glueContext.spark_session
job = Job(glueContext)
job.init(args['JOB_NAME'], args)

json_schema = StructType([
    StructField("name", StringType(), True),
    StructField("age", IntegerType(), True),
    StructField("income", DoubleType(), True)
])

raw_df = spark.readStream.format("kafka") \
    .option("kafka.bootstrap.servers", "<bootstrap-servers>:9094") \
    .option("kafka.security.protocol", "SSL") \
    .option("subscribe", "t1") \
    .option("startingOffsets", "earliest").load()

# GSR header: 18 bytes (1 version + 1 compression + 16 UUID)
# Skip header, parse JSON
parsed_df = raw_df.select(
    col("key").cast("string").alias("key"),
    expr("substring(value, 19)").cast("string").alias("json_value"),
    col("offset"), col("timestamp")
).withColumn("data", from_json(col("json_value"), json_schema)).select(
    col("key"), col("data.name").alias("name"),
    col("data.age").alias("age"), col("data.income").alias("income"),
    col("offset"), col("timestamp")
)

query = parsed_df.writeStream.outputMode("append").format("json") \
    .option("path", "s3://<bucket>/output/") \
    .option("checkpointLocation", "s3://<bucket>/checkpoint/") \
    .trigger(once=True).start()
query.awaitTermination()
job.commit()

执行结果(S3 输出):

json 复制代码
{"key":"key1","name":"Alice","age":30,"offset":0,"timestamp":"2026-05-11T14:11:12.899Z"}
{"key":"key2","name":"Bob","age":25,"income":50000.0,"offset":1,"timestamp":"2026-05-11T14:11:14.215Z"}

Glue Job 运行详情:

json 复制代码
{
    "JobRunState": "SUCCEEDED",
    "ExecutionTime": 79,
    "WorkerType": "G.1X",
    "NumberOfWorkers": 2,
    "GlueVersion": "4.0"
}

分析: V1 数据(Alice)没有 income 字段,解析为 null;V2 数据(Bob)完整包含所有字段。不同版本的数据共存于同一 Topic,消费者使用 superset schema 兼容读取。

⚠️ 注意事项(Glue Streaming Job 开发):

Glue Kafka Source 不兼容 GSR Header: Glue 封装的 create_data_frame.from_options(connection_type= kafka, classification=json) 会尝试将 value 当纯 JSON 解析,但 GSR 序列化的数据前 18 字节是 header,导致解析失败(产生 jsonjsonjson 临时列)。解决方案是使用 Spark 原生 spark.readStream.format(kafka) 读取原始二进制,手动 substring(value, 19) 跳过 header。

AVRO 格式

使用 AVRO 格式发送和消费数据,验证 Schema 自动注册。

AVRO 与 JSON 的关键差异

差异点 JSON AVRO
数据对象 JsonDataWithSchema GenericRecord
Schema 定义 JSON Schema (Draft-07) Avro Schema (.avsc)
Consumer 额外配置 需要 AVRO_RECORD_TYPE
兼容性演进 无 default 概念,规则宽松 依赖 default 值,规则严格
序列化结果 文本(可读) 二进制(不可读)

Schema 定义:

json 复制代码
// V1: name + age
{"type":"record","name":"Person","fields":[
  {"name":"name","type":"string"},
  {"name":"age","type":"int"}
]}

// V2 (BACKWARD 兼容): 添加带 default 的字段
{"type":"record","name":"Person","fields":[
  {"name":"name","type":"string"},
  {"name":"age","type":"int"},
  {"name":"income","type":["null","double"],"default":null}
]}

Producer 代码片段:

java 复制代码
props.put(AWSSchemaRegistryConstants.DATA_FORMAT, DataFormat.AVRO.name());
props.put(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING, Compatibility.NONE.name());

Schema schemaV1 = new Schema.Parser().parse(SCHEMA_V1);
GenericRecord record = new GenericData.Record(schemaV1);
record.put("name", "Alice");
record.put("age", 30);
producer.send(new ProducerRecord<>(topic, "key1", record)).get();

Consumer 代码片段:

java 复制代码
// AVRO 消费者需要额外指定 AVRO_RECORD_TYPE
props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName());
KafkaConsumer<String, GenericRecord> consumer = new KafkaConsumer<>(props);

执行日志:

复制代码
[BASIC] Sending V1: {"name": "Alice", "age": 30}
[BASIC] V1 sent successfully
[BASIC] Sending V2: {"name": "Bob", "age": 25, "income": 50000.0}
[BASIC] V2 sent successfully
[BASIC] Done! Schema auto-registered with NONE compatibility.

消费验证:

复制代码
[CONSUME] Reading from topic: avro-t1
  Key=key1 Value={"name": "Alice", "age": 30} Schema=Person
  Key=key2 Value={"name": "Bob", "age": 25, "income": 50000.0} Schema=Person
[CONSUME] Done.

**** AVRO 反序列化器自动根据消息中的 Schema Version ID 获取对应 schema,将二进制数据还原为 GenericRecord 对象。两条消息使用不同版本的 schema,消费者均能正确解析。

AVRO 格式:BACKWARD 兼容性校验

BACKWARD 兼容意味着新版本消费者能读取旧版本数据。新增字段必须有 default 值,否则新消费者读旧数据时无法填充该字段。

复制代码
V1 数据: {name: "Alice", age: 30}
V2 消费者读取:
  - income 有 default:null → {name: "Alice", age: 30, income: null} 
  - income 无 default     → 无法构造完整记录 
添加带 default 的字段允许创建
java 复制代码
// V2: 新增 income 字段,有 default: null
private static final String SCHEMA_V2_BACKWARD = "{"
    + "\"type\":\"record\",\"name\":\"Person\",\"fields\":["
    + "{\"name\":\"name\",\"type\":\"string\"},"
    + "{\"name\":\"age\",\"type\":\"int\"},"
    + "{\"name\":\"income\",\"type\":[\"null\",\"double\"],\"default\":null}"
    + "]}";

执行日志:

复制代码
[BACKWARD-OK] Registering V1 schema...
[BACKWARD-OK] V1 registered successfully
[BACKWARD-OK] Sending V2 (add field with default) - should PASS...
[BACKWARD-OK] V2 PASSED! New field with default is BACKWARD compatible.
添加不带 default 的字段拒绝创建
java 复制代码
// V2: 新增 income 字段,无 default
private static final String SCHEMA_V2_NOT_BACKWARD = "{"
    + "\"type\":\"record\",\"name\":\"Person\",\"fields\":["
    + "{\"name\":\"name\",\"type\":\"string\"},"
    + "{\"name\":\"age\",\"type\":\"int\"},"
    + "{\"name\":\"income\",\"type\":\"double\"}"
    + "]}";

执行日志:

复制代码
[BACKWARD-FAIL] Registering V1 schema...
[BACKWARD-FAIL] V1 registered successfully
[BACKWARD-FAIL] Sending V2 (add field WITHOUT default) - should FAIL...
[BACKWARD-FAIL] REJECTED as expected! Error: AWSSchemaRegistryException: Schema evolution check failed.
    schemaVersionId 68bd4a73-81a6-450e-b1a5-0b319abeb867 is in FAILURE status.

GSR 在 schema 注册阶段就拦截了不兼容的变更,生产者无法将数据写入 Kafka,从源头防止数据污染。

AVRO 格式:FORWARD 兼容性校验

FORWARD 兼容意味着旧版本消费者能读取新版本数据。删除字段时,旧 schema 中该字段必须有 default 值,否则旧消费者读新数据时无法填充缺失字段。

复制代码
V2 数据: {name: "Bob", age: 25}  (删除了 email)
V1 消费者读取:
  - email 有 default:"unknown" → {name: "Bob", age: 25, email: "unknown"}

V2 数据: {age: 25, email: "bob@test.com"}  (删除了 name)
V1 消费者读取:
  - name 无 default → 无法构造完整记录
删除带 default 的字段 → 允许
java 复制代码
// V1: name + age + email(default:"unknown")
private static final String SCHEMA_V1_WITH_EMAIL = "{"
    + "\"type\":\"record\",\"name\":\"PersonFwd\",\"fields\":["
    + "{\"name\":\"name\",\"type\":\"string\"},"
    + "{\"name\":\"age\",\"type\":\"int\"},"
    + "{\"name\":\"email\",\"type\":\"string\",\"default\":\"unknown\"}"
    + "]}";

// V2: 删除 email
private static final String SCHEMA_V2_FORWARD_OK = "{"
    + "\"type\":\"record\",\"name\":\"PersonFwd\",\"fields\":["
    + "{\"name\":\"name\",\"type\":\"string\"},"
    + "{\"name\":\"age\",\"type\":\"int\"}"
    + "]}";

执行日志:

复制代码
[FORWARD-OK] Registering V1 schema (name+age+email)...
[FORWARD-OK] V1 registered successfully
[FORWARD-OK] Sending V2 (remove email which has default) - should PASS...
[FORWARD-OK] V2 PASSED! Removing field with default is FORWARD compatible.
删除不带 default 的字段 → 拒绝
java 复制代码
// V2: 删除 name(V1 中 name 无 default)
private static final String SCHEMA_V2_FORWARD_FAIL = "{"
    + "\"type\":\"record\",\"name\":\"PersonFwd\",\"fields\":["
    + "{\"name\":\"age\",\"type\":\"int\"},"
    + "{\"name\":\"email\",\"type\":\"string\",\"default\":\"unknown\"}"
    + "]}";

执行日志:

复制代码
[FORWARD-FAIL] Registering V1 schema (name+age+email)...
[FORWARD-FAIL] V1 registered successfully
[FORWARD-FAIL] Sending V2 (remove name which has NO default) - should FAIL...
[FORWARD-FAIL] REJECTED as expected! Error: AWSSchemaRegistryException: Schema evolution check failed.
    schemaVersionId 590e02a9-216c-4fb0-9a54-696409cf5e41 is in FAILURE status.

FORWARD 模式保护了旧消费者不受新 schema 变更的影响。

兼容性校验总结

兼容性模式 允许的操作 拒绝的操作 保护对象
BACKWARD 添加带 default 的字段 添加不带 default 的字段 新消费者读旧数据
FORWARD 删除带 default 的字段 删除不带 default 的字段 旧消费者读新数据
FULL 两者都满足 两者都检查 双向兼容

AVRO 中 default 的作用:

场景 default 的角色
BACKWARD + 加字段 新消费者读旧数据时,用 default 填充缺失的新字段
FORWARD + 删字段 旧消费者读新数据时,用 default 填充缺失的旧字段
FULL 所有新增/删除的字段都必须有 default

这就是为什么 AVRO 的兼容性规则比 JSON Schema 更严格,AVRO 是强类型二进制格式,必须能构造出完整的记录对象,而 JSON 可以简单地忽略未知字段或返回 null。

参考资料

相关推荐
XD7429716362 小时前
科技晚报|2026年5月12日:Claude 进 AWS,AI 落地拼控制面
人工智能·科技·aws·科技新闻·科技晚报
yyuuuzz2 小时前
企业出海aws运维常见问题梳理
运维·服务器·网络·数据库·aws
wanhengidc2 小时前
云手机 云计算科技运行
科技·智能手机·云计算
TG_yunshuguoji2 小时前
阿里云代理商:企业级AI应用 阿里云GPU服务器运行DeepSeek V4优化方案
人工智能·阿里云·云计算·ai智能体·deepseek-v4
阿乔外贸日记3 小时前
以色列电商市场现状:规模、机遇与挑战
大数据·人工智能·智能手机·云计算·汽车
亚马逊云开发者3 小时前
亚马逊云科技发布 Quick 桌面端——能读本地文件、连 MCP、还会主动提醒的 AI 助手
aws
亚马逊云开发者3 小时前
DevOps Agent 接入实操:手把手配置 AI 自动排障,从告警到修复方案
aws
easy_coder16 小时前
云产品诊断架构设计:路由 + 分层加载方案实践
人工智能·架构·云计算
Geoking.19 小时前
云计算服务模型详解:SaaS、PaaS 与 IaaS 的区别、发展历史与应用场景
云原生·云计算·paas