Schema Registry y Avro/Protobuf en Kafka con Spring Boot

1. Descripción general

Cuando múltiples servicios leen y escriben en el mismo topic de Kafka, el riesgo más grande es el formato del mensaje. Basta con que un productor cambie el nombre de un campo en JSON para que los consumidores posteriores se rompan inmediatamente. Confluent Schema Registry resuelve este problema. Almacena esquemas de forma centralizada, vincula cada mensaje con un ID de esquema y bloquea los cambios que rompen la compatibilidad.

Este artículo te guía en la configuración de Schema Registry, envío y recepción de mensajes usando Avro y Protobuf en Spring Boot 3.x, configuración de compatibilidad, y escritura de pruebas sin necesidad de un Kafka real.

2. Cómo funciona Schema Registry

Schema Registry es un servicio REST independiente, junto al cluster de Kafka. Internamente, almacena esquemas en un topic llamado _schemas. Cada esquema pertenece a un subject. Por defecto (TopicNameStrategy), el subject del valor en el topic orders se llama orders-value.

Cuando un productor envía un mensaje, el serializador realiza estos pasos:

  1. Registra u obtiene el esquema de Registry para conseguir el ID del esquema. El resultado se cachea.
  2. Escribe el payload según el formato de cable de Confluent:
| byte 0: magic byte (0) | byte 1-4: schema ID (int) | byte 5..: datos binarios de Avro/Protobuf |

El consumidor lee los primeros 5 bytes, obtiene el ID del esquema, carga el esquema una sola vez y lo cachea, luego decodifica el payload. El mensaje no necesita llevar el esquema completo, por lo que el tamaño es mucho menor que JSON.

3. Configurar el entorno local

Usa Docker Compose con Kafka en modo KRaft y Schema Registry:

services:
  kafka:
    image: confluentinc/cp-kafka:7.7.1
    ports: ["9092:9092"]
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093
      KAFKA_LISTENERS: PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,EXTERNAL://0.0.0.0:9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,EXTERNAL://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk

  schema-registry:
    image: confluentinc/cp-schema-registry:7.7.1
    depends_on: [kafka]
    ports: ["8081:8081"]
    environment:
      SCHEMA_REGISTRY_HOST_NAME: schema-registry
      SCHEMA_REGISTRY_KAFKA_STORE_BOOTSTRAP_SERVERS: kafka:29092
      SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081

Después de ejecutar docker compose up -d, verifica Registry:

curl -s http://localhost:8081/subjects
[]

El resultado es una lista vacía porque aún no hay subjects registrados.

4. Avro con Spring Boot

4.1. Dependencia y plugin

Los serializadores de Confluent no están en Maven Central. Necesitas declarar el repositorio de Confluent:

<repositories>
    <repository>
        <id>confluent</id>
        <url>https://packages.confluent.io/maven/</url>
    </repository>
</repositories>

<dependencies>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>
    <dependency>
        <groupId>io.confluent</groupId>
        <artifactId>kafka-avro-serializer</artifactId>
        <version>7.7.1</version>
    </dependency>
    <dependency>
        <groupId>org.apache.avro</groupId>
        <artifactId>avro</artifactId>
        <version>1.12.0</version>
    </dependency>
</dependencies>

El plugin avro-maven-plugin genera clases Java a partir de archivos .avsc:

<plugin>
    <groupId>org.apache.avro</groupId>
    <artifactId>avro-maven-plugin</artifactId>
    <version>1.12.0</version>
    <executions>
        <execution>
            <phase>generate-sources</phase>
            <goals><goal>schema</goal></goals>
            <configuration>
                <sourceDirectory>${project.basedir}/src/main/avro</sourceDirectory>
                <outputDirectory>${project.build.directory}/generated-sources/avro</outputDirectory>
                <stringType>String</stringType>
            </configuration>
        </execution>
    </executions>
</plugin>

La opción stringType=String hace que los campos de cadena se generen como java.lang.String en lugar de CharSequence. De este modo, las comparaciones en código y pruebas son más simples.

4.2. Definición del esquema

Crea el archivo src/main/avro/Order.avsc:

{
  "type": "record",
  "name": "Order",
  "namespace": "dev.nonla.order.avro",
  "fields": [
    { "name": "id", "type": "string" },
    { "name": "customerId", "type": "string" },
    { "name": "amount", "type": "double" }
  ]
}

Ejecuta mvn generate-sources. El plugin generará la clase dev.nonla.order.avro.Order, incluido un builder y esquema incrustado en la clase.

4.3. Configurar productor y consumidor

spring:
  kafka:
    bootstrap-servers: localhost:9092
    properties:
      schema.registry.url: http://localhost:8081
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
    consumer:
      group-id: shipping-service
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer
      properties:
        specific.avro.reader: true

La propiedad schema.registry.url se establece bajo spring.kafka.properties para que se aplique a productor y consumidor. La propiedad specific.avro.reader: true es muy importante. Sin ella, el consumidor recibirá GenericRecord en lugar de la clase Order generada.

4.4. Enviar y recibir mensajes

@Service
public class OrderPublisher {

    private final KafkaTemplate<String, Order> kafkaTemplate;

    public OrderPublisher(KafkaTemplate<String, Order> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publish(String orderId, String customerId, double amount) {
        Order order = Order.newBuilder()
                .setId(orderId)
                .setCustomerId(customerId)
                .setAmount(amount)
                .build();
        kafkaTemplate.send("orders", order.getId(), order);
    }
}
@Component
public class ShippingListener {

    private static final Logger log = LoggerFactory.getLogger(ShippingListener.class);

    @KafkaListener(topics = "orders")
    public void onOrder(Order order) {
        log.info("Preparando envío del pedido {} para el cliente {}, monto {}",
                order.getId(), order.getCustomerId(), order.getAmount());
    }
}

En el primer envío, KafkaAvroSerializer registra automáticamente el esquema en el subject orders-value. Puedes verificarlo:

curl -s http://localhost:8081/subjects/orders-value/versions/latest
{"subject":"orders-value","version":1,"id":1,"schema":"{\"type\":\"record\",\"name\":\"Order\",...}"}

Ahora el mensaje en el topic solo lleva el ID del esquema 1, no el esquema completo.

5. Protobuf con Spring Boot

Los pasos con Protobuf son casi idénticos a Avro, solo difieren el serializador y la forma de generar código. Añade esta dependencia:

<dependency>
    <groupId>io.confluent</groupId>
    <artifactId>kafka-protobuf-serializer</artifactId>
    <version>7.7.1</version>
</dependency>

Define src/main/proto/order_event.proto:

syntax = "proto3";

package dev.nonla.order;

option java_package = "dev.nonla.order.proto";
option java_multiple_files = true;

message OrderEvent {
  string id = 1;
  string customer_id = 2;
  double amount = 3;
}

Genera la clase Java usando un plugin protoc para Maven, por ejemplo io.github.ascopes:protobuf-maven-plugin. Luego configura el serializador:

spring:
  kafka:
    producer:
      value-serializer: io.confluent.kafka.serializers.protobuf.KafkaProtobufSerializer
    consumer:
      value-deserializer: io.confluent.kafka.serializers.protobuf.KafkaProtobufDeserializer
      properties:
        specific.protobuf.value.type: dev.nonla.order.proto.OrderEvent

La propiedad specific.protobuf.value.type le dice al deserializador la clase destino. Sin ella, recibirás un DynamicMessage.

¿Cuál elegir?

  • Avro se ajusta bien al ecosistema de Kafka y pipelines de datos como Kafka Connect o ksqlDB. El esquema es JSON, las reglas de evolución se basan en nombres de campos y valores por defecto.
  • Protobuf es mejor si tu organización ya usa gRPC. Puedes reutilizar archivos .proto existentes. Las reglas de evolución se basan en números de orden de campos.

6. Evolución del esquema y compatibilidad

6.1. Niveles de compatibilidad

Cada subject tiene un nivel de compatibilidad. El nivel por defecto es BACKWARD.

Nivel Significado Orden seguro de deployment
BACKWARD El nuevo esquema puede leer datos antiguos Consumidor primero, productor después
FORWARD El esquema antiguo puede leer datos nuevos Productor primero, consumidor después
FULL Cumple ambas direcciones Cualquier orden funciona
*_TRANSITIVE Verifica con todas las versiones antiguas, no solo la anterior Como arriba

Establece el nivel para cada subject mediante la API REST:

curl -s -X PUT -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"compatibility": "FULL_TRANSITIVE"}' \
  http://localhost:8081/config/orders-value

6.2. Un cambio válido

Supongamos que quieres agregar el campo currency a Order. Con Avro, el nuevo campo debe tener un valor por defecto para que el nuevo esquema alcance FULL:

{ "name": "currency", "type": "string", "default": "VND" }

Antes de hacer merge del código, debes verificar el nuevo esquema con Registry:

curl -s -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"schema": "{\"type\":\"record\",\"name\":\"Order\",\"namespace\":\"dev.nonla.order.avro\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"customerId\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"double\"},{\"name\":\"currency\",\"type\":\"string\",\"default\":\"VND\"}]}"}' \
  http://localhost:8081/compatibility/subjects/orders-value/versions/latest
{"is_compatible":true}

Si omites "default": "VND", el resultado será false. Cuando intentes registrar el esquema, el productor recibirá un error 409 Conflict.

Con Protobuf, la regla correspondiente es: nunca cambies o reutilices números de orden de campos. Cuando elimines un campo, márcalo como reserved para que nadie reutilice ese número:

message OrderEvent {
  reserved 2;
  reserved "customer_id";
  string id = 1;
  double amount = 3;
  string currency = 4;
}

7. Escribir pruebas con Schema Registry simulado

El serializador de Confluent soporta URLs del tipo mock://<scope>. Con esta URL, el serializador usa un Registry simulado en memoria. Puedes probar serialización sin Docker:

import static org.assertj.core.api.Assertions.assertThat;

class OrderAvroSerdeTest {

    private static final Map<String, Object> CONFIG = Map.of(
            "schema.registry.url", "mock://order-serde-test",
            "specific.avro.reader", true);

    @Test
    void shouldSerializeWithWireFormatAndDeserializeBack() {
        try (var serializer = new KafkaAvroSerializer();
             var deserializer = new KafkaAvroDeserializer()) {
            serializer.configure(CONFIG, false);
            deserializer.configure(CONFIG, false);

            Order order = Order.newBuilder()
                    .setId("ORD-1001")
                    .setCustomerId("CUS-42")
                    .setAmount(250_000.0)
                    .build();

            byte[] bytes = serializer.serialize("orders", order);

            assertThat(bytes[0]).isZero();                       // magic byte
            assertThat(ByteBuffer.wrap(bytes, 1, 4).getInt()).isPositive(); // schema ID

            Object result = deserializer.deserialize("orders", bytes);
            assertThat(result).isInstanceOf(Order.class).isEqualTo(order);
        }
    }
}

Esta prueba verifica tres cosas:

  • El primer byte es el magic byte 0.
  • Los siguientes cuatro bytes son un ID de esquema válido.
  • El deserializador devuelve la clase exacta Order, no GenericRecord. Esto confirma que specific.avro.reader está en efecto.

Para una prueba de integración completa, puedes usar Testcontainers para ejecutar Kafka junto con Schema Registry. Sin embargo, las pruebas con mock:// se ejecutan rápidamente y son suficientes para detectar la mayoría de errores de configuración.

Nota: Los serializadores que usan el mismo scope mock:// compartirán un Registry simulado. Cada clase de prueba debería usar un scope diferente para evitar interferencias.

8. Errores comunes y cuándo no usar

Poison pill. Un mensaje que no cumple el formato de cable hará que KafkaAvroDeserializer lance SerializationException antes de llegar al listener. El consumidor puede quedar atrapado en ese offset para siempre. Debes envolver el deserializador con ErrorHandlingDeserializer:

spring:
  kafka:
    consumer:
      value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
      properties:
        spring.deserializer.value.delegate.class: io.confluent.kafka.serializers.KafkaAvroDeserializer

Registrar esquemas automáticamente en producción. Por defecto, auto.register.schemas=true, es decir, cualquier productor puede registrar un nuevo esquema. En producción, debes establecer auto.register.schemas: false y use.latest.version: true. El esquema solo debe registrarse a través del pipeline CI/CD, después de verificar compatibilidad.

Recibir GenericRecord o DynamicMessage sin esperarlo. La causa suele ser olvidar specific.avro.reader o specific.protobuf.value.type. La consecuencia es un ClassCastException justo en el listener.

Cambiar el namespace o nombre del record en Avro. Con Avro, esto es un esquema completamente diferente. El specific reader no encontrará la clase correspondiente. Considera el namespace como una parte inmutable del contrato.

Establecer compatibilidad en NONE por conveniencia. Hacerlo elimina todos los beneficios de Registry. Usa NONE solo en topics de prueba.

Nota: Schema Registry es una dependencia de runtime. El serializador cachea esquemas, por lo que un Registry caído temporalmente rara vez afecta esquemas ya conocidos. Pero un nuevo productor que arranca, o un esquema nuevo que no está en caché, fallará inmediatamente. Ejecuta Registry en modo multiinstancia en producción.

Cuándo no usar Schema Registry:

  • Sistema pequeño, un solo equipo es dueño de productor y consumidor, pocos cambios. En este caso, JSON con JsonSerializer de Spring es mucho más simple.
  • Los mensajes deben ser legibles directamente por clientes que no entienden el formato de cable de Confluent, como una herramienta de terceros que solo lee JSON puro.
  • No puedes operar un servicio de infraestructura adicional y tampoco tienes acceso a un Registry gestionado.

9. Conclusión

Schema Registry transforma el formato de mensajes de Kafka en un contrato con versiones y verificaciones automáticas. Ya has visto cómo:

  • Configurar Registry con Docker.
  • Enviar y recibir mensajes usando KafkaAvroSerializer y KafkaProtobufSerializer en Spring Boot 3.
  • Entender el formato de cable: magic byte e ID de esquema.
  • Establecer niveles de compatibilidad y evolucionar esquemas de forma segura: campos nuevos con valor por defecto en Avro, reserved en Protobuf.
  • Escribir pruebas rápidas con un Registry mock://.

Al aplicar en producción, desactiva el registro automático de esquemas, incluye la verificación de compatibilidad en CI, y siempre envuelve el deserializador con ErrorHandlingDeserializer. De esta manera, un cambio de esquema incorrecto se detectará durante la revisión, en lugar de romper los consumidores a la mitad de la noche.

kafkaschema-registryavroprotobufspring-boot