Schema e contratos — Avro e Schema Registry

TL;DR

Sem schema explícito, producer e consumer compartilham um contrato invisível: qualquer renomeação de campo quebra o consumer em produção sem aviso. O Confluent Schema Registry resolve isso centralizando o registro de schemas (Avro, Protobuf ou JSON Schema), atribuindo um schema_id único a cada versão e impondo políticas de compatibilidade (BACKWARD, FORWARD, FULL) que protegem o pipeline antes do deploy.


O que é

O Confluent Schema Registry é um serviço REST que armazena, versiona e valida schemas para mensagens Kafka. Cada schema registrado recebe um ID numérico único e globalmente crescente. Na produção de uma mensagem, o KafkaAvroSerializer não envia o schema inteiro: envia apenas 1 byte mágico (0x00) + 4 bytes com o schema_id + o payload Avro binário. O consumer, ao receber, lê o ID, consulta o registry e usa o schema para desserializar.

O registry suporta três formatos de serialização:

  • Apache Avro — binário compacto, schema em JSON (.avsc)
  • Protocol Buffers (Protobuf) — IDL própria, geração de código
  • JSON Schema (JSON_SR) — validação de documentos JSON

Por que importa

Em pipelines Kafka, producer e consumer são desacoplados no tempo: o consumer pode estar offline quando a mensagem é produzida, e pode processar a mensagem horas ou dias depois. Se o contrato for implícito (classe Java ou dicionário JSON compartilhado via convenção), qualquer mudança de campo — renomear, trocar tipo, remover — quebra silenciosamente o consumer.

O Schema Registry transforma esse contrato implícito em um contrato explícito, versionado e validado na borda de produção. Um producer que tenta registrar um schema incompatível com a política configurada recebe erro imediatamente, antes de qualquer mensagem chegar ao tópico.


Como funciona

Por que schema explícito

Producer e consumer num sistema de mensageria são independentes: não há chamada direta, não há compilação conjunta, não há garantia de deploy simultâneo. O JSON “funciona” até o dia em que alguém renomeia userId para user_id — o campo simplesmente some para o consumer sem exceção, apenas null.

Avro e outros formatos baseados em schema invertem isso: o schema é o contrato, e qualquer violação é detectada no registro — não no consumer em produção às 3h da manhã.

Avro + Schema Registry

O Avro define schemas em arquivos .avsc (JSON). Um schema de record tem:

{
  "type": "record",
  "namespace": "br.com.exemplo.mensageria",
  "name": "PedidoCriado",
  "fields": [
    { "name": "pedidoId", "type": "string" },
    { "name": "clienteId", "type": "string" },
    { "name": "valorTotal", "type": "double" },
    { "name": "status", "type": "string", "default": "PENDENTE" }
  ]
}

Cada schema é registrado num subject. Por padrão (estratégia TopicNameStrategy), o subject de um tópico pedidos será pedidos-value para o value e pedidos-key para a key. O registry mantém versões: v1, v2, v3… e cada versão tem um ID único.

O wire format de uma mensagem Avro no Kafka:

[ 0x00 ] [ schema_id: 4 bytes big-endian ] [ payload Avro binário ]

O consumer lê o ID, busca o schema no registry e desserializa. O schema não trafega na mensagem — só o ID.

Compatibilidade

O registry impõe uma política de compatibilidade por subject (configurável também globalmente):

PolíticaO que garanteOperações seguras
BACKWARD (padrão)Consumer com schema novo lê dados antigosAdicionar campo com default; remover campo opcional
FORWARDConsumer com schema antigo lê dados novosRemover campo com default; adicionar campo opcional
FULLAmbas ao mesmo tempoSó mudanças que satisfazem as duas
NONESem verificaçãoQualquer mudança
BACKWARD_ALL / FORWARD_ALL / FULL_ALLVariantes transitivas: compara contra todas as versões anteriores, não só a últimaMais restritivo

BACKWARD é o padrão porque o padrão de deploy mais seguro é atualizar consumers antes de producers: o consumer novo precisa conseguir ler mensagens antigas que ainda estão no tópico.


Na prática

Dependência Maven (Spring Boot 3.x + Confluent)

<dependency>
    <groupId>io.confluent</groupId>
    <artifactId>kafka-avro-serializer</artifactId>
    <version>7.6.0</version>
</dependency>

Configuração do producer

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: io.confluent.kafka.serializers.KafkaAvroSerializer
    properties:
      schema.registry.url: http://localhost:8081
      auto.register.schemas: true          # dev only; false em produção
      use.latest.version: false

Configuração do consumer

spring:
  kafka:
    consumer:
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: io.confluent.kafka.serializers.KafkaAvroDeserializer
    properties:
      schema.registry.url: http://localhost:8081
      specific.avro.reader: true           # usa classe gerada, não GenericRecord

REST API básica do registry

# Listar todos os subjects registrados
GET /subjects
 
# Listar versões de um subject
GET /subjects/pedidos-value/versions
 
# Registrar novo schema
POST /subjects/pedidos-value/versions
Content-Type: application/vnd.schemaregistry.v1+json
{ "schema": "{...avsc como string escapada...}" }
 
# Testar compatibilidade antes de registrar
POST /compatibility/subjects/pedidos-value/versions/latest

Protobuf e JSON Schema como alternativas

O registry suporta os três formatos com a mesma lógica de subjects/versions/compatibilidade. A escolha é de trade-off:

  • Avro — binário compacto, schema externo obrigatório, maduro no ecossistema Kafka/Hadoop
  • Protobuf — IDL própria, geração de código mais tipada, usado em gRPC
  • JSON Schema — mantém legibilidade JSON, útil quando o consumer não é JVM

Armadilhas

(1) Mudança incompatível quebrando consumers em produção

Renomear um campo (userIduser_id) ou trocar o tipo (intstring) sem política de compatibilidade configurada, ou com política NONE, faz o registry aceitar o novo schema. O producer passa a enviar dados no novo formato. Consumers antigos — que ainda estão rodando, processando o backlog — tentam desserializar com o schema antigo e encontram dados que não batem. O resultado é exceção de desserialização em massa ou, pior, leitura silenciosa de null em campos críticos.

Mitigação: configurar política BACKWARD ou FULL no subject antes do primeiro deploy; nunca remover campos sem default; tratar renomeação como remoção + adição.

(2) Sem registry — contrato implícito no código

Times que não adotam o registry tendem a compartilhar DTOs Java entre producer e consumer via biblioteca interna, ou confiam em “todo mundo sabe que o campo se chama X”. Funciona até a primeira mudança assíncrona de times. Sem um ponto central de validação, a divergência entre schemas é detectada apenas em tempo de execução, muitas vezes apenas em produção.

Mitigação: adotar o registry desde o primeiro tópico; o custo de implantação é baixo (um container), o custo de não ter é alto.

(3) Evoluir schema sem política de compatibilidade definida

Deixar a política como NONE (ou nunca configurar, aceitando o padrão global sem revisar) cria uma falsa sensação de segurança: o registry existe, os schemas estão registrados, mas nada impede um producer de registrar um schema completamente incompatível. O contrato existe no papel, mas não é enforced.

Mitigação: definir explicitamente a política por subject via API ou no provisionamento de infraestrutura; documentar no ADR do time qual política é usada e por quê.


Em entrevista

Frase pronta (inglês)

  • “Schema Registry enforces explicit contracts between producers and consumers at registration time, not at runtime.”
  • “BACKWARD compatibility means a new consumer can read old data — that’s the safe default when you deploy consumers before producers.”
  • “The schema ID in the wire format means only 5 bytes of overhead per message instead of repeating the full schema.”
  • “Without schema governance, any field rename is a silent breaking change waiting to surface in production.”

Vocabulário

TermoSignificado
subjectUnidade de versionamento no registry (ex: pedidos-value)
schema IDInteiro único e crescente atribuído a cada schema registrado
wire formatFormato binário do payload Kafka: magic byte + schema ID + payload
BACKWARD compatibilityConsumer novo consegue ler dados produzidos com schema antigo
FORWARD compatibilityConsumer antigo consegue ler dados produzidos com schema novo
TopicNameStrategyEstratégia padrão: subject = {topic}-value / {topic}-key
specific.avro.readerConfig que faz o consumer usar a classe Java gerada, não GenericRecord

Veja também


Referências