Outbox Pattern
Como garantir a publicação confiável de eventos em sistemas distribuídos usando transactional outbox.
Que problema resolve
Em sistemas baseados em eventos, um microsserviço precisa fazer duas coisas quando processa uma operação: salvar dados no seu banco de dados e publicar um evento para notificar outros serviços. O problema é que essas são duas operações em dois sistemas diferentes (banco de dados e message broker), e não existe uma transação atômica que cubra ambas.
O problema do dual write
Serviço de Pedidos:
1. Salvar pedido no PostgreSQL ✅
2. Publicar "PedidoCriado" no Kafka ❌ (falha de rede)
Resultado: O pedido existe no banco de dados,
mas nenhum outro serviço ficou sabendo.
Esse cenário se chama dual write (escrita dupla) e pode causar:
- Dados salvos, evento perdido: O pedido existe, mas o estoque nunca foi reservado
- Evento publicado, dados não salvos: Outros serviços reagem a um pedido que não existe
- Inconsistência silenciosa: O sistema parece funcionar, mas os dados entre serviços divergem
Tentar resolver isso com “publicar primeiro, salvar depois” apenas inverte o problema. E usar transações distribuídas (2PC) introduz latência e pontos de falha.
Cenários reais onde acontece
| Cenário | Consequência sem Outbox |
|---|---|
| Criar pedido + notificar estoque | Estoque não é reservado, vende-se produto esgotado |
| Processar pagamento + notificar envios | Pagamento cobrado, mas envio nunca é agendado |
| Atualizar perfil + notificar serviços | Dados dessincronizados entre serviços |
| Concluir passo N da saga + publicar evento | Saga fica travada em passo intermediário |
Como funciona
O Outbox Pattern resolve o dual write usando uma única transação de banco de dados para ambas as operações. Em vez de publicar o evento diretamente no broker, o serviço grava o evento em uma tabela outbox dentro da mesma transação que salva os dados de negócio.
Fluxo básico
┌─────────────────────────────────────────────┐
│ Transação de banco de dados (atômica) │
│ │
│ 1. INSERT INTO pedidos (...) │
│ 2. INSERT INTO outbox (evento, payload...) │
│ │
│ COMMIT │
└─────────────────────────────────────────────┘
│
│ (processo separado)
▼
┌─────────────────────┐ ┌──────────────┐
│ Relay / Publisher │────►│ Kafka / │
│ (lê tabela outbox) │ │ RabbitMQ │
└─────────────────────┘ └──────────────┘
A chave é que ambas as escritas (dados de negócio + evento) acontecem na mesma transação de banco de dados. Se a transação falhar, nenhuma das duas é persistida. Se tiver sucesso, ambas são persistidas. Não há janela de inconsistência.
Estrutura da tabela outbox
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(255) NOT NULL, -- "Pedido", "Pago"
aggregate_id VARCHAR(255) NOT NULL, -- ID del agregado
event_type VARCHAR(255) NOT NULL, -- "PedidoCreado"
payload JSONB NOT NULL, -- datos del evento
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP NULL -- NULL = pendiente
);
-- Índice para el publisher
CREATE INDEX idx_outbox_pending
ON outbox (created_at)
WHERE published_at IS NULL;
Exemplo de uso em código
// Dentro de una transacción de base de datos
BEGIN;
-- 1. Operación de negocio
INSERT INTO pedidos (id, cliente_id, total, estado)
VALUES ('ped-001', 'cli-789', 150.00, 'confirmado');
INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio)
VALUES ('ped-001', 'prod-42', 2, 75.00);
-- 2. Evento en la tabla outbox (misma transacción)
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES (
'Pedido',
'ped-001',
'PedidoConfirmado',
'{"pedidoId": "ped-001", "clienteId": "cli-789", "total": 150.00}'
);
COMMIT;
-- Ambas escrituras son atómicas: o se guardan las dos, o ninguna
Estratégias de publicação
Há duas formas principais de ler a tabela outbox e publicar os eventos no broker:
Polling Publisher (Publicador por sondagem)
Um processo periódico consulta a tabela outbox em busca de eventos não publicados, os envia ao broker e os marca como publicados.
┌──────────────┐ ┌──────────┐
│ Outbox │ SELECT * FROM │ Polling │
│ Table │◄─── outbox WHERE ──│ Publisher│
│ │ published_at NULL │ │
└──────────────┘ └────┬─────┘
│
Publicar evento
│
▼
┌──────────┐
│ Broker │
└──────────┘
Vantagens: Simples de implementar, não requer infraestrutura adicional. Desvantagens: Latência (depende do intervalo de polling), carga no banco de dados.
Implementação típica do polling:
// Cada 500ms (configurable)
loop {
eventos = SELECT * FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED; -- evita conflictos entre publishers
for evento in eventos {
broker.publish(evento.event_type, evento.payload);
UPDATE outbox
SET published_at = NOW()
WHERE id = evento.id;
}
}
Change Data Capture (CDC)
Um conector CDC (como o Debezium) monitora o log de transações do banco de dados e captura automaticamente as inserções na tabela outbox.
┌──────────────┐ ┌──────────┐
│ PostgreSQL │ WAL / binlog │ Debezium │
│ Transaction │──────────────────► │ (CDC) │
│ Log │ └────┬─────┘
└──────────────┘ │
Publicar evento
│
▼
┌──────────┐
│ Kafka │
└──────────┘
Vantagens: Latência muito baixa (quase real-time), não gera carga adicional com queries. Desvantagens: Requer infraestrutura adicional (Debezium, Kafka Connect), mais complexo de operar.
Comparação de estratégias
| Aspecto | Polling | CDC |
|---|---|---|
| Latência | 100ms - 5s (configurável) | < 100ms (quase real-time) |
| Complexidade | Baixa (um cron ou processo simples) | Alta (Debezium + Kafka Connect) |
| Carga no BD | Queries periódicas | Lê do WAL, sem queries extras |
| Infraestrutura | Mínima | Debezium, Kafka Connect, Zookeeper |
| Escalabilidade | Limitada pelo BD | Alta (CDC é muito eficiente) |
| Recomendado para | Volume baixo-médio | Volume alto, baixa latência |
Garantias de entrega
O Outbox Pattern garante at-least-once delivery (entrega ao menos uma vez). Isso significa que um evento pode ser publicado mais de uma vez. Por isso, os consumidores devem ser idempotentes.
Cenário de duplicação:
1. Publisher lê evento da outbox
2. Publisher publica evento no Kafka ✅
3. Publisher falha antes de marcar como publicado ❌
4. Publisher reinicia e publica novamente o mesmo evento
Solução: O consumidor detecta o duplicado pelo ID do evento
e o ignora (Idempotency Pattern).
Limpeza da tabela outbox
A tabela outbox cresce continuamente. Você precisa de uma estratégia de limpeza:
-- Opción 1: Borrar eventos publicados hace más de 7 días
DELETE FROM outbox
WHERE published_at IS NOT NULL
AND published_at < NOW() - INTERVAL '7 days';
-- Opción 2: Particionado por fecha (más eficiente)
-- Crear particiones mensuales y dropear particiones antiguas
CREATE TABLE outbox (
...
) PARTITION BY RANGE (created_at);
-- Opción 3: Mover a tabla de archivo antes de borrar
INSERT INTO outbox_archive SELECT * FROM outbox
WHERE published_at < NOW() - INTERVAL '30 days';
Vantagens
- Consistência garantida: Dados e eventos são gravados na mesma transação, eliminando o dual write
- Sem transações distribuídas: Você não precisa de 2PC nem XA transactions
- Auditoria natural: A tabela outbox funciona como log de todos os eventos publicados
- Resiliência: Se o broker estiver fora do ar, os eventos se acumulam e são publicados quando ele voltar
- Desacoplamento temporal: O serviço não depende da disponibilidade do broker
- Compatível com qualquer banco de dados relacional: Você só precisa de uma tabela adicional
Trade-offs / Desvantagens
- Latência adicional: Os eventos não são publicados instantaneamente (especialmente com polling)
- Complexidade operacional: Você precisa manter o publisher ou o conector CDC
- A tabela outbox cresce: Você precisa de uma estratégia de limpeza periódica
- At-least-once: Os consumidores devem lidar com duplicados (idempotência obrigatória)
- Acoplamento com o banco de dados: O CDC depende de features específicas do engine (WAL, binlog)
- Overhead na escrita: Cada operação grava uma linha adicional na tabela outbox
Quando usar
- Sistemas event-driven onde a confiabilidade dos eventos é crítica
- Microsserviços que precisam notificar mudanças a outros serviços de forma confiável
- Quando você implementa o Saga Pattern e precisa garantir que os eventos de cada passo sejam publicados
- Sistemas financeiros ou de e-commerce onde perder um evento tem impacto no negócio
- Quando você já usa um banco de dados relacional e quer evitar transações distribuídas
- Fluxos onde a consistência eventual é aceitável, mas a perda de eventos não
Quando evitar
- Sistemas que exigem publicação de eventos em tempo real estrito (microssegundos)
- Quando você usa um banco de dados que não suporta transações ACID (alguns NoSQL)
- Aplicações simples onde publicar diretamente no broker é suficiente
- Sistemas com volume de eventos muito baixo, onde a complexidade não se justifica
- Quando você já tem Event Sourcing implementado (o event store já é a sua “outbox”)
Tecnologias e implementações comuns
| Categoria | Opções |
|---|---|
| CDC | Debezium, Maxwell (MySQL), AWS DMS |
| Brokers | Apache Kafka, RabbitMQ, Amazon SNS/SQS, Azure Service Bus |
| Bancos de dados | PostgreSQL (WAL), MySQL (binlog), SQL Server (CT) |
| Frameworks | Eventuate Tram, MassTransit Outbox (.NET), Wolverine (.NET) |
| Conectores | Kafka Connect, Debezium Server |
| Limpeza | Cron jobs, pg_partman (particionamento por data) |
Relação com outros padrões
O Outbox Pattern é uma peça fundamental na infraestrutura de sistemas event-driven:
┌─────────────────────────────────────────────────┐
│ Outbox en el ecosistema │
│ │
│ ┌──────────┐ necesita ┌──────────────────┐ │
│ │ Saga │ ──────────► │ Outbox Pattern │ │
│ │ Pattern │ publicar │ (este patrón) │ │
│ └──────────┘ eventos └────────┬─────────┘ │
│ │ │
│ entrega al │
│ menos una vez │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Idempotency │ │
│ │ Pattern │ │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ usa ┌──────────────┐ │
│ │ Event-Driven │ ────────►│ Outbox │ │
│ │ Architecture │ para │ (confiable) │ │
│ └──────────────┘ eventos └──────────────┘ │
└─────────────────────────────────────────────────┘
- Saga Pattern: Cada passo de uma saga precisa publicar eventos de forma confiável. O Outbox garante que nenhum evento se perca entre os passos
- Idempotency Pattern: Como o Outbox garante at-least-once, os consumidores devem ser idempotentes para lidar com duplicados
- Event-Driven Architecture: O Outbox é a infraestrutura que torna confiável a publicação de eventos em uma arquitetura event-driven
- Circuit Breaker: Se o publisher da outbox não conseguir conectar ao broker, o circuit breaker evita retentativas infinitas
- DDD: Os Domain Events que cada bounded context gera são publicados de forma confiável através da outbox
Erros comuns
| Erro | Consequência | Solução |
|---|---|---|
| Não limpar a tabela outbox | Tabela cresce sem limite, queries lentas | Cron de limpeza ou particionamento |
| Publisher sem idempotência | Eventos duplicados no broker | Consumidores idempotentes + dedup |
| Payload grande demais | Tabela outbox ocupa muito espaço | Salvar apenas IDs, consumidor consulta os detalhes |
| Um único publisher | Gargalo em alto volume | Múltiplos publishers com SKIP LOCKED |
| Não monitorar o lag | Eventos se acumulam sem serem detectados | Alertas por tamanho da fila pendente |
Próximos passos
Com a publicação confiável de eventos resolvida, o próximo desafio é proteger seus serviços quando as dependências falham. O Circuit Breaker é o padrão que evita que um serviço fora do ar arraste todo o sistema. Explore também o Idempotency Pattern para garantir que os consumidores dos seus eventos lidem corretamente com os duplicados.