Fila de mensagens em produção: quando o Postgres basta, quando o RabbitMQ compensa e quando o Kafka se justifica

Escrito por

em

A pergunta costuma chegar deformada: “qual broker usar?”. A decisão real é outra — o que precisa acontecer depois que a mensagem é consumida. Se a resposta for “nada, o trabalho acabou”, metade das opções do mercado está sobrando. Se a resposta for “outro time vai querer ler o mesmo evento daqui a seis meses”, uma fila tradicional já está errada desde o primeiro dia.

O critério que separa os três modelos

Postgres com SKIP LOCKED, RabbitMQ e Kafka não são o mesmo produto em escalas diferentes. São três semânticas distintas.

Em uma fila de trabalho — Postgres ou RabbitMQ — a mensagem é um comando destinado a um consumidor. Consumida com sucesso, ela desaparece. O broker mantém estado por mensagem: pendente, em processamento, concluída, morta. É esse estado que permite retry individual, dead-letter queue e prioridade.

No Kafka, a mensagem é um fato registrado em um log append-only particionado. Ela não desaparece quando alguém lê: fica até a retenção expirar. O que avança é o offset de cada grupo de consumidores. Isso muda o que é barato e o que é caro. Reprocessar três dias de eventos é trivial — reposiciona o offset. Já dar retry em uma única mensagem que falhou, sem travar as que vieram atrás dela na mesma partição, é um problema que exige tópico de retry, DLQ manual e disciplina.

Quem escolhe Kafka para processar 200 jobs de geração de PDF por dia acaba reimplementando, em código de aplicação, o gerenciamento de estado por mensagem que o RabbitMQ entrega pronto. Quem escolhe RabbitMQ para distribuir eventos de domínio a cinco consumidores independentes acaba criando cinco filas espelhadas e descobrindo, no primeiro incidente, que não há como um consumidor novo ler o histórico.

Postgres como fila: o limite não é o throughput

Uma tabela de jobs com SELECT ... FOR UPDATE SKIP LOCKED sustenta com folga a faixa de centenas a poucos milhares de mensagens por segundo em hardware comum — muito acima do que a maioria dos sistemas de gestão, ERPs e plataformas B2B produz. O padrão é curto:

UPDATE jobs SET status = 'running', locked_at = now()
WHERE id IN (
  SELECT id FROM jobs
  WHERE status = 'pending' AND run_after <= now()
  ORDER BY priority DESC, id
  FOR UPDATE SKIP LOCKED
  LIMIT 20
)
RETURNING *;

A vantagem decisiva não é performance: é a transação. O job entra na mesma transação que cria o pedido. Ou os dois existem, ou nenhum existe. Com broker externo, esse acoplamento exige outbox pattern — uma tabela de eventos gravada na transação e um processo separado publicando no broker — o que reintroduz o Postgres no caminho de qualquer forma.

O limite real aparece em outro lugar. Filas geram alta rotatividade de linhas, e alta rotatividade gera bloat: a tabela de jobs vira a mais cara do autovacuum, e um pico de backlog não expurgado degrada o banco que atende as requisições dos usuários. Duas contramedidas resolvem a maior parte dos casos: mover jobs concluídos para uma tabela de histórico em vez de deixá-los acumulando, e ajustar autovacuum_vacuum_scale_factor especificamente nessa tabela, já que o padrão global é conservador demais para ela.

Ajuste por tabela, não globalmente: ALTER TABLE jobs SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_delay = 0); — em uma tabela com muitos DELETE/UPDATE, o fator padrão de 0.2 deixa o vacuum passar tarde demais.

O segundo limite é o fan-out. Distribuir o mesmo job para consumidores com ritmos diferentes na mesma tabela vira uma máquina de estados na aplicação. É aí que a fila em banco deixa de compensar — não quando o volume cresce.

RabbitMQ: roteamento e retry prontos, operação por conta da casa

O RabbitMQ entrega o que é trabalhoso de escrever à mão: ack e nack por mensagem, prefetch para controlar quanto cada worker puxa, dead-letter exchange, TTL, mensagens com prioridade e roteamento por topic exchange. Um worker que morre no meio do processamento devolve a mensagem para a fila sem nenhuma linha de código adicional — o canal caiu, o ack não veio, a mensagem volta.

O preço vem em dois itens. O primeiro é operacional: quorum queues replicadas exigem entender comportamento de partição de rede, e a decisão de memória e disco do nó não é auto-ajustável — um nó que atinge o watermark de memória aplica backpressure em todos os publishers, inclusive nos que estão publicando em filas saudáveis. O segundo é que a fila do RabbitMQ não é um histórico. Mensagem consumida, mensagem perdida. Quando alguém pergunta “quais pedidos passaram por esse fluxo na semana passada?”, a resposta precisa vir do banco, nunca do broker.

Há um detalhe que morde com frequência: prefetch alto em consumidores com tempo de processamento desigual produz filas invisíveis dentro do próprio worker. Trinta mensagens reservadas por um consumidor que travou ficam paradas até o timeout do canal, enquanto os outros workers estão ociosos. Para jobs longos e heterogêneos, prefetch 1 é o ponto de partida sensato, e valores maiores só se justificam quando o processamento é curto e uniforme.

Kafka: o log compensa quando há mais de um leitor

Kafka se justifica quando o mesmo evento interessa a consumidores independentes — o serviço de faturamento, o pipeline de analytics, o índice de busca — e quando reprocessar histórico é requisito, não exceção. Adicionar um quarto consumidor a um tópico existente não afeta os três que já estão lá, e ele pode começar a leitura do início da retenção.

O ordenamento é por partição, não por tópico. Quem precisa que eventos do mesmo pedido sejam processados em ordem particiona pela chave do pedido. Isso tem consequência direta: o paralelismo máximo de um grupo de consumidores é o número de partições. Vinte e quatro partições significam, no máximo, vinte e quatro consumidores ativos naquele grupo — o vigésimo quinto fica ocioso. E aumentar partições depois redistribui as chaves, o que quebra a ordem para as mensagens em trânsito.

O ponto mais subestimado é o retry. Uma mensagem envenenada trava a partição inteira enquanto o consumidor insiste nela. A saída conhecida é publicar a falha em um tópico de retry com atraso e seguir adiante, mas isso abre mão da ordem exatamente para os casos que falharam. Não existe configuração que resolva — é decisão de projeto.

A tabela e o erro que se repete

Critério Postgres (SKIP LOCKED) RabbitMQ Kafka
Semântica Comando Comando Fato/evento
Retry por mensagem Trivial Nativo Manual, com tópico extra
Múltiplos leitores independentes Ruim Exige fila por consumidor Nativo
Reprocessar histórico Só se você guardou Não Nativo
Atomicidade com o dado de negócio Direta Exige outbox Exige outbox
Custo operacional Baixo Médio Alto

O erro mais frequente não é escolher a opção errada — é escolher uma e usá-la com a semântica da outra. Publicar eventos de domínio em fila de trabalho e depois criar uma fila por consumidor, ou tratar tópico Kafka como fila de jobs e reimplementar ack por mensagem em cima do offset. Nos dois casos o sistema funciona por alguns meses e só cobra a conta quando entra o terceiro consumidor ou o primeiro incidente de reprocessamento.

O que fazer na segunda-feira

Para um sistema que ainda não tem broker: fila no Postgres, com tabela de histórico separada e autovacuum ajustado. Ela cobre e-mail, geração de relatório, webhook de saída e importação de arquivo pelo tempo que a maioria dos produtos leva para chegar à escala seguinte — e elimina uma dependência de infraestrutura enquanto o time ainda é pequeno.

Migre para RabbitMQ quando a máquina de estados dos jobs começar a acumular casos especiais — prioridade, atraso exponencial, roteamento por tipo. O sinal é o código da aplicação, não o gráfico de throughput.

Adote Kafka quando existir um segundo consumidor genuinamente independente do primeiro, com ritmo próprio e necessidade de ler histórico. Antes disso, o custo operacional não encontra contrapartida. E se a resposta for “vamos precisar dos dois”, o desenho que se sustenta é Kafka para eventos de domínio e fila de trabalho — Postgres ou RabbitMQ — para os comandos que cada consumidor gera a partir deles. São camadas diferentes, não concorrentes.

Comentários

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *