Transactional Outbox: publicação confiável de eventos a partir do banco de dados
Como gravar o evento na mesma transação do agregado e entregá-lo ao broker por polling ou por captura do log de transações, com garantia at-least-once, ordem por agregado e deduplicação no consumidor.
Por Alexandre Martins · · 7 min de leitura
Considere um serviço de pedidos que, ao confirmar um pedido, grava a linha na tabela e publica o evento PedidoCriado em um broker como Kafka ou RabbitMQ. São duas escritas em dois sistemas distintos, cada um com seu próprio protocolo de confirmação. Nenhuma transação cobre os dois.
Em qualquer ordem existe uma janela em que um lado persiste e o outro não, e a divergência não gera erro em lugar nenhum.
Esse é o problema da dupla escrita (dual write). Retry não o resolve, porque a repetição da publicação não sabe se o commit ocorreu. Two-phase commit também não, porque a maioria dos brokers não participa de transações distribuídas. A saída é transformar as duas escritas em uma só.
Como garantir que todo evento publicado corresponda a uma transação confirmada no banco, e que toda transação confirmada produza o seu evento?
As janelas de falha da dupla escrita
Com o banco primeiro, o commit ocorre e o processo cai antes de chamar o broker, ou a publicação falha. O pedido existe e nenhum consumidor sabe dele. Um retry só é possível se o processo continuar vivo e souber o que faltou publicar, o que exclui exatamente o caso da queda.
Com o broker primeiro, a mensagem é publicada e a transação sofre rollback por violação de restrição, deadlock ou indisponibilidade do banco. Os consumidores reservam estoque e disparam cobrança para um pedido que nunca foi confirmado.
Publicar dentro da transação não muda nada, porque o broker não participa do rollback. A observação que orienta o padrão é outra: o banco já oferece atomicidade, e basta usá-la para as duas escritas.
O padrão e a tabela outbox
O padrão Transactional Outbox, catalogado por Chris Richardson em microservices.io, grava o evento em uma tabela do próprio banco, chamada outbox, na mesma transação que altera o agregado. O commit passa a cobrir as duas escritas. A publicação fica com um componente separado, o relay de mensagens, que lê a tabela e encaminha os eventos.
O esquema a seguir usa os nomes de coluna esperados pelo Debezium, o que permite trocar o tipo de relay sem migrar a tabela:
CREATE TABLE outbox (
id UUID PRIMARY KEY, -- identificador do evento, chave de deduplicação
sequencia BIGSERIAL, -- ordem de inserção para o relay por polling
aggregatetype VARCHAR(255) NOT NULL, -- ex.: Pedido; define o tópico de destino
aggregateid VARCHAR(255) NOT NULL, -- ex.: id do pedido; vira a chave da mensagem
type VARCHAR(255) NOT NULL, -- ex.: PedidoCriado
payload JSONB NOT NULL,
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
publicado_em TIMESTAMPTZ -- NULL até o relay publicar
);
CREATE INDEX outbox_pendentes_idx ON outbox (sequencia) WHERE publicado_em IS NULL;
Três colunas carregam decisões de projeto. A coluna aggregatetype decide o tópico de destino. A coluna aggregateid vira a chave da mensagem e garante a ordem por agregado no broker. A coluna id identifica o evento e é a chave de deduplicação do consumidor. O índice parcial cobre só as linhas pendentes e mantém a consulta do relay barata quando a tabela acumula histórico.
Os dois tipos de relay
O relay por polling (polling publisher) é um job que, a cada intervalo, lê um lote de linhas com publicado_em nulo, publica cada uma no broker e marca a linha. Com mais de uma instância do relay, o lote é lido com FOR UPDATE SKIP LOCKED, cláusula que a documentação do PostgreSQL indica para tabelas usadas como fila. O polling funciona com qualquer banco e qualquer broker, e o custo é a latência do intervalo mais a carga das consultas repetidas.
O relay por captura do log de transações (transaction log tailing) usa um conector de Change Data Capture (CDC). No PostgreSQL, o Debezium lê o Write-Ahead Log (WAL) por um slot de replicação lógica com o plugin pgoutput e publica cada INSERT da tabela outbox no Kafka. A aplicação não marca nada e a latência cai para milissegundos. O custo é operacional: um cluster Kafka Connect, um slot de replicação que retém WAL enquanto o conector estiver parado e o Kafka como destino obrigatório.
Nos dois modelos o relay pode publicar o mesmo evento mais de uma vez. No polling, basta o processo cair entre o envio ao broker e a marcação da linha. No CDC, basta o conector reiniciar antes de confirmar o offset. A garantia do padrão é at-least-once, e isso define o que o consumidor precisa fazer.
Ver o fluxograma em texto (Mermaid)
flowchart TD
comando["Comando: criar pedido"] --> tx["Transação local"]
tx --> pedido["INSERT na tabela pedido"]
tx --> evento["INSERT na tabela outbox"]
pedido --> commit{"Commit?"}
evento --> commit
commit -- "não" --> nada["Nada é gravado nem publicado"]
commit -- "sim" --> relay{"Tipo de relay"}
relay -- "polling" --> poll["SELECT pendentes FOR UPDATE SKIP LOCKED"]
poll --> publica["Publica no broker e marca publicado_em"]
relay -- "CDC" --> wal["Debezium lê o INSERT no WAL"]
wal --> smt["EventRouter: tópico, chave e cabeçalho id"]
publica --> broker["Broker (at-least-once)"]
smt --> broker
broker --> consumidor["Consumidor deduplica pelo id do evento"]
Exemplo em Java com Spring
O serviço grava o pedido e o evento em um único método anotado com @Transactional. A entidade OutboxEvento mapeia a tabela outbox, com o payload anotado como @JdbcTypeCode(SqlTypes.JSON) no Hibernate 6.
@Service
public class PedidoService {
private final PedidoRepository pedidos;
private final OutboxRepository outbox;
private final ObjectMapper mapper;
public PedidoService(PedidoRepository pedidos, OutboxRepository outbox, ObjectMapper mapper) {
this.pedidos = pedidos;
this.outbox = outbox;
this.mapper = mapper;
}
@Transactional
public Pedido criar(NovoPedido comando) {
Pedido pedido = pedidos.save(new Pedido(comando));
// Mesma transação do agregado: o commit cobre o pedido e o evento, ou nenhum dos dois.
OutboxEvento evento = new OutboxEvento(
UUID.randomUUID(), // id do evento, chave de deduplicação no consumidor
"Pedido", // aggregatetype, define o tópico
pedido.getId().toString(), // aggregateid, chave da mensagem
"PedidoCriado", // type
mapper.valueToTree(PedidoCriado.de(pedido)));
outbox.save(evento);
return pedido;
}
}
O relay por polling lê o lote, publica e marca, tudo em uma transação: as linhas lidas com SKIP LOCKED ficam bloqueadas até o commit e nenhuma outra instância as publica em paralelo.
@Component
public class OutboxRelay {
private static final int TAMANHO_LOTE = 100;
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 500)
@Transactional
public void publicarPendentes() {
// SKIP LOCKED: duas instâncias do relay não disputam as mesmas linhas.
List<LinhaOutbox> pendentes = jdbc.query("""
SELECT id, aggregatetype, aggregateid, type, payload::text AS payload
FROM outbox
WHERE publicado_em IS NULL
ORDER BY sequencia
LIMIT ?
FOR UPDATE SKIP LOCKED""", LinhaOutbox.MAPPER, TAMANHO_LOTE);
for (LinhaOutbox linha : pendentes) {
var registro = new ProducerRecord<>("outbox.event." + linha.aggregatetype(),
linha.aggregateid(), linha.payload());
registro.headers().add("id", linha.id().toString().getBytes(UTF_8));
registro.headers().add("eventType", linha.type().getBytes(UTF_8));
kafka.send(registro).join(); // espera o ack do broker antes de marcar a linha
jdbc.update("UPDATE outbox SET publicado_em = now() WHERE id = ?", linha.id());
}
// Queda do processo antes do commit: o lote é relido e republicado. At-least-once.
}
}
O nome do tópico e os cabeçalhos seguem a convenção do Debezium, então trocar o relay pelo conector não altera os consumidores.
Configuração do Debezium Outbox Event Router
O conector é registrado no Kafka Connect com a Single Message Transformation (SMT) EventRouter. O valor de aggregatetype entra no nome do tópico, o de aggregateid vira a chave da mensagem e o de id vai para o cabeçalho de mesmo nome.
{
"name": "pedidos-outbox",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "db",
"database.user": "debezium",
"database.dbname": "pedidos",
"topic.prefix": "pedidos",
"plugin.name": "pgoutput",
"slot.name": "outbox_slot",
"publication.name": "outbox_pub",
"table.include.list": "public.outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.by.field": "aggregatetype",
"transforms.outbox.route.topic.replacement": "outbox.event.${routedByValue}",
"transforms.outbox.table.field.event.key": "aggregateid",
"transforms.outbox.table.fields.additional.placement": "type:header:eventType"
}
}
A SMT espera que toda mudança na tabela seja um INSERT e filtra os DELETE. Por isso, com CDC, a aplicação pode apagar a linha na mesma transação em que a inseriu: o INSERT já está no WAL e a tabela não cresce.
Garantias e o par obrigatório do consumidor
O padrão garante que o evento é publicado se, e apenas se, a transação foi confirmada. Garante também a ordem dos eventos de um mesmo agregado, desde que aggregateid seja a chave da mensagem e o relay publique na ordem de inserção.
O que o padrão não garante é entrega única. Como discutido no artigo sobre idempotência em APIs distribuídas, o consumidor registra o identificador de cada evento processado na mesma transação em que aplica o efeito e descarta o evento quando o identificador já existir. Sem esse par, o Outbox apenas desloca a duplicidade do produtor para o consumidor.
Desafios
A tabela cresce com cada evento. No relay por polling, um job de limpeza remove as linhas com publicado_em anterior a um prazo, ou a tabela é particionada por dia com descarte das partições antigas. No relay por CDC, a remoção na própria transação de inserção mantém a tabela quase vazia.
O intervalo de polling é uma troca entre latência e carga: centenas de milissegundos atendem à maioria dos fluxos de negócio, e latências menores pedem CDC.
O payload é um contrato público. Consumidores de outros times vão ler o JSON gravado hoje por meses. Mudanças aditivas são seguras. Remoções ou renomeações exigem uma nova versão do tipo do evento, como PedidoCriado.v2, com período de convivência.
Com várias instâncias do relay por polling, dois eventos do mesmo agregado podem ser lidos por instâncias diferentes e publicados fora de ordem. Uma única instância ativa, eleita por lock no banco, resolve o problema.
Por fim, o padrão não coordena transações entre serviços. Um pedido que exige reserva de estoque e cobrança em serviços distintos continua precisando de uma Saga, como tratado no artigo sobre microsserviços e transações, e o Outbox publica cada passo dela.
Boas práticas
- Grave o evento na mesma transação do agregado, nunca em transação separada nem depois do commit.
- Use o identificador do agregado como chave da mensagem para preservar a ordem por agregado.
- Gere o identificador do evento no produtor e envie-o como cabeçalho para a deduplicação no consumidor.
- No relay por polling, leia o lote com FOR UPDATE SKIP LOCKED e espere a confirmação do broker antes de marcar a linha.
- Trate o payload como contrato público e versione o tipo do evento a cada mudança que não seja aditiva.
- Defina a política de retenção da tabela desde o início, por remoção após a publicação ou por particionamento por data.
- Exija consumidores idempotentes, porque a garantia do padrão é at-least-once e não entrega única.
Conclusão
A dupla escrita falha porque pede a dois sistemas que concordem sem um protocolo que os una. O Transactional Outbox recusa a segunda escrita: o evento é apenas uma linha a mais na transação que o banco já sabe confirmar ou desfazer. O relay, por polling ou por captura do log, converte essa linha em mensagem sem participar da decisão de negócio.
A escolha entre polling e CDC é uma decisão de operação, não de correção. O polling cabe em qualquer infraestrutura e custa latência. O Debezium custa um cluster Kafka Connect e um slot de replicação, e devolve latência de milissegundos. Nos dois casos a garantia é at-least-once, e o consumidor idempotente é parte do padrão, não um complemento opcional.
Referências
- Chris Richardson, Pattern: Transactional outbox. https://microservices.io/patterns/data/transactional-outbox.html
- Debezium, Outbox Event Router. https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html
- Debezium, Debezium connector for PostgreSQL. https://debezium.io/documentation/reference/stable/connectors/postgresql.html
- PostgreSQL, SELECT, cláusula de bloqueio (FOR UPDATE, SKIP LOCKED). https://www.postgresql.org/docs/current/sql-select.html
- Spring Framework, Using @Transactional. https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html
Assuntos: Distributed Systems, Event Driven Architecture, Spring Boot, PostgreSQL, Debezium
Continue lendo
- Idempotência em APIs distribuídas: chaves, persistência e janelas de validade — Como garantir que uma operação de escrita repetida por retry produza exatamente um efeito, com Idempotency-Key, armazenamento transacional e tratamento de concorrência.
- Semântica de entrega em sistemas de mensageria: at-least-once, idempotência e dead-letter queues — Por que o broker entrega mais de uma vez, como o consumidor converte repetição em efeito único com uma tabela de mensagens processadas na mesma transação, e o que fazer com a mensagem que falha sempre.
- Planejamento de consultas no PostgreSQL: lendo o EXPLAIN e projetando índices — Como o planejador estima custo e seletividade a partir das estatísticas, como ler cada linha de um EXPLAIN (ANALYZE, BUFFERS), por que um índice existente é ignorado e como projetar índices compostos, parciais e cobertos sem pagar demais na escrita.
Quer aplicar isto no seu sistema? Fale com a MFactor: contato ou WhatsApp +55 (19) 99778-9831.