Pular para o conteúdo principal
Todos os Posts
Engineering12 min de leitura

O Transactional Outbox Não É uma Fila

O transactional outbox é um ledger, não uma fila. Tratá-lo como fila é o que quebra o Postgres sob carga. Este post percorre os modos de falha específicos — autovacuum travando, drift do horizonte xmin, lag do replication slot, poison pills — e as regras operacionais que realmente o mantêm funcionando em produção.

Tiarê Balbi BonaminiEngenheiro de Software · Vancouver
2/4

Todo time que vi adotar o padrão outbox parte da mesma premissa: o serviço precisa de um "write and publish" atômico, então largam uma tabela outbox ao lado das tabelas de domínio, inserem uma linha na mesma transação que a escrita de negócio, e deixam um poller empurrar essas linhas para o Kafka. Livro-texto. O diagrama cabe em um guardanapo.

Seis meses depois, o mesmo time está chamando alguém às 2 da manhã porque o pg_wal está em 87% no primário, a tabela outbox tem 40 GB de tuplas em sua maioria mortas, e o autovacuum está rodando contra ela há três horas sem terminar. O padrão não falhou. O modelo mental falhou.

O outbox não é uma fila. É um ledger de curta duração e escrita intensa que vive dentro do seu banco OLTP primário. Assim que você para de tratá-lo como RabbitMQ-em-uma-tabela e começa a tratá-lo como um pedaço de infraestrutura do Postgres com seu próprio orçamento operacional, a maior parte da dor desaparece. Este post é sobre o que essa mudança realmente significa.

Onde o modelo mental de fila quebra

A implementação canônica do outbox parece assim:

kotlin
@Transactional
fun placeOrder(cmd: PlaceOrder): Order {
    val order = orderRepository.save(cmd.toOrder())
    outboxRepository.save(
        OutboxEvent(
            aggregateType = "order",
            aggregateId = order.id,
            type = "OrderPlaced",
            payload = json.encodeToString(OrderPlaced(order)),
        )
    )
    return order
}

Um worker separado faz polling, publica no Kafka e marca as linhas como enviadas — ou, na variante CDC, o Debezium acompanha o WAL e emite cada linha inserida. De qualquer forma, a escrita de domínio e a "intenção de publicar" commitam atomicamente. Essa parte funciona. Essa parte não é o problema.

O problema é o que acontece após o commit. A tabela outbox agora absorve toda a taxa de escrita de todos os agregados do serviço. Em uma modesta taxa de 2k writes/segundo, isso é 172 milhões de linhas por dia. Se o consumidor está saudável, você insere uma linha e a deleta (ou marca) segundos depois. Se não está — se o Kafka está lento, se o poller está em deploy, se uma mensagem envenenada trava o worker — as linhas se acumulam. E no Postgres, linhas que ficam paradas sendo pesadamente escritas e deletadas não são uma condição benigna. São um risco operacional com quatro formatos distintos.

O autovacuum não consegue acompanhar. Cada delete ou update cria uma tupla morta. O autovacuum as recupera, mas é limitado por autovacuum_vacuum_cost_limit e compete com tráfego de foreground. Um outbox quente vai gerar tuplas mortas mais rápido do que as configurações padrão conseguem limpar, a tabela incha, as varreduras de índice ficam lentas, e o SELECT ... WHERE published_at IS NULL ORDER BY id LIMIT 100 do poller começa a levar 400ms em vez de 2ms. O poller fica mais atrasado. Tuplas mortas crescem mais rápido. Você construiu um loop de feedback.

O horizonte xmin se desloca. O Postgres só pode fazer vacuum de tuplas mais antigas que o xmin da transação mais antiga em execução. Uma consulta analítica de longa duração, um BEGIN esquecido em uma sessão psql, uma réplica travada com hot_standby_feedback = on — qualquer um destes trava o xmin e impede que o outbox seja limpo. Já vi uma consulta de relatório de 12 horas inchar uma tabela outbox de 200 MB para 18 GB. O write path estava bem. O vacuum simplesmente estava proibido de fazer seu trabalho.

O replication slot atrasa. Se você está usando Debezium ou qualquer consumidor de decodificação lógica, o replication slot dele retém WAL até que o consumidor o reconheça. O slot atrasa, o WAL não pode ser reciclado, o pg_wal enche o disco e o primário para de aceitar escritas. Essa falha é particularmente desagradável porque nada na tabela outbox em si está errado — a tabela é a ponta do iceberg; o estado real vive no WAL que o slot está retendo.

Poison pills param a linha. O poller processa linhas em ordem. Uma linha com JSON malformado, ou um tópico Kafka que não existe mais, ou um payload que dispara um bug de serializador, e o worker tenta novamente para sempre. Todas as linhas atrás dela esperam. O outbox agora é uma fila ilimitada com as próximas três horas de eventos de negócio, e seus consumidores não veem nada.

Nenhum desses é bug do padrão outbox. São o custo de rodar um ledger de escrita intensa dentro de um banco transacional, e não aparecem em tutoriais porque tutoriais não rodam a 2k writes/segundo por seis meses.

Repensando o outbox como ledger limitado

A mudança é esta: pare de raciocinar sobre o outbox como "uma fila que por acaso está no Postgres". Comece a raciocinar sobre ele como "uma tabela limitada, majoritariamente append, cujo trabalho é transferir linhas para fora do Postgres tão rápido quanto chegam".

Essa releitura muda o que você otimiza. O trabalho de uma fila é segurar coisas. O trabalho do outbox é não segurar coisas. Se as linhas estão se acumulando, algo já está errado — a resposta correta é alertar, não escalar a tabela. Cada decisão de design segue disso:

  • O outbox deve ser pequeno. As linhas devem viver segundos, não minutos. Se a profundidade em regime permanente for mais que alguns milhares de linhas, o consumidor é o gargalo e você precisa saber.
  • O outbox deve ser observável como infraestrutura. Você monitora profundidade, idade da linha mais antiga, lag do consumidor e saúde do vacuum — não apenas "o publisher está rodando".
  • O outbox deve falhar alto e seletivamente. Uma única linha envenenada não deve bloquear as outras noventa e nove.
  • O outbox não deve viver para sempre na mesma tabela. Linhas publicadas com sucesso devem sair — idealmente via DELETE, não uma flag published=true que transforma a tabela em cemitério.

Esse último ponto é o que a maioria dos times erra, e vale uma seção própria.

Aprofundamento Técnico

Delete, não marque

O primeiro instinto é adicionar uma coluna published_at TIMESTAMP e fazer UPDATE quando a linha for enviada. Isso está errado para uma tabela quente. Todo update é uma tupla morta. Você agora dobrou sua carga de vacuum — uma tupla morta do update, outra quando a linha for finalmente deletada — e seus índices crescem a cada passada. O padrão correto é:

sql
CREATE UNLOGGED TABLE outbox (
    id          BIGSERIAL PRIMARY KEY,
    aggregate_id UUID NOT NULL,
    type        TEXT NOT NULL,
    payload     JSONB NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

O consumidor lê um batch, publica e faz DELETE pela primary key. Sem coluna de flag, sem máquina de estados na linha. Se você precisa de uma trilha de auditoria do que foi publicado, essa é uma tabela diferente com padrões de acesso diferentes — escreva a partir do consumidor após o ack do Kafka, não da transação.

UNLOGGED merece uma pausa. Significa que a tabela não é escrita no WAL, o que aproximadamente dobra o throughput de insert e elimina o outbox dos streams de réplica. O trade-off: a tabela é truncada na recuperação de crash. Para um outbox puro baseado em poller, isso é sobrevivível — você perde eventos em voo que ainda não foram publicados, o que é o mesmo modo de falha de um consumidor que crashou no meio de um batch. Para um outbox baseado em Debezium/CDC, UNLOGGED não é opção porque CDC lê o WAL. Escolha seu modelo de consumidor primeiro, depois o tipo de tabela segue.

SKIP LOCKED para pollers paralelos

Um poller single-threaded é um ponto único de throughput. Escale horizontalmente com FOR UPDATE SKIP LOCKED:

sql
SELECT id, aggregate_id, type, payload
FROM outbox
ORDER BY id
LIMIT 500
FOR UPDATE SKIP LOCKED;

Cada worker pega um batch disjunto sem bloquear os outros. O detalhe crítico é manter a transação curta — ler, publicar, deletar, commitar. Segurar o lock enquanto publica no Kafka significa que um broker lento segura um lock de linha que bloqueia o próximo poller que estende xmin que bloqueia o vacuum. A cadeia inteira desata a partir de uma única chamada de rede lenta.

Se o seu producer Kafka suporta envios assíncronos com callbacks por mensagem, melhor ainda: faça batch nos envios, commite os deletes somente após todos os acks retornarem, e dimensione o batch de forma que a latência de publicação no pior caso caiba no seu orçamento de vacuum.

CDC muda os trade-offs, mas não os fundamentos

O Debezium com o Outbox Event Router é a versão mais limpa desse padrão — o consumidor nunca faz polling, o WAL é a fonte da verdade e a ordenação por agregado é preservada. O Debezium 3 em particular melhorou significativamente o throughput em tabelas movimentadas.

Mas CDC move o modo de falha, não o elimina. Em vez de um poller atrasando, agora um replication slot atrasa, e a consequência é pior: o WAL do primário não pode ser reciclado. max_slot_wal_keep_size (Postgres 13+) não é opcional — configure, alerte nele e entenda que quando ele disparar, seu slot está morto e você precisará refazer o snapshot. O Postgres 18 adicionou idle_replication_slot_timeout como complemento baseado em tempo: um slot inativo além do limite é invalidado automaticamente. Configure os dois — baseado em tamanho para retenção descontrolada de WAL, baseado em tempo para slots esquecidos. Escolha seu veneno: um poller que atrasa degrada a latência de publicação; um slot CDC que atrasa degrada o primário inteiro.

Uma regra aproximada: se sua taxa de outbox está abaixo de ~5k eventos/segundo e você quer simplicidade operacional, faça polling. Se você precisa de ordenação estrita por agregado com partition keys do Kafka e já roda Debezium, use CDC e aceite o custo de gerenciamento de slot. Não vá para CDC porque soa mais elegante.

Particione a tabela se ela for quente o suficiente

Em taxas altas de escrita, mesmo um outbox bem vacuumado pode sofrer de bloat de índice em id. Particionamento por range em created_at (diário ou horário) permite fazer DROP PARTITION em vez de DELETE para dados antigos, o que pula a dança das tuplas mortas inteiramente. É exagero para a maioria dos serviços. É um salva-vidas acima de ~10k inserts/segundo.

Armadilhas e Casos Extremos

Algumas coisas que não são óbvias até morderem:

A linha do outbox e a linha de domínio devem estar na mesma transação. Isso soa óbvio, mas já vi setups onde a escrita de domínio vai para uma conexão e a escrita do outbox para outra, ambas dentro de um método Spring @Transactional que por acaso cruzava dois DataSources. Funciona em staging. Dá race em produção. Use uma conexão, uma transação, ou use XA e entenda no que está se inscrevendo.

Não confie em índices published_at IS NULL. Um índice parcial em "linhas não publicadas" soa eficiente, mas à medida que a tabela cresce e o conjunto de não publicadas fica pequeno, as estatísticas do planner atrasam em relação à realidade e ocasionalmente ele escolhe um sequential scan. Ou delete na publicação (para não haver estado "não publicada" para indexar) ou use uma partição dedicada para linhas em voo.

Linhas envenenadas precisam de um sidecar. O design confiável mais simples: após N tentativas de publicação falhas, mova a linha para outbox_dead_letter em uma transação separada e delete do outbox. O poller principal nunca bloqueia em uma única mensagem ruim. Um processo separado — ou um humano — drena a DLQ. Pular essa etapa é como um único evento malformado derruba a publicação para o serviço inteiro.

O producer transacional do Kafka não é o mesmo que o outbox. O outbox resolve "escrita atômica no banco e intenção de publicar". As transações do Kafka resolvem "escrita atômica em múltiplas partições do Kafka". Você pode combiná-los, mas o outbox é o que dá exactly-once em relação ao seu banco, que é quase sempre a garantia que você realmente queria.

Evolução de schema dos payloads é um problema real. A linha do outbox captura uma serialização em um ponto do tempo; se você muda o schema de OrderPlaced, linhas ainda no outbox estão no schema antigo. Ou garanta que o consumidor consegue ler ambas as versões, ou drene o outbox antes do deploy. "Resolve no consumidor" é como você acaba com linhas impublicáveis que bloqueiam a tabela.

E uma nota operacional: inclua pg_stat_all_tables.n_dead_tup para a tabela outbox no seu monitoramento padrão. É um indicador antecedente para todos os modos de falha acima, e coletar não custa nada.

Conclusões Práticas

  • Trate o outbox como infraestrutura com orçamento operacional, não como estado da aplicação.
  • Delete linhas publicadas. Não as marque. Tuplas mortas são o inimigo.
  • Use FOR UPDATE SKIP LOCKED para escalar pollers horizontalmente, e mantenha a transação mais curta que a chamada de publicação.
  • Alerte em profundidade do outbox e idade da linha mais antiga, não apenas em "o publisher está de pé".
  • Observe o horizonte xmin. Consultas de longa duração em outros pontos do banco podem silenciosamente matar seu outbox.
  • Configure max_slot_wal_keep_size se você usa CDC. No Postgres 18+, configure também idle_replication_slot_timeout. Planeje para o dia em que um replication slot morre.
  • Dê às mensagens envenenadas um caminho de dead-letter. Uma linha ruim nunca deve bloquear o resto.
  • Considere UNLOGGED para outboxes baseados em poller. Considere particionamento acima de ~10k writes/segundo.
  • Monitore n_dead_tup na tabela outbox como um sinal de primeira classe.

Quando o outbox vale o trabalho

O padrão outbox não é quebrado. É um bom padrão. O que quebra é o modelo mental de que é "só uma fila". No momento em que você aceita que é um ledger de escrita intensa dentro do seu banco primário, o trabalho operacional que ele exige para de parecer overhead e começa a parecer o trabalho de verdade.

Use o outbox quando precisa de "write and publish" atômico e já está rodando Postgres com Kafka downstream. Pule quando sua taxa de escrita não justifica o peso operacional, ou quando um stream CDC eventualmente consistente direto das suas tabelas de domínio te dá o que precisa sem o hop extra. E quando usar: monitore como a infraestrutura crítica que é, não como uma tabela lateral que você escreveu uma vez e esqueceu.

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.