Introdução ao padrão outbox para mensageria confiável
1. O Problema da Mensageria Não Confiável
Em sistemas distribuídos modernos, a comunicação assíncrona via filas de mensagens (como RabbitMQ, Apache Kafka ou Amazon SQS) é essencial para desacoplar serviços. No entanto, um problema crítico surge quando precisamos garantir que uma mensagem seja publicada exatamente quando uma transação de banco de dados é confirmada.
1.1. Riscos de inconsistência entre banco de dados e fila de mensagens
Considere um cenário típico de e-commerce: ao confirmar um pedido, o sistema precisa:
1. Salvar o pedido no banco de dados
2. Publicar uma mensagem "PedidoCriado" na fila para notificar outros serviços
Se a publicação na fila falhar após o banco ser atualizado, o sistema fica inconsistente — o pedido existe, mas ninguém foi notificado. Se a ordem for invertida (publicar antes de salvar), uma falha no banco gera uma mensagem órfã.
1.2. Falhas comuns: mensagens perdidas, duplicadas ou fora de ordem
As abordagens ingênuas sofrem de múltiplos problemas:
- Mensagens perdidas: quando a publicação na fila falha e não há retry
- Mensagens duplicadas: quando o envio é retentado mas a primeira tentativa realmente funcionou
- Mensagens fora de ordem: quando retentativas assíncronas reordenam eventos
1.3. A transação distribuída como abordagem frágil (XA/2PC)
O padrão XA (eXtended Architecture) com two-phase commit (2PC) tenta resolver isso coordenando transações entre banco e fila. Porém, essa abordagem:
- Introduz alta latência devido ao bloqueio de recursos
- É frágil em cenários de falha de rede
- Não é suportada por todos os sistemas de mensageria
- Viola o Teorema CAP em ambientes distribuídos
2. Conceito Central do Padrão Outbox
2.1. Definição e propósito: garantia de entrega atômica
O padrão outbox resolve esse problema de forma elegante: em vez de publicar a mensagem diretamente na fila, o sistema escreve a mensagem em uma tabela especial (outbox) dentro da mesma transação de banco de dados que altera o estado de negócio. Um processo separado lê essa tabela e publica as mensagens na fila.
2.2. Estrutura da tabela outbox: colunas essenciais
Uma tabela outbox típica tem a seguinte estrutura:
CREATE TABLE outbox_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
processed_at TIMESTAMP WITH TIME ZONE,
retry_count INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending'
);
CREATE INDEX idx_outbox_pending ON outbox_messages (status, created_at)
WHERE status = 'pending';
- aggregate_type/aggregate_id: identificam a entidade de negócio (ex: "order"/"12345")
- event_type: tipo do evento (ex: "OrderCreated")
- payload: dados do evento em formato JSON
- status: controla o ciclo de vida (pending, processing, completed, failed)
2.3. Diferença entre outbox e abordagens tradicionais de enfileiramento
Diferente do envio direto para a fila, o outbox:
- Garante que a mensagem só exista se a transação de negócio for confirmada
- Permite auditoria completa de todas as mensagens geradas
- Facilita reprocessamento em caso de falhas
- Não depende de disponibilidade do sistema de mensageria no momento da transação
3. Fluxo Básico de Implementação
3.1. Escrita da mensagem na tabela outbox dentro da mesma transação de negócio
-- Transação de negócio + outbox
BEGIN;
-- 1. Operação de negócio
INSERT INTO orders (id, customer_id, total)
VALUES ('order-123', 'cust-456', 299.99);
-- 2. Escrita na outbox (mesma transação)
INSERT INTO outbox_messages (
aggregate_type, aggregate_id, event_type, payload
) VALUES (
'order', 'order-123', 'OrderCreated',
'{"orderId": "order-123", "customerId": "cust-456", "total": 299.99}'
);
COMMIT;
3.2. Processo de polling para ler e publicar mensagens pendentes
-- Worker de publicação (exemplo simplificado em pseudocódigo)
WHILE true:
SELECT * FROM outbox_messages
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKED;
FOR each message:
BEGIN;
UPDATE outbox_messages SET status = 'processing' WHERE id = message.id;
result = publish_to_queue(message.event_type, message.payload);
IF result.success:
UPDATE outbox_messages SET status = 'completed', processed_at = NOW() WHERE id = message.id;
ELSE:
UPDATE outbox_messages SET retry_count = retry_count + 1 WHERE id = message.id;
IF retry_count > MAX_RETRIES:
UPDATE outbox_messages SET status = 'failed' WHERE id = message.id;
COMMIT;
WAIT 1 SECOND;
3.3. Remoção ou marcação de mensagens após confirmação de entrega
Mensagens processadas podem ser removidas periodicamente para evitar crescimento da tabela:
-- Limpeza de mensagens processadas há mais de 7 dias
DELETE FROM outbox_messages
WHERE status = 'completed'
AND processed_at < NOW() - INTERVAL '7 days';
4. Estratégias de Publicação a Partir da Outbox
4.1. Polling periódico com intervalo fixo
Vantagens: Simples de implementar, não requer ferramentas externas
Desvantagens: Latência determinada pelo intervalo de polling, carga constante no banco
4.2. Publicação baseada em eventos de log (CDC - Change Data Capture)
O CDC captura mudanças no log de transações do banco (WAL no PostgreSQL, binlog no MySQL) e as converte em eventos:
-- PostgreSQL WAL permite detectar inserts na tabela outbox
-- Ferramentas como Debezium escutam essas mudanças em tempo real
4.3. Uso de ferramentas como Debezium, Kafka Connect ou PostgreSQL logical replication
-- Configuração básica do Debezium para monitorar a tabela outbox
{
"name": "outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "localhost",
"database.port": "5432",
"database.dbname": "ecommerce",
"table.include.list": "public.outbox_messages",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter"
}
}
5. Garantias de Entrega e Idempotência
5.1. Entrega pelo menos uma vez vs. exatamente uma vez
O padrão outbox naturalmente oferece entrega pelo menos uma vez (at-least-once). Para aproximar-se de exatamente uma vez (exactly-once), é necessário implementar idempotência no consumidor.
5.2. Implementação de idempotência no consumidor (chave de idempotência)
-- Consumidor idempotente usando a chave do evento
def process_order_created(message):
event_id = message.id # UUID único da outbox
IF EXISTS(SELECT 1 FROM processed_events WHERE event_id = event_id):
RETURN # Já processado, ignorar
ELSE:
INSERT INTO processed_events (event_id) VALUES (event_id);
-- Lógica de negócio aqui
5.3. Tratamento de falhas na publicação: retry com backoff exponencial
-- Algoritmo de backoff exponencial
def calculate_backoff(retry_count):
base_delay = 1 # segundos
max_delay = 60 # segundos
delay = min(base_delay * (2 ** retry_count), max_delay)
return delay + random_uniform(0, delay * 0.1) # jitter
6. Considerações de Performance e Escalabilidade
6.1. Impacto da tabela outbox no banco de dados
- Índices: O índice parcial
WHERE status = 'pending'é crucial para consultas eficientes - Particionamento: Particionar por data de criação melhora performance e facilita limpeza
- Monitoramento: Acompanhar o tamanho da tabela e a latência de processamento
6.2. Estratégias de limpeza de mensagens processadas
-- Particionamento por mês para facilitar remoção
CREATE TABLE outbox_messages (
id UUID,
created_at TIMESTAMP,
-- demais colunas
) PARTITION BY RANGE (created_at);
CREATE TABLE outbox_2024_01 PARTITION OF outbox_messages
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
-- Remover partição antiga é um DDL rápido
DROP TABLE outbox_2023_12;
6.3. Escalabilidade horizontal com múltiplos workers de publicação
O uso de FOR UPDATE SKIP LOCKED permite que múltiplos workers concorram sem conflito:
-- Cada worker pega um lote diferente de mensagens
SELECT * FROM outbox_messages
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 50
FOR UPDATE SKIP LOCKED;
7. Padrões Vizinhos e Relacionados
7.1. Comparação com Saga e compensação transacional
Enquanto o outbox garante a publicação confiável de eventos, o padrão Saga gerencia transações de longa duração com etapas de compensação. Eles são complementares: o outbox pode ser usado para disparar os passos de uma Saga.
7.2. Relação com event sourcing e arquiteturas orientadas a eventos
No event sourcing, o banco de eventos é a fonte da verdade. O outbox pode ser visto como um precursor simples para sistemas que eventualmente evoluem para event sourcing completo.
7.3. Documentação da decisão via ADR (Architecture Decision Record)
# ADR-001: Uso do padrão outbox para mensageria
## Contexto
Precisamos garantir que mensagens sejam publicadas atomicamente com transações de banco.
## Decisão
Adotar o padrão outbox com CDC via Debezium para Kafka.
## Consequências
- Positivas: consistência forte entre banco e fila
- Negativas: complexidade operacional adicional
Referências
- Padrão Outbox na Documentação da Microsoft — Descrição oficial do padrão com diagramas e considerações de implementação
- Debezium Outbox Event Router — Documentação oficial do Debezium para implementação de outbox via CDC
- Transactional Outbox Pattern no Blog da AWS — Guia prático da AWS com exemplos em Node.js e PostgreSQL
- Udi Dahan - Reliable Messaging Without Distributed Transactions — Artigo seminal sobre mensageria confiável sem transações distribuídas
- Padrão Outbox no Martin Fowler — Descrição do padrão no catálogo de padrões de sistemas distribuídos
- Kafka Connect JDBC Source Connector — Documentação do Kafka Connect para polling de tabelas como fonte de eventos
- PostgreSQL Logical Replication Documentation — Documentação oficial do PostgreSQL sobre replicação lógica, base para implementações CDC