O pedido é gravado no Postgres e, na linha seguinte, o serviço publica PedidoCriado no broker. Funciona até o dia em que o commit passa e a publicação falha — ou o contrário: o evento sai, a transação sofre rollback e o serviço de faturamento cobra um pedido que não existe. Não há ordem de chamadas que resolva isso sem uma transação distribuída, e ninguém quer 2PC entre banco e broker em produção.
O padrão outbox resolve a primeira metade: o evento é gravado numa tabela do próprio banco, na mesma transação que a mudança de negócio. Ou os dois existem, ou nenhum existe. A segunda metade — tirar o evento da tabela e entregá-lo ao broker — é onde está a decisão. Há dois caminhos maduros: um processo que faz polling na tabela outbox, ou captura de mudanças (CDC) lendo o log de replicação do banco, tipicamente com Debezium.
O ponto de partida comum: a tabela outbox
Qualquer que seja o relay escolhido, a escrita é igual. Uma tabela com identificador, tipo de agregado, id do agregado, tipo do evento, payload e data de criação. A aplicação insere nela dentro da transação de negócio e não fala com o broker.
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
BEGIN;
INSERT INTO pedidos (...) VALUES (...);
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
VALUES (gen_random_uuid(), 'pedido', '8812', 'PedidoCriado', '{...}');
COMMIT;
Duas propriedades valem para os dois caminhos e convém aceitá-las antes de comparar. A entrega é pelo menos uma vez: em algum cenário de falha o mesmo evento sai duas vezes, então o consumidor precisa ser idempotente, usando o id do evento como chave de deduplicação. E a ordem só é garantida por agregado, desde que a chave de partição no broker seja o aggregate_id. Ordem global entre agregados diferentes nenhum dos dois entrega de graça.
Caminho 1: relay por polling
Um worker consulta a tabela em intervalo fixo, pega um lote de eventos não publicados, envia ao broker e marca como publicados. No Postgres, o FOR UPDATE SKIP LOCKED permite rodar mais de uma instância do worker sem que duas peguem a mesma linha.
WITH lote AS (
SELECT id FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 200
FOR UPDATE SKIP LOCKED
)
UPDATE outbox o SET published_at = now()
FROM lote WHERE o.id = lote.id
RETURNING o.*;
O envio ao broker acontece entre a leitura e o commit dessa transação. Se o envio falhar, rollback e o lote volta a ficar disponível. Se o envio passar e o commit falhar, o lote sai de novo na próxima rodada — daí a duplicata que o consumidor idempotente absorve.
O que pesa a favor: é código da aplicação, roda no mesmo deploy, usa o mesmo driver e a mesma observabilidade. Não exige permissão de replicação no banco, funciona igual em Postgres, MySQL ou SQL Server, e qualquer pessoa do time entende o que ele faz lendo trinta linhas.
O que pesa contra aparece com volume. A latência mínima é o intervalo de polling: com consulta a cada 500 ms, um evento espera em média algo perto de 250 ms antes de sair, mais o tempo do lote. Cada rodada é uma consulta ao primário, mesmo quando não há nada para publicar. E o UPDATE de published_at gera tuplas mortas numa tabela de escrita intensa, o que cobra autovacuum bem configurado e uma rotina de limpeza das linhas antigas. Um índice parcial em created_at WHERE published_at IS NULL mantém a consulta barata enquanto a fila pendente for pequena.
Há ainda uma armadilha de ordenação: duas transações podem gravar na outbox em ordem de created_at diferente da ordem de commit. Com SKIP LOCKED e várias instâncias, eventos do mesmo agregado podem sair fora de ordem. Quem precisa de ordem estrita por agregado costuma rodar uma única instância ativa ou particionar o relay por hash do aggregate_id.
Caminho 2: CDC com Debezium
O Debezium lê o log de escrita do banco — no Postgres, via replicação lógica com o plugin pgoutput — e transforma cada INSERT na tabela outbox num evento no Kafka. O transformador de outbox do próprio Debezium (o Outbox Event Router) usa as colunas da tabela para decidir tópico, chave e payload. A aplicação só grava; nada consulta a tabela.
Como o log reflete a ordem de commit, a ordem entre transações é a real, o que elimina a armadilha do created_at. A latência cai para a casa das dezenas a poucas centenas de milissegundos em condição normal, sem nenhuma consulta extra ao primário. E como a publicação não depende de marcar linhas, a tabela outbox pode ser limpa agressivamente — há quem apague a linha na mesma transação em que a insere, já que o log captura o INSERT de qualquer forma.
O custo está no que passa a existir fora da aplicação. O caminho padrão roda sobre Kafka Connect, ou seja, mais um cluster para operar, versionar e monitorar; o Debezium Server é uma alternativa para publicar em outros brokers sem Connect, mas continua sendo um processo a mais. O banco precisa de wal_level = logical, o que em serviço gerenciado costuma exigir mudança de parâmetro e reinício. E há o risco operacional que mais derruba times que adotam CDC sem preparo: o slot de replicação.
Enquanto o conector está parado, o Postgres retém todo o WAL a partir da última posição confirmada pelo slot. Um conector que falha numa sexta à noite e só é notado na segunda pode encher o disco do primário. Isso é mitigável — alerta sobre o lag do slot, max_slot_wal_keep_size nas versões que o suportam — mas precisa ser feito no dia um, não depois do primeiro incidente.
active ficar falso por mais de poucos minutos:SELECT slot_name, active, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS retido FROM pg_replication_slots;Failover é o outro ponto. Em versões mais antigas do Postgres, o slot lógico não sobrevive à promoção de uma réplica, e o conector precisa ser recriado com snapshot ou reposicionado manualmente. Versões recentes trouxeram sincronização de slots para standby, mas vale conferir o que o seu provedor gerenciado suporta de fato antes de contar com isso.
Lado a lado
| Critério | Polling | CDC (Debezium) |
|---|---|---|
| Latência típica | Intervalo de polling + lote (centenas de ms a segundos) | Dezenas a poucas centenas de ms |
| Carga no primário | Consulta periódica + UPDATE por evento | Leitura do WAL, sem consulta à tabela |
| Ordem entre transações | Por created_at, sujeita a inversão |
Ordem de commit |
| Infraestrutura nova | Nenhuma | Kafka Connect ou Debezium Server |
| Permissões no banco | Leitura e escrita na tabela | Replicação lógica, wal_level = logical |
| Falha mais cara | Fila pendente crescendo sem alarme | Slot retendo WAL até encher o disco |
| Quem precisa entender | Time da aplicação | Time da aplicação + quem opera o Connect |
O critério de escolha
A pergunta que decide não é “qual é mais moderno”, e sim “já existe Kafka Connect operado por alguém nesta empresa?”. Quase todo o custo do CDC é fixo: montar e sustentar o conector, os alertas de slot, o procedimento de failover. Se essa estrutura já existe para outros fluxos — replicação para data warehouse, por exemplo —, o custo marginal de mais um conector é baixo e o CDC ganha em latência, ordem e carga no banco.
Se não existe, o polling atende à maioria das cargas de SaaS de porte médio sem esforço. Algumas centenas de eventos por segundo, com latência aceitável de até um segundo, cabem num relay por polling com índice parcial e limpeza periódica. O limite prático aparece quando o volume faz a própria tabela outbox virar gargalo de vacuum, ou quando um requisito de negócio exige latência abaixo de 100 ms.
A decisão, cravada:
- Polling para quem tem um ou poucos serviços publicando eventos, não opera Kafka Connect e tolera latência de centenas de milissegundos. É a escolha padrão para começar.
- CDC com Debezium quando já há Connect em produção, quando vários serviços precisam publicar eventos com o mesmo mecanismo ou quando a ordem de commit é requisito e não detalhe.
Nos dois casos, a tabela outbox tem o mesmo formato. Começar com polling e migrar para CDC depois é trocar o relay, não reescrever os produtores — e esse é o argumento mais forte para não adotar CDC antes de haver motivo concreto. O próximo passo prático é medir o volume atual de eventos por segundo e o p99 de latência que os consumidores toleram; com esses dois números, a decisão deixa de ser opinião.