Pular para o conteúdo
Vailati
Voltar

CDC ou outbox: por que escolhi o outbox para publicar eventos

Os dois garantem que o evento sai se, e só se, a transação do banco foi gravada. A diferença está no que sai, em quem define o contrato e no que você passa a operar.

Contexto

Eu precisava que cada operação do ledger de pontos gerasse um evento no Kafka sem risco de gravar no banco e não publicar, ou publicar e não gravar. Avaliei dois caminhos: Change Data Capture com Debezium lendo o log de replicação do PostgreSQL, que chegou a virar uma prova de conceito, e o padrão outbox, uma tabela gravada na mesma transação da operação e drenada por um job. Escolhi o outbox. O custo foi código na aplicação, um job por minuto e uma tabela com alta rotatividade; o ganho foi um contrato de evento definido pelo domínio, sem acoplar consumidores ao esquema das tabelas e sem um Kafka Connect para operar.

Este artigo não é sobre qual padrão é melhor. É sobre quais perguntas decidiram, no meu caso, e como responder a elas no seu.

Problema

O problema de fundo é o mesmo nos dois padrões: escrita dupla. Um serviço que grava no banco e depois chama o Kafka tem dois pontos de falha que não se desfazem juntos. Se o commit passa e o produce falha, o evento some. Se o produce passa e o commit falha, o mundo fica sabendo de algo que não aconteceu. Retentativa não resolve: resolve só um dos lados.

As duas soluções tiram o Kafka do caminho da transação. No CDC, a aplicação só grava no banco; um conector lê o log de transações do PostgreSQL, o WAL, por meio de replicação lógica, e transforma cada linha alterada num evento. No outbox, a aplicação grava, na mesma transação, a linha de negócio e uma linha com o evento já montado; um processo separado lê essa tabela e publica.

A escolha entre eles raramente é sobre garantia de entrega, que as duas dão. É sobre três coisas: o que é o evento, quem controla o esquema e o que entra na operação.

O que medi

Aqui o balanço é desigual, e vale ser explícito: o CDC foi uma prova de conceito de infraestrutura; o outbox roda em produção.

A prova de conceito de CDC. Um Kafka Connect com o conector PostgreSQL do Debezium, plugin pgoutput, PostgreSQL com wal_level=logical, capturando uma tabela de créditos. O conector ignorava updates, deletes e truncates, publicava só inserts, aplicava três transformações: extrair o estado novo da linha, usar o número da conta como chave da mensagem e rotear tudo para um tópico fixo. Funcionou em minutos: cada INSERT virou uma mensagem com as colunas da tabela como campos.

O que a prova não mostrou foi um consumidor. A aplicação do mesmo repositório nunca leu o tópico gerado pelo conector; os eventos que ela consumia e produzia continuaram sendo mensagens montadas em código. A prova provou que o encanamento funciona, e parou aí. Isso é um resultado: o encanamento nunca foi a dúvida.

O outbox em produção. Três tabelas de outbox, uma por família de evento. O volume em um dia típico é de pouco mais de um milhão de linhas gravadas e apagadas só na principal. Cada job roda a cada minuto, lê um lote de duas mil linhas em ordem de id, publica com produtor idempotente e acks=all, usando o id da conta como chave de partição, e apaga o lote inteiro só se todas as entregas foram confirmadas. A latência entre o commit e a mensagem no Kafka é de até um minuto. O evento publicado é o mesmo JSON gravado na linha, byte a byte, com mais de cem campos: a operação completa, os itens e os dados da conta que o consumidor precisa.

A tabela de outbox é insert e delete puros, e isso tem um custo que eu só vi depois: muitas tuplas mortas por dia, autovacuum frequente, e índices que crescem mais do que a tabela. Volto a isso no fim.

Solução

A decisão veio de quatro diferenças concretas. Coloco cada uma com o que o CDC daria e o que o outbox dá.

1. O que é o evento. No CDC, o evento é a linha. Uma operação de negócio no ledger toca três tabelas: a entrada, os créditos e os débitos. O Debezium emitiria três fluxos de mudanças, um por tabela, cada um com as colunas daquela tabela, e o consumidor teria de juntar as peças para entender o que aconteceu. Dá para configurar o conector para emitir metadados de transação e agrupar, mas a reconstrução fica do lado de quem consome. No outbox, o evento é o que o domínio quer dizer: “esta operação foi processada, com estes itens, para esta conta”. Uma mensagem, um contrato. Os consumidores do meu caso são um lake, um pipeline de streaming e um parceiro de pagamentos. Nenhum deles quer saber que existem três tabelas.

2. Quem é dono do esquema. No CDC, o contrato do evento é o esquema da tabela. Renomear uma coluna, trocar um tipo, mover um campo para outra tabela: tudo vira mudança de contrato com terceiros, e o banco vira API pública. Há mitigação, com transformações no conector e registro de esquemas, mas é mitigação. No outbox, o esquema da tabela muda à vontade; o contrato é a classe que serializa o evento, versionada no código, com teste. Para um ledger que ainda evolui o modelo de dados, isso pesou mais do que tudo.

3. Tabelas particionadas. As três tabelas do ledger são particionadas em dois níveis, e novas partições nascem com o tempo. A replicação lógica do PostgreSQL publica mudanças por partição folha, a menos que a publicação seja configurada para publicar pela raiz, e o conector precisa saber quais tabelas capturar. Cada partição nova é um item a mais na configuração do conector, ou uma expressão regular que precisa continuar certa. Não é intransponível, mas é mais um lugar para esquecer na véspera do ano novo. O outbox é uma tabela simples, sem partição, e não sabe nada sobre as outras.

4. O que entra na operação. CDC traz um Kafka Connect para rodar, monitorar e atualizar, um slot de replicação no PostgreSQL e uma consequência que assusta quem já viu: se o conector para, o slot segura o WAL, o WAL cresce até encher o disco, e o banco para junto. Em serviço gerenciado isso é um alerta a configurar e uma rotina a ter. O outbox traz um job no mesmo worker que já existia, uma tabela a mais e a vigilância sobre ela: lote que não escoa, linha presa, autovacuum. Tudo dentro do que o time já operava.

Com as quatro na mesa, a resposta ficou óbvia para o meu contexto. Eu quero publicar eventos de domínio com contrato próprio, a partir de tabelas particionadas, com um time que opera PostgreSQL e Kafka mas não opera Kafka Connect. Isso é outbox.

flowchart TD
    Q1{O evento é a linha<br/>ou é o fato de negócio?} -->|linha, 1 tabela,<br/>consumidor interno| CDC[CDC: Debezium lê o WAL]
    Q1 -->|fato de negócio,<br/>várias tabelas, contrato externo| Q2{Quem opera<br/>Kafka Connect?}
    Q2 -->|ninguém ainda| OUT[Outbox: tabela + job]
    Q2 -->|já existe| HYB[Híbrido: outbox + Debezium<br/>Outbox Event Router]

Onde o CDC ganharia

Para ser justo com o caminho que não escolhi. Se o evento fosse mesmo a linha, uma tabela de auditoria ou de histórico, o CDC entrega sem uma linha de código na aplicação e captura também o que a aplicação não fez: um UPDATE manual na madrugada, uma carga em lote, uma correção de script. O outbox só sabe do que passou pelo código. Se eu precisasse de latência de milissegundos em vez de até um minuto, o CDC também ganharia; o outbox com polling tem o intervalo do job como piso, e trocar polling por notificação é trabalho a mais.

E há um terceiro caminho que une os dois: a aplicação grava o outbox, como hoje, e o Debezium lê a tabela de outbox pelo WAL em vez de um job fazer polling. O Debezium tem um componente pronto para isso, o Outbox Event Router. O contrato continua do domínio; some o job, some o DELETE, some a latência do minuto. O preço é o Kafka Connect, de volta. Eu não escolhi isso na época porque a razão número quatro pesava; se o time passar a operar Connect por outro motivo, é a primeira coisa que eu revisitaria.

O que faria diferente

Eu teria dado um critério de parada para a prova de conceito. Ela provou que o Debezium publica inserts, o que ninguém duvidava, e não provou o que importava: um consumidor real reconstruindo um evento de negócio a partir de três tabelas e dizendo quanto isso custou. Prova de conceito sem a pergunta certa produz um repositório que ninguém consome.

Eu teria pensado no ciclo de vida da tabela de outbox no dia um. Insert e delete de mais de um milhão de linhas por dia é um padrão que o autovacuum do PostgreSQL atende, mas não de graça: tuplas mortas, índices inchados e um REINDEX que acaba entrando na rotina. A alternativa que eu avaliaria hoje é marcar como publicado em vez de apagar, e limpar por faixa de tempo, ou particionar o outbox por dia e descartar partições inteiras.

Eu documentaria a decisão com estas quatro perguntas, na época. A escolha foi correta, mas o registro dela era “usamos outbox”. Dois anos depois, quando alguém propõe CDC de novo, a resposta precisa ser reconstruída. Este artigo é, em parte, essa reconstrução.

Referências



Compartilhe este artigo:

Artigo anterior
Trace distribuído que atravessa o outbox e a fila em memória