Todo sistema orientado a eventos esbarra no mesmo problema em algum momento. Um serviço precisa fazer duas coisas quando um pedido é criado: gravar o pedido no banco e publicar um evento para que faturamento, estoque e notificações reajam. São duas operações em dois sistemas diferentes, e não existe uma transação que cubra as duas.
O problema da escrita dupla
Considere a sequência mais comum:
await db.orders.insert(order);
await broker.publish("order.created", order); // e se isto falhar?Se a publicação falha depois do insert, o pedido existe, mas nenhum outro serviço sabe disso. Inverter a ordem não resolve: se a publicação funciona e o insert falha, os consumidores reagem a um pedido que não existe. Retentativas reduzem a probabilidade, mas um processo pode morrer exatamente entre as duas linhas.
Esse problema não aparece em testes locais. Aparece em produção, raramente, e gera divergências difíceis de investigar.
A ideia do outbox
O padrão outbox troca duas escritas em sistemas diferentes por duas escritas no mesmo banco, na mesma transação. Em vez de publicar diretamente, o serviço grava o evento em uma tabela de saída, junto com a mudança de estado. Um processo separado, o relay, lê essa tabela e publica no broker.
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
aggregate_id text NOT NULL,
topic text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX outbox_unpublished ON outbox (id) WHERE published_at IS NULL;await db.transaction(async (tx) => {
await tx.orders.insert(order);
await tx.outbox.insert({ aggregateId: order.id, topic: "order.created", payload: order });
});Agora, ou as duas linhas são gravadas, ou nenhuma é. O evento não se perde e não existe evento sem pedido.
O relay
O relay lê eventos não publicados em ordem, publica e marca como publicados. Com PostgreSQL, FOR UPDATE SKIP LOCKED permite rodar mais de uma instância sem que duas peguem o mesmo lote.
async function relayBatch() {
await db.transaction(async (tx) => {
const batch = await tx.query(
`SELECT id, topic, payload FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED`,
);
for (const event of batch.rows) {
await broker.publish(event.topic, event.payload, { messageId: String(event.id) });
}
await tx.query(`UPDATE outbox SET published_at = now() WHERE id = ANY($1)`, [batch.rows.map((e) => e.id)]);
});
}Há duas formas comuns de alimentar o relay:
- Polling, como no exemplo: simples, sem dependências extras, com latência igual ao intervalo de consulta.
- Captura de mudanças (CDC), lendo o log de replicação do banco, por exemplo com replicação lógica no PostgreSQL. Tem menor latência e menos carga de consultas, ao custo de mais infraestrutura.
Para a maioria dos sistemas, começar com polling é a decisão certa.
Pelo menos uma vez, não exatamente uma vez
O relay pode publicar um evento e cair antes de marcá-lo como publicado. Na próxima execução, ele publica de novo. O outbox garante entrega pelo menos uma vez, e isso é intencional: perder um evento é muito pior do que entregá-lo duas vezes.
A consequência é que os consumidores precisam ser idempotentes. A forma mais direta é registrar os IDs de mensagens já processadas na mesma transação do efeito:
CREATE TABLE processed_messages (
consumer text NOT NULL,
message_id text NOT NULL,
PRIMARY KEY (consumer, message_id)
);
-- Na transação do consumidor: INSERT … ON CONFLICT DO NOTHING.
-- Se nenhuma linha foi inserida, a mensagem já foi processada e o efeito é ignorado.Ordenação
Se a ordem dos eventos importa, ela normalmente importa por agregado: os eventos de um mesmo pedido precisam chegar em sequência, mas pedidos diferentes podem ser processados em paralelo. Brokers que particionam por chave preservam essa ordem quando a chave de partição é o aggregate_id. Evite exigir ordem global: ela limita o paralelismo sem necessidade.
Manutenção da tabela
A tabela de outbox cresce continuamente. Apague, ou mova para armazenamento frio, eventos publicados há mais do que o período em que você poderia precisar reprocessá-los. Monitore também a idade do evento não publicado mais antigo: se ela cresce, o relay parou ou não está dando conta, e esse é um dos alertas mais úteis do sistema.
Resumo
O outbox transacional resolve um problema que retentativas e cuidado não resolvem: a impossibilidade de gravar atomicamente em dois sistemas. Com uma tabela de saída, um relay simples e consumidores idempotentes, eventos deixam de se perder, e os sistemas param de divergir silenciosamente.
Leituras relacionadas
- Arquitetura de software · 3 min de leitura
- Engenharia de backend · 3 min de leitura
- Engenharia de backend · 3 min de leitura