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.
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.

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:
@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(ouFULL_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
aliasesno 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
reservedtanto 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.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
DBOS vs Temporal: Quando o Postgres É Suficiente para Execução Durável de Workflows
O DBOS reutiliza o Postgres como camada de durabilidade para workflows, enquanto o Temporal roda um cluster dedicado. A escolha certa depende do tamanho do time, da forma do workload e de onde você quer que seu orçamento operacional vá. Este é um critério prático para escolher entre eles.
Avaliação de Memória: Medindo Como a Memória de IA se Degrada ao Longo da Vida de um Projeto
A maioria dos benchmarks de memória de IA avalia recall e para por aí. Isso esconde o modo de falha real: fatos desatualizados envenenando silenciosamente a janela de contexto. Aqui está um framework de avaliação baseado em ciclo de vida que testa recall, revisão e esquecimento controlado em todos os pontos de mudança pelos quais um projeto de longa duração passa.
O Transactional Outbox Não É uma Fila
O transactional outbox é um ledger, não uma fila. Tratá-lo como fila é o que quebra o Postgres sob carga. Este post percorre os modos de falha específicos — autovacuum travando, drift do horizonte xmin, lag do replication slot, poison pills — e as regras operacionais que realmente o mantêm funcionando em produção.