Padrão transactional outbox: diagrama e guia de implementação

O padrão transactional outbox resolve a escrita dupla: grave o evento na mesma transação dos dados e depois repasse. Diagrama, SQL, polling ou CDC.

· Fundador e desenvolvedor

Publicado em

Padrão transactional outbox: diagrama e guia de implementação

Todo serviço orientado a eventos acaba escrevendo um código assim: salvar o pedido e depois publicar OrderPlaced. Nos testes funciona. Em produção, o processo cai entre as duas linhas e o pedido existe sem o evento. Ou a publicação dá certo, a transação do banco é desfeita e o evento anuncia um pedido que não existe.

Esse é o problema da escrita dupla (dual write), e o padrão transactional outbox é a solução padrão. Este artigo explica o problema, o padrão, as duas formas de construir o relay e os detalhes que decidem se ele funciona de verdade: ordenação, duplicatas e limpeza.

O problema da escrita dupla

Um banco de dados e um broker de mensagens são dois sistemas sem transação compartilhada. Qualquer código que grave nos dois tem uma janela em que uma gravação deu certo e a outra não:

Ordem das operações O que pode dar errado
Commit, depois publicar Queda depois do commit: o evento se perde para sempre
Publicar, depois commit O commit falha: os consumidores agem sobre um pedido que nunca existiu
Publicar dentro da transação A chamada ao broker dá certo, e a transação é desfeita mesmo assim

Novas tentativas não fecham a janela; só a mudam de lugar. Truques de ordenação, como publicar a partir de um hook pós-commit, apenas a encolhem: uma queda entre o commit e o hook ainda perde o evento. Transações distribuídas entre o banco e o broker raramente estão disponíveis, e são lentas quando estão.

O padrão

Grave o evento no banco, na mesma transação dos dados de negócio, e publique-o depois a partir dali:

  1. O serviço insere a linha do pedido e uma linha de outbox (ID do evento, ID do agregado, tipo, payload) em uma única transação local. As duas são confirmadas ou nenhuma é.
  2. Um relay separado lê as linhas novas da outbox e as publica no broker.
  3. O relay marca as linhas como publicadas, ou as apaga, quando o broker confirma o recebimento.

Agora a garantia é simples: se o pedido existe, seu evento será publicado pelo menos uma vez. A descrição do padrão por Chris Richardson usa a mesma estrutura.

CREATE TABLE outbox (
  id            uuid PRIMARY KEY,
  aggregate_id  bigint      NOT NULL,
  type          text        NOT NULL,
  payload       jsonb       NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);

Duas formas de construir o relay

Publicador por polling. Um loop seleciona em lotes as linhas não publicadas, publica e apaga. No Postgres, SELECT ... FOR UPDATE SKIP LOCKED permite que várias instâncias do relay dividam o trabalho sem bloquear umas às outras; veja a documentação da cláusula de bloqueio. É fácil de construir e de entender. Os custos são a latência e a carga do polling, além de uma sutileza de ordenação: linhas com IDs menores podem ser confirmadas depois de linhas com IDs maiores, então um poller que ordena por ID pode pular uma linha que ainda não está visível.

Leitura do log (change data capture). Uma ferramenta de CDC lê o log de transações do banco e transforma as inserções confirmadas na outbox em mensagens. O outbox event router do Debezium faz exatamente isso para tabelas outbox, roteando cada linha para um tópico de acordo com suas colunas. Os eventos saem na ordem de commit, com baixa latência e sem consultas de polling, ao custo de rodar e operar o pipeline de CDC.

Polling CDC
Latência Intervalo de polling Perto do momento do commit
Ordenação Exige cuidado Ordem de commit
Peças móveis Um worker Um conector e a configuração do log
Bom para Volumes moderados, times pequenos Volume alto, muitas outboxes

Pelo menos uma vez significa duplicatas

O relay pode publicar uma linha e cair antes de marcá-la como publicada. Ao reiniciar, publica a linha de novo. Isso é inevitável, então os consumidores precisam ser idempotentes:

  • Todo evento carrega um ID único (o ID da linha da outbox).
  • Os consumidores registram os IDs que já processaram, de preferência na mesma transação do efeito do processamento (uma tabela inbox), e ignoram IDs já vistos.
  • Ou torne o efeito naturalmente idempotente, como um upsert pela chave do ID do pedido.

Ordenação

Os consumidores costumam se importar com a ordem por agregado: OrderPlaced antes de OrderCancelled para o mesmo pedido. Use o ID do agregado como chave da mensagem para que todos os eventos de um pedido vão para a mesma partição, e publique na ordem de commit (o CDC faz isso; um poller deve ordenar por uma sequência atribuída no momento do commit ou processar em série os eventos de cada agregado). Ordem global entre todos os pedidos raramente é necessária e custa caro.

Limpeza

Uma tabela outbox que nunca é esvaziada cresce sem limite e deixa o poller lento. Apague as linhas depois de publicá-las, ou guarde-as por uma janela curta de auditoria e descarte as partições antigas. Com CDC, as linhas podem ser apagadas logo depois da inserção, na mesma transação, porque o conector lê a inserção do log, não da tabela.

Repassando as falhas

Confira o design em cada ponto de queda:

  • Queda antes do commit. Nem o pedido nem o evento existem. O cliente recebe um erro e pode tentar de novo.
  • Queda depois do commit, antes de o relay rodar. As duas linhas existem; o relay publica o evento quando iniciar.
  • Queda do relay depois de publicar e antes de apagar a linha. O evento é publicado de novo na reinicialização, e os consumidores descartam a duplicata pelo ID.
  • Broker fora do ar. As linhas se acumulam na outbox; o serviço continua aceitando pedidos, e o relay se atualiza quando o broker volta. Crie um alerta para a idade da linha não publicada mais antiga.

Em todos os casos, o banco continua sendo a fonte da verdade, que é exatamente a propriedade que o padrão existe para oferecer.

O diagrama de arquitetura

Um serviço de pedidos insere linhas em duas tabelas, orders e outbox, numa única transação do Postgres. Um relay da outbox lê as linhas novas e as publica no tópico order-events. Os serviços de envio e de e-mail consomem os eventos; o de e-mail registra os IDs processados.

10 componentes · 6 conexões

Abrir no Archboard
Conteúdo do diagrama
  • Order service
  • Postgres: one transaction
  • orders
  • outbox
  • Outbox relay
  • order-events
  • Shipping service
  • Email service
  • Processed events
  • both rows
  • new rows
  • publish
  • OrderPlaced
  • seen it?
O padrão transactional outbox. O serviço de pedidos grava o pedido e uma linha de outbox numa única transação do Postgres; um relay publica as linhas da outbox num tópico do Kafka, e os consumidores eliminam duplicatas pelo ID, porque a entrega é pelo menos uma vez.

Como ler:

  • Esquerda: o serviço de pedidos grava duas linhas, nas tabelas orders e outbox, dentro de uma única transação do Postgres. A moldura marca o limite da transação.
  • Centro: o relay da outbox, por polling ou CDC, lê as linhas novas e as publica no tópico order-events.
  • Direita: os serviços de envio e de e-mail consomem os eventos. O serviço de e-mail consulta uma tabela de IDs de eventos processados antes de agir; é assim que as duplicatas são absorvidas.
  • O bloco SQL mostra a transação, para que ninguém precise adivinhar o que "atomicamente" quer dizer aqui.

O que colocar no diagrama

  • O limite da transação. Desenhe as duas tabelas dentro dele; esse é o padrão inteiro.
  • O relay e seu tipo. Polling ou CDC muda a latência e a ordenação.
  • A semântica de entrega. Escreva "pelo menos uma vez" no relay e "idempotente" em cada consumidor.
  • A chave da mensagem. Anote que os eventos usam o ID do agregado como chave.

O artigo sobre arquitetura orientada a eventos aprofunda o lado do consumidor, e o diagrama do padrão saga mostra onde uma outbox é necessária em cada passo de um workflow com vários serviços. O artigo sobre replicação de banco de dados explica o write-ahead log que o CDC lê.

Monte no Archboard

  1. Entre com Google ou GitHub e crie um diagrama.
  2. Adicione um Contêiner do pack Modelo C4 para o serviço de pedidos e outro para o relay da outbox.
  3. Adicione uma Moldura do pack Básico com o nome do banco e da transação, e coloque dentro dela dois nós Entidade do pack Entidade-relacionamento para as tabelas orders e outbox.
  4. Adicione Kafka do pack Dados para o tópico, nós Contêiner para os consumidores e um Banco de dados para os IDs de eventos processados.
  5. Adicione um bloco Markdown do pack Básico com o SQL da transação.

O exemplo usa 10 nós de diagrama e fica dentro do plano Free.

Perguntas frequentes

Que problema a transactional outbox resolve? Perder ou inventar eventos quando um serviço grava no banco e publica uma mensagem em dois passos separados. A outbox torna o evento parte da mesma transação de banco de dados.

O padrão outbox garante exactly once? Não. Ele garante entrega pelo menos uma vez. Os consumidores precisam lidar com duplicatas, em geral registrando os IDs de eventos processados.

Polling ou CDC: qual relay devo usar? Polling é mais simples e serve para volumes moderados. CDC oferece latência menor e entrega na ordem de commit, e vale a pena com volume alto ou quando muitos serviços usam outboxes.

Ainda preciso da outbox se uso event sourcing? Não quando o próprio event store é a fonte que os consumidores assinam. Você precisa dela sempre que um serviço guarda estado em tabelas e também tem de publicar eventos.

Quanto a tabela outbox cresce? O quanto você deixar. Apague logo as linhas publicadas ou particione a tabela por tempo e descarte as partições antigas.

Comece grátis — um assento, sem cartão de crédito.

Artigos relacionados

← Voltar ao blog

Abra um canvas em um minuto

Entre com Google ou GitHub. Grátis para um assento. Faça upgrade quando a equipe entrar.