ResiliênciaAvançado8 min de leitura7 seçõesanos 2010, Data aproximada

Transactional Outbox

Salvar no banco e publicar um evento são duas operações em dois sistemas — e não há transação que cubra as duas. O outbox transforma o evento numa linha da mesma transação, e deixa a publicação para depois.

Em 10 segundos

Não dá para commitar no banco e publicar no broker atomicamente. Então o evento vira uma linha da mesma transação do dado, e um processo separado drena essa tabela e publica. A publicação passa a ser at-least-once — o que exige deduplicação no consumidor, mas nunca perde o evento.

Antes de ter nome

É a aplicação, entre banco e broker, da velha ideia do WAL: grave a intenção de publicar na mesma transação do dado e deixe outro processo despachar depois.

  • #mensageria
  • #eventos
  • #atomicidade
  • #escrita-dupla
  • #resiliencia
Analogia

A caixa de saída

Você escreve o e-mail no avião e clica em enviar. Ele não vai a lugar nenhum — vai para a caixa de saída, gravada no seu aparelho. Quando a conexão volta, o cliente drena a caixa e transmite. Você nunca fica na dúvida sobre ter escrito ou não: escrever e enfileirar foram a mesma ação. O envio é que ficou para depois.

01Conceito

A escrita dupla

O nome do problema é *dual write*: duas escritas, em dois sistemas, que precisariam ser uma coisa só e não são.

O caso ruim não dá erro — é justamente por isso que ele passa despercebido. A transação commitou, o serviço respondeu 200, e o evento simplesmente não existe.

Nenhuma ordem resolve. Publicar depois do commit arrisca o estado mudar sem ninguém saber; publicar antes arrisca anunciar algo que o rollback desfaz — e evento publicado não se desfaz. Repetir a publicação em caso de falha ajuda, mas não cobre o processo que morre entre uma coisa e outra.

A solução clássica de livro seria commit em duas fases entre banco e broker. Na prática ela é cara, trava recursos durante a votação, e a maioria dos brokers modernos nem oferece — Kafka e SQS não participam de transação XA. O outbox é a alternativa que troca atomicidade distribuída por atomicidade local, que é barata e existe.

O truque conceitual é reconhecer que publicar não precisa ser síncrono; registrar a intenção de publicar, sim. E registrar intenção é escrever uma linha — coisa que o banco já sabe fazer transacionalmente. Deslocado o problema para dentro de um sistema só, ele deixa de ser distribuído.

Isso conecta diretamente com Saga: numa coreografia, cada passo publica o evento que dispara o próximo. Se um evento se perde, a saga trava no meio, com um pedido confirmado e um estoque que nunca baixou — e nada no log indicando o que houve. O outbox é o que torna a saga confiável.

Como se encaixa
  1. Serviço
  2. confirmar pedidoUma transação: dado + linha no outbox
  3. commit atômico — ou os dois, ou nenhumTabela outbox
  4. lê pendentesDrenador (processo separado)
  5. publica e marca, com reentregaBroker
A atomicidade acontece à esquerda, dentro de um banco só. À direita, a entrega é at-least-once por construção — e é por isso que o consumidor precisa deduplicar pela chave do evento.
02Conceito

Drenar sem duplicar demais e sem travar

O drenador é onde as decisões operacionais aparecem: como várias instâncias leem sem pegar a mesma linha, o que fazer quando a publicação falha, e como impedir que a tabela cresça para sempre.

E há uma ordem que não tem saída boa: publicar e marcar não são atômicos entre si.

Se o processo morre depois de publicar e antes de marcar, o evento sai duas vezes. Inverter — marcar antes de publicar — é pior: uma falha ali perde o evento definitivamente, que é exatamente o que o padrão existia para evitar. Então a escolha é assumida: publica primeiro, marca depois, e o consumidor deduplica pelo id do evento — o mesmo raciocínio das garantias de entrega.

Para escalar o drenador, SELECT ... FOR UPDATE SKIP LOCKED é a ferramenta certa: várias instâncias leem lotes diferentes sem bloquear umas às outras e sem pegar a mesma linha. Sem isso, ou só uma instância drena (e ela vira o gargalo), ou duas publicam o mesmo evento com frequência.

A alternativa ao drenador por polling é CDC: uma ferramenta como o Debezium lê o log de transações do banco e publica o que entrou na tabela. Elimina o processo de polling e a latência dele, ao custo de uma peça de infraestrutura a mais e de acoplamento ao formato de replicação do banco.

E a tabela precisa de expurgo. Linhas publicadas não servem mais para nada depois de um tempo, e sem limpeza o índice cresce até o drenador ficar lento — o gargalo aparece justamente no componente que deveria ser invisível.

03No mundo real

Como isso aparece na prática

A saga que travou no meio

Cenário

O serviço de pedidos confirma o pedido e publica PedidoConfirmado, que dispara a baixa de estoque. Numa instabilidade do broker, algumas publicações falham. Os pedidos ficam confirmados, o estoque não baixa, e não há erro em nenhum log.

Como o padrão entra

O evento passa a ser gravado na tabela outbox junto do pedido. Durante a instabilidade, as linhas se acumulam; quando o broker volta, o drenador publica tudo e a saga continua.

Trade-off: Os eventos saem atrasados durante o incidente, então o estoque fica temporariamente inconsistente. É uma consistência eventual explícita, em vez de uma perda silenciosa.

O broker menos disponível que o banco

Cenário

O broker é gerenciado por outra equipe e tem janelas de manutenção que o serviço não controla. Durante elas, qualquer operação que publique evento falha para o usuário final.

Como o padrão entra

Com o outbox, a operação do usuário depende apenas do banco. A publicação é assíncrona e se recupera sozinha quando o broker volta.

Trade-off: A resposta ao usuário passa a significar 'foi registrado', não 'foi propagado'. A interface precisa refletir isso, ou alguém vai assumir que o efeito downstream já aconteceu.

Commitar e publicar em seguida

Compila, roda, passa no code review. E quebra depois.

A transação fecha, e logo depois vem a chamada ao broker. Parece sequencial e correto, e funciona em todos os testes — porque nos testes o broker nunca cai entre as duas linhas.

// A "escrita dupla": dois sistemas, duas operacoes, nenhuma atomicidade.
async function confirmarPedido(id: string) {
  await db.transacao(async (tx) => {
    await tx.pedidos.atualizar(id, { status: "CONFIRMADO" });
  });                                   // <- commitou aqui

  await broker.publicar("PedidoConfirmado", { id });  // <- e se falhar?
}

// Tres finais possiveis, e dois sao ruins:
//
// 1. commit OK + publish OK    -> tudo certo
// 2. commit OK + publish FALHA -> o pedido esta confirmado no banco
//                                 e ninguem foi avisado. O estoque nao
//                                 baixa, a nota nao sai, o cliente nao
//                                 recebe e-mail. E nao ha erro em lugar
//                                 nenhum: a transacao commitou.
// 3. publish antes do commit   -> pior ainda: avisou de algo que pode
//                                 nem ter acontecido (rollback depois)
//
// Inverter a ordem nao resolve. Envolver o broker na transacao do banco
// exigiria commit em duas fases entre sistemas — que e caro, fragil,
// e a maioria dos brokers nem oferece.

O que quebra

  • Se o broker oscilarO estado muda no banco e o evento não sai. Não há exceção registrada em lugar nenhum: a transação commitou com sucesso.
  • Ao reiniciar o processoUma morte entre o commit e o publish perde o evento definitivamente, e nada no sistema indica que ele deveria existir.
  • Numa sagaO passo seguinte nunca é disparado, a coreografia trava, e o pedido fica num estado intermediário que ninguém consegue explicar depois.

A saída: Publicar não pode participar da transação do banco — mas registrar a intenção de publicar pode, porque é só uma linha. Grave o evento na tabela outbox junto do dado e deixe um processo separado publicar, assumindo at-least-once e deduplicando no consumidor.

05Cuidado

Onde isto dá errado

  1. Achar que o outbox garante entrega única

    Ele garante que o evento não se perde, não que sai uma vez só. Publicar e marcar não são atômicos, então a reentrega é esperada e o consumidor precisa deduplicar pela chave do evento.

  2. Marcar como publicado antes de publicar

    Parece proteger contra duplicata e é a única variante que perde evento: uma falha entre marcar e publicar apaga a intenção para sempre. Publicar primeiro é a ordem correta, com a duplicação assumida.

  3. Drenador único como gargalo

    Uma instância só drenando limita a vazão de eventos ao que ela dá conta. Escalar exige FOR UPDATE SKIP LOCKED ou particionamento — sem isso, duas instâncias disputam as mesmas linhas.

  4. Tabela sem expurgo nem índice parcial

    Linhas publicadas se acumulam e a consulta por pendentes fica lenta. Um índice parcial sobre publicado_em IS NULL mais limpeza periódica mantêm o custo constante.

  5. Payload que referencia em vez de descrever

    Gravar só o id e esperar que o consumidor busque o estado atual reintroduz a corrida que o padrão evitava: quando ele buscar, o estado já pode ter mudado de novo. O evento carrega o que aconteceu.

  6. Ordem entre eventos assumida sem garantia

    Drenar em paralelo publica fora de ordem. Se o consumidor depende da sequência, é preciso ordenar por chave de partição — ou o consumidor precisa tolerar chegada desordenada.

06Implementação

Uma transação para o dado e o evento

// A ideia: o evento vira uma LINHA, na MESMA transacao do dado.
// Um banco so, uma transacao so — atomicidade de verdade.

async function confirmarPedido(id: string, agora: Date) {
  await db.transacao(async (tx) => {
    await tx.pedidos.atualizar(id, { status: "CONFIRMADO" });

    // Nao publica: apenas registra a intencao de publicar.
    await tx.outbox.inserir({
      id: crypto.randomUUID(),      // vira a chave de deduplicacao do consumidor
      tipo: "PedidoConfirmado",
      payload: { pedidoId: id },
      criadoEm: agora,              // injetado, nunca new Date() aqui dentro
      publicadoEm: null,
    });
  });
  // Ou os dois aconteceram, ou nenhum. Nao existe meio-termo.
}

// Um processo separado drena a tabela e publica de verdade.
async function drenar(agora: Date) {
  const pendentes = await db.outbox.naoPublicados({ limite: 100 });

  for (const ev of pendentes) {
    await broker.publicar(ev.tipo, ev.payload, { chave: ev.id });
    // Se o processo morrer AQUI, o evento sera publicado de novo:
    // por isso o consumidor precisa deduplicar por ev.id.
    await db.outbox.marcarPublicado(ev.id, agora);
  }
}
07Decisão

Quando usar × quando evitar

Use quando

3
  • Quando uma mudança de estado precisa produzir evento externo confiável.
  • Em sagas, onde perder um evento trava a coreografia.
  • Quando o broker é menos disponível que o banco.

Evite quando

3
  • Para eventos dispensáveis, como telemetria informativa.
  • Quando o consumidor pode simplesmente ler o estado do banco.
  • Se não houver deduplicação no consumidor.

Testar o que ficou

6 armadilhas de Transactional Outbox viram perguntas, com o nome do padrão escondido, misturadas com padrões parecidos.

Onde isto aparece de verdade

Você já usa isto, talvez sem saber o nome.

  • Debezium sobre a tabela outboxLê o log de transações do banco e publica o que entrou na tabela — sem o serviço nem saber do broker.
  • O log de transações do próprio bancoO WAL é, ele mesmo, um outbox: a intenção é escrita e durável antes de ser aplicada em qualquer outro lugar.
  • A pasta de saída de um cliente de e-mailA mensagem é gravada localmente ao clicar em enviar, e transmitida depois — sem depender da rede no instante do clique.

O padrão em uma linha

Sem nome de domínio, sem cerimônia. Só a ideia.

// Evento na mesma transação do estado.
await tx.insert(pedido);
await tx.insert(outbox, evento);

O que ele cobra

1 salto a mais entre a chamada e o efeito.

  • Uma tabela de outbox e um processo de despacho a mais para manter e monitorar
  • O evento é publicado depois do commit, então a entrega é assíncrona e ao menos uma vez

Não vale se: não há dois destinos a manter consistentes (banco e broker) — sem essa dupla escrita, o outbox não tem o que resolver.

Onde isto aparece

Continue por aqui