Transactional outbox: como publicar eventos sem perder consistência

Linha automatizada transportando pacotes entre um sistema protegido e servidores com mensagens digitais

Salvar um pedido no banco e publicar OrderCreated no broker parece uma sequência simples. O problema aparece no intervalo entre as duas operações. Se o commit funcionar e a publicação falhar, o pedido existe sem evento. Se a mensagem sair primeiro e a transação for revertida, consumidores reagem a algo que nunca existiu. Esse é o clássico problema de dual write.

O transactional outbox troca duas escritas sem uma transação comum por uma única transação local. A aplicação grava a mudança de negócio e uma mensagem de saída no mesmo banco. Depois, um processo separado entrega essa mensagem ao broker. Não é mágica distribuída: é uma forma pragmática de mover o ponto de falha para um lugar recuperável.

O desenho básico

Uma tabela de outbox costuma guardar um identificador único, o tipo do agregado, sua chave, o tipo do evento, o payload e a data de criação. Pedido e evento entram no mesmo COMMIT:

BEGIN;

INSERT INTO orders (id, customer_id, total, status)
VALUES (:id, :customerId, :total, 'created');

INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES (:eventId, 'order', :id, 'OrderCreated', :json, NOW());

COMMIT;

Se qualquer INSERT falhar, nada é persistido. Se ambos forem confirmados, o evento fica disponível para um relay. Esse relay pode consultar a tabela periodicamente ou usar change data capture, como o conector do Debezium, para transformar cada nova linha em uma mensagem.

A garantia é pelo menos uma vez

O outbox impede que a alteração de negócio fique sem um registro publicável, mas não garante entrega exatamente uma vez. Imagine que o relay publique no broker e caia antes de marcar a linha como processada. Na próxima tentativa, publicará novamente. Esse comportamento é correto para não perder eventos, porém transfere uma obrigação aos consumidores: eles precisam ser idempotentes.

O identificador do evento deve acompanhar a mensagem. O consumidor registra os IDs já aplicados ou constrói a operação de forma que repetir o mesmo evento não altere o resultado. Cobrar um cartão ou enviar um e-mail sem deduplicação é o tipo de efeito colateral que revela o problema tarde demais.

Ordem também precisa ser projetada

Eventos do mesmo agregado devem chegar na ordem em que foram confirmados. Uma sequência crescente por agregado ajuda, e a chave da mensagem deve manter todos os eventos daquele agregado na mesma partição do broker. No roteador de outbox do Debezium, o ID do agregado pode virar a chave do Kafka justamente para preservar essa ordem dentro da partição.

Isso não cria uma ordem global entre todos os pedidos — e geralmente nem deveria. A regra útil costuma ser mais estreita: OrderCreated do pedido 42 não pode aparecer depois de OrderCancelled do mesmo pedido.

Polling ou CDC?

  • Polling: é simples, explícito e funciona sem infraestrutura adicional. Exige concorrência bem controlada, índices e uma política de retenção para a tabela não crescer para sempre.
  • CDC: lê o log de mudanças do banco e reduz consultas repetidas. Em troca, adiciona conectores, offsets e operação especializada ao sistema.

Começar com polling não é uma solução menor. Para um volume moderado, um worker com SELECT ... FOR UPDATE SKIP LOCKED, lotes limitados e retentativa com backoff é previsível e fácil de observar. CDC passa a fazer sentido quando latência, volume ou uma plataforma de streaming já existente justificam o custo.

O que o padrão não resolve

Outbox não coordena uma transação de negócio que já cruza vários serviços. Nesse caso, uma saga pode organizar compensações, enquanto cada serviço ainda usa seu próprio outbox para publicar mudanças com segurança. Também não substitui monitoramento: idade do evento mais antigo, tamanho da fila, taxa de falhas e número de tentativas precisam virar métricas.

Minha regra prática é direta: se uma operação precisa confirmar estado local e anunciar essa mudança fora do banco, trato o outbox como ponto de partida. A implementação só está completa quando inclui deduplicação, ordem por agregado, retentativas e limpeza. Sem essas quatro peças, você apenas mudou o lugar onde a inconsistência vai aparecer.

O relay precisa assumir trabalho sem bloquear os vizinhos

Com vários workers, selecione um lote curto com FOR UPDATE SKIP LOCKED, registre uma lease e confirme a transação antes de publicar. Não segure a transação do banco durante a chamada ao broker. Depois da publicação, marque o evento como entregue usando o ID e o dono da lease.

BEGIN;

SELECT id, payload
FROM outbox_events
WHERE delivered_at IS NULL
  AND (lease_until IS NULL OR lease_until < NOW())
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 100;

UPDATE outbox_events
SET lease_owner = :worker, lease_until = NOW() + INTERVAL '30 seconds'
WHERE id = ANY(:ids);

COMMIT;

A lease evita que um worker morto esconda a mensagem para sempre. Ela não elimina duplicação: se o processo publica e cai antes de gravar delivered_at, outro worker tenta de novo. O consumidor ainda precisa aplicar o ID do evento de forma idempotente.

Teste os dois lados do instante de publicação

  1. mate o relay antes de chamar o broker e confirme nova tentativa;
  2. mate depois do aceite do broker e antes de atualizar a outbox;
  3. atrase um evento antigo do mesmo agregado;
  4. indisponibilize o broker até a lease expirar;
  5. reprocesse uma mensagem no consumidor e confirme um único efeito.

O relay deve aplicar backoff, jitter e limite de tentativas para não transformar a volta do broker em avalanche. A operação que recebe a requisição pode usar idempotência para impedir que o próprio pedido duplicado gere dois eventos distintos.

Monitore idade do evento mais antigo por tipo, quantidade pronta, leases expiradas e tentativas. Tamanho da tabela sozinho não distingue retenção saudável de uma fila parada. Defina também quando mover mensagens que falham repetidamente para investigação sem bloquear todo o agregado.

Fontes