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