Pular para o conteúdo principal
Todos os Posts
Engineering6 min de leitura

Log de Eventos como Fonte da Verdade Transforma Evolução de Schema em um Problema Eterno

Quando o log é a fonte da verdade, toda mudança de schema é permanente. Um passo a passo em Kotlin/Avro do rename que passou na verificação do Schema Registry e corrompeu silenciosamente todos os eventos antigos, mais os invariantes de Protobuf e Avro que agora mantenho fixados acima da minha mesa.

Tiarê Balbi BonaminiEngenheiro de Software · Vancouver
2/4

Passei as últimas semanas movendo um serviço bancário de brinquedo de "Kafka como barramento" para "Kafka como armazenamento autoritativo". A mudança parecia cosmética no início. Os tópicos já existiam. Os consumidores já construíam estado a partir deles. O que mudou foi a política de retenção: infinita. Cada evento se tornou um artefato permanente. A palestra do InfoQ Event-Driven Patterns for Cloud-Native Banking — What Works, What Hurts? insistia nesse custo. Não apreciei o quão abrangente ele era até executar minha primeira mudança de schema "segura" e perceber que um replay retornava os números errados.

Este é um relato do que encontrei enquanto investigava as regras de resolução do Avro, o formato de fio (wire format) do Protobuf e os modos de compatibilidade do Confluent Schema Registry. A versão curta: "backward compatible" não é uma propriedade única. Uma vez que o log é autoritativo, eu herdo todas as decisões de schema que já tomei, não apenas a última.

A matriz de compatibilidade que acabei desenhando

O problema tem quatro eixos independentes, não dois: versão do schema do produtor, versão do schema do consumidor, janela de retenção e eventos arquivados mais antigos que a retenção atual. O diagrama abaixo é o que mantenho fixado enquanto penso sobre uma mudança.

Log de Eventos como Fonte da Verdade Transforma Evolução de Schema em um Problema Eterno

Com 7 dias de retenção em um tópico Kafka comum e um único par produtor/consumidor, o padrão BACKWARD do Schema Registry parece suficiente. Esse padrão só verifica se o novo schema consegue ler dados escritos pelo último schema. BACKWARD_TRANSITIVE verifica contra todas as versões anteriores.

Quando o log é a fonte da verdade, a retenção é efetivamente infinita, e os eventos arquivados não são um bucket separado — eles são o sistema de registro. Cada mudança precisa ser legível por todo consumidor futuro, até a v1. O modo BACKWARD padrão não é suficiente; o único padrão seguro que encontrei foi BACKWARD_TRANSITIVE, ou FULL_TRANSITIVE se eu também quiser rebobinar produtores. A documentação da Confluent é explícita sobre a diferença: BACKWARD verifica apenas contra a versão imediatamente anterior, enquanto BACKWARD_TRANSITIVE verifica contra todas as versões registradas no subject (Confluent Schema Evolution).

Esse único toggle é o mais importante deste post.

O rename que passou na verificação e corrompeu silenciosamente todos os registros antigos

Aqui está uma evolução Avro aparentemente segura que tentei. Peguei um registro Tx com id: long e amount: int e renomeei amount para amount_cents. Dei ao novo campo um valor default de 0, porque é o que todo tutorial faz para satisfazer o BACKWARD.

O registry disse COMPATIBLE. O produtor foi implantado. Um consumidor executando o novo schema reproduziu o histórico, e toda transação pré-rename voltou com amount_cents = 0.

Aqui está uma reprodução em arquivo único Kotlin contra Avro 1.11:

kotlin
@file:DependsOn("org.apache.avro:avro:1.11.3")

import org.apache.avro.Schema
import org.apache.avro.SchemaCompatibility

fun parse(s: String): Schema = Schema.Parser().parse(s)

val v1 = parse("""
    {"type":"record","name":"Tx","fields":[
      {"name":"id","type":"long"},
      {"name":"amount","type":"int"}
    ]}
""".trimIndent())

val v2NoAlias = parse("""
    {"type":"record","name":"Tx","fields":[
      {"name":"id","type":"long"},
      {"name":"amount_cents","type":"int","default":0}
    ]}
""".trimIndent())

val v2WithAlias = parse("""
    {"type":"record","name":"Tx","fields":[
      {"name":"id","type":"long"},
      {"name":"amount_cents","type":"int","default":0,"aliases":["amount"]}
    ]}
""".trimIndent())

listOf("no alias" to v2NoAlias, "with alias" to v2WithAlias).forEach { (label, reader) ->
    val r = SchemaCompatibility.checkReaderWriterCompatibility(reader, v1)
    println("rename $label -> ${r.type}")
}

Execute com: kotlin rename.main.kts

Ambos os casos imprimem COMPATIBLE. A versão sem alias passa porque o resolvedor do Avro vê duas coisas independentes: o writer tem amount e o reader não tem — descarte; o reader tem amount_cents e o writer não tem — preencha com o default. Nada dá erro; nada é preservado. Adicione aliases: ["amount"] e o resolvedor mapeia o campo antigo para o novo e lê o valor real.

A lição: o veredicto COMPATIBLE do registry é sobre o fio (wire), não sobre a semântica. Para um rename, o alias é quem faz o trabalho real. Pule-o e a verificação se torna um carimbo de borracha sobre perda de dados silenciosa. Em um pipeline estilo barramento com janela de 7 dias, isso se corrigiria sozinho conforme os eventos antigos envelhecem. Em um log autoritativo, é permanente.

Os invariantes do Protobuf parecem similares e não são os mesmos

O Protobuf codifica campos por número de tag, não por nome, então as regras de Updating A Message Type do proto3 desenham uma zona de perigo diferente.

Renames são seguros no fio de graça — a tag é o que importa, então renomear amount para amount_cents produz bytes que consumidores antigos decodificam corretamente. Nenhuma maquinaria de alias necessária. O código-fonte das classes geradas, sim, quebra, o que é um problema diferente.

Reutilização de tag é a armadilha. Se eu deletar um campo e um colega de equipe mais tarde adicionar um campo diferente com o mesmo número de tag, eventos antigos em disco decodificam no novo campo, com o tipo ou significado errado. É exatamente para isso que existe o reserved. Uma vez que eu delete a tag 5, escrevo reserved 5; reserved "amount"; para que nenhum autor futuro possa reutilizar nem o número nem o nome. Um log com retenção infinita significa que a reserva permanece no schema para sempre também.

int32 -> int64 é seguro no fio na direção de ampliação, porque a codificação varint usa o número mínimo de bytes para cada valor. Ir no sentido contrário trunca silenciosamente valores que não cabem mais. Ampliar é uma porta de mão única.

singular -> repeated é definido como compatível na especificação, embora um reader singular recebendo múltiplos valores mantenha apenas o último para primitivos e faça merge para mensagens. Misturar modos no mesmo log funciona para decodificação; eventos mais antigos simplesmente voltam como listas de um elemento.

Mesma classe de falha do Avro, invariantes diferentes. A rede de segurança do Protobuf é número de tag mais reserved. A do Avro é aliases mais defaults. Nenhuma cobre a camada semântica, e apenas uma oferece uma primitiva de rename.

O que mantenho fixado acima da minha mesa

  • Mude o padrão do registry para BACKWARD_TRANSITIVE (ou FULL_TRANSITIVE) em qualquer tópico com retenção maior do que um ciclo de deploy. O BACKWARD padrão é uma garantia de um único deploy; um log autoritativo precisa de uma garantia de todas as versões.
  • Para renames em Avro, sempre adicione aliases no novo campo. Se eu não puder, não é um rename — é um novo campo mais um job de migração.
  • Para Protobuf, todo campo deletado ganha uma entrada reserved tanto para o número da tag quanto para o nome na mesma mudança. Sem exceções.
  • Ampliação de tipo é uma via de mão única. Comprometa-se com o tipo mais amplo da primeira vez, ou planeje um cutover de escrita dupla.
  • Antes que qualquer mudança de schema entre em produção, reproduza uma semana de eventos reais em um consumidor construído a partir do novo schema num ambiente descartável e compare o estado materializado contra o atual. A verificação do registry é necessária, mas não suficiente.

Quando recorrer a esse padrão e quando ficar longe

Um log de eventos autoritativo compensa quando auditabilidade, replay para debugging e modelos de leitura paralelos são preocupações de primeira classe. Ledgers, pagamentos e qualquer sistema cujos invariantes são naturalmente expressos como "o que aconteceu" se encaixam. O ensaio de 2005 Event Sourcing de Martin Fowler foi franco sobre os custos mesmo naquela época — interações com sistemas externos durante replay, lógica temporal que precisa viver no modelo de domínio, e o esforço necessário para reverter um evento. O fardo da evolução de schema é um primo disso.

Se o domínio é majoritariamente CRUD, uma tabela relacional com migrações ordinárias permanece mais simples por anos. Eu só recorro ao log autoritativo quando as propriedades que ele oferece de forma única valem o imposto, e quando faço isso, orçamento para a matriz de compatibilidade de antemão — não na primeira vez que um rename cai num PR.

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.