---
title: "Log de Eventos como Fonte da Verdade Transforma Evolução de Schema em um Problema Eterno"
url: https://tiarebalbi.com/pt-br/blog/event-log-schema-evolution-forever-problem
markdown: https://tiarebalbi.com/pt-br/blog/event-log-schema-evolution-forever-problem.md
description: "Quando o log de eventos é autoritativo, o modo BACKWARD padrão do Schema Registry corrompe eventos antigos. Reprodução em Kotlin/Avro mais armadilhas do Protobuf."
author: "Tiarê Balbi Bonamini"
locale: pt-br
published: 2026-05-05
updated: 2026-05-05
category: "Engineering"
tags: ["kafka", "avro", "protobuf", "schema-registry", "event-sourcing", "kotlin", "schema-evolution"]
translation: https://tiarebalbi.com/en/blog/event-log-schema-evolution-forever-problem
---
# Log de Eventos como Fonte da Verdade Transforma Evolução de Schema em um Problema Eterno

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?](https://www.infoq.com/presentations/patterns-payment-system/) 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.

​

![](https://rso2zax703psuq0y.public.blob.vercel-storage.com/postscontent/1776997401273-qhifwuvxc.jpeg)

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](https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html)).

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](https://protobuf.dev/programming-guides/proto3/#updating) 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](https://martinfowler.com/eaaDev/EventSourcing.html) 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.
