O sistema já tem Postgres, já tem uma fila de e-mails e webhooks para disparar, e alguém sugere subir um RabbitMQ “para fazer direito”. Outra pessoa responde que uma tabela com SKIP LOCKED resolve. As duas estão certas em algum contexto, e a decisão depende de três coisas: o volume, a consistência com o resto do dado e quem vai operar a infraestrutura.
O que a fila em Postgres entrega de graça
A vantagem principal é transacional. Se o job nasce na mesma transação que altera o dado de negócio, ele existe se e somente se o commit acontecer. Com um broker externo, esse par vira o problema clássico da dupla escrita: o pedido é gravado, a publicação falha, e o job se perde. Ou o inverso: a mensagem é publicada, o commit falha, e o consumidor processa algo que nunca existiu.
A segunda vantagem é operacional. Uma fila em tabela usa o backup, a replicação, o monitoramento e as permissões que você já tem. Inspecionar a fila é um SELECT; reprocessar um job é um UPDATE.
O padrão de consumo mais usado é este:
WITH proximo AS (
SELECT id
FROM jobs
WHERE status = 'pendente'
AND executar_em <= now()
ORDER BY executar_em
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'em_execucao',
tentativas = tentativas + 1,
travado_ate = now() + interval '5 minutes'
FROM proximo
WHERE jobs.id = proximo.id
RETURNING jobs.*;
(executar_em) WHERE status = 'pendente'. Sem ele, a consulta de polling varre a tabela inteira conforme o histórico cresce, e o custo aparece primeiro em lock e CPU, não em erro.Onde a fila em Postgres começa a doer
O primeiro limite é o polling. Sem LISTEN/NOTIFY, os workers perguntam ao banco a cada intervalo se há trabalho, o que adiciona latência ou carga. Com NOTIFY, a latência cai, mas a entrega não é garantida em caso de queda da conexão, então o polling de segurança continua necessário.
O segundo é a tabela inchada. Jobs concluídos geram linhas mortas, o autovacuum precisa acompanhar o ritmo, e uma tabela de fila com alto churn compete com as tabelas de negócio por I/O. Particionar por data e apagar partições antigas é a solução limpa, mas dá trabalho a mais.
O terceiro é o acoplamento de capacidade. Se os workers ocupam conexões demais, o banco principal sente. Em picos, quem paga é a aplicação inteira, e não só a fila.
Há também o problema da observabilidade. Em uma tabela, é fácil medir a idade do job pendente mais antigo, que é o melhor indicador de saúde da fila: se ele passa de alguns minutos num sistema que deveria processar em segundos, algo travou. Quem adota um broker precisa montar esse painel por outro caminho, com métricas de profundidade de fila, taxa de consumo e mensagens não confirmadas. Nenhum dos dois é difícil, mas é comum que só o segundo apareça no planejamento, e a fila vá para produção sem alarme algum.
Por fim, considere o custo de migração. Mover de uma fila em tabela para um broker é viável quando a interface está isolada. Mover no sentido contrário também, mas raramente vale o esforço. Isso sugere uma assimetria: errar para o lado simples custa pouco para corrigir, enquanto errar para o lado complexo cobra operação desde o primeiro dia.
O que um broker dedicado resolve
Um broker como RabbitMQ ou uma fila baseada em Redis resolve exatamente o que o Postgres faz mal: entrega por push, backpressure, roteamento, filas de retentativa e dead letter nativas. A latência do consumidor é baixa porque ele não faz polling, e o banco fica livre para o que importa.
O ponto de atenção é a durabilidade. No RabbitMQ, ela depende de filas duráveis, mensagens persistentes e confirmação do consumidor (ack). No Redis, depende da configuração de persistência, e uma fila sem isso pode perder jobs numa reinicialização. Ambos exigem que você trate a idempotência no consumidor, porque entrega “pelo menos uma vez” é o padrão realista.
A tabela abaixo resume as diferenças que costumam pesar na decisão:
| Critério | Fila em Postgres | Broker dedicado |
|---|---|---|
| Consistência com o dado de negócio | Transacional, na mesma transação | Exige outbox ou aceitar dupla escrita |
| Latência de entrega | Depende de polling ou NOTIFY | Push, tipicamente baixa |
| Roteamento e fan-out | Manual | Nativo (exchanges, tópicos) |
| Operação adicional | Nenhuma além do banco | Novo serviço, monitoramento, upgrade |
| Teto de escala | Limitado pelo banco principal | Escala de forma independente |
| Retentativa e dead letter | Você implementa | Recursos nativos |
O meio-termo: outbox com broker
Quando a consistência importa e o fan-out também, o padrão outbox une os dois mundos. A aplicação grava o evento em uma tabela outbox na mesma transação do dado, e um processo separado lê essa tabela e publica no broker, marcando cada linha como enviada.
BEGIN;
UPDATE pedidos SET status = 'pago' WHERE id = 42;
INSERT INTO outbox (agregado, evento, payload)
VALUES ('pedido', 'pedido_pago', '{"id": 42}');
COMMIT;
Você troca um pouco de complexidade por uma garantia sólida: nenhum evento se perde, e o broker cuida da distribuição. O preço é que o consumidor receberá duplicatas em caso de falha entre a publicação e a marcação, o que exige idempotência. Uma chave de evento única, checada antes do efeito colateral, resolve a maior parte dos casos.
Retentativa, jobs travados e idempotência
Seja qual for a infraestrutura, três perguntas definem se a fila é confiável. O que acontece quando o worker morre no meio de um job? Quantas vezes o job será reexecutado? E o que impede que a reexecução repita o efeito colateral?
Na fila em tabela, a resposta à primeira é o campo travado_ate do exemplo anterior: um job em execução cujo prazo expirou volta a ser elegível. Um processo de varredura periódico, ou a própria consulta de consumo, trata status = 'em_execucao' AND travado_ate < now() como pendente. O prazo precisa ser maior que o pior tempo de execução legítimo, senão dois workers processam o mesmo job ao mesmo tempo.
A segunda pergunta pede um teto de tentativas e um destino para quem estoura: um status falhou com a última mensagem de erro, para alguém olhar. Backoff exponencial, aplicado ao atualizar executar_em, evita martelar um serviço externo que já está fora do ar.
UPDATE jobs
SET status = CASE WHEN tentativas >= 5 THEN 'falhou' ELSE 'pendente' END,
executar_em = now() + (interval '30 seconds' * power(2, tentativas)),
ultimo_erro = $2
WHERE id = $1;
A terceira pergunta é a mais negligenciada. Enviar e-mail, cobrar cartão e chamar webhook são operações que não se desfazem. A defesa é uma chave de idempotência por job, gravada antes do efeito e consultada na reexecução, ou a própria API externa aceitando um identificador de requisição. Sem isso, qualquer fila, a mais cara que seja, entrega duplicata em algum dia ruim.
No broker dedicado, o mecanismo muda, mas o raciocínio é o mesmo: o ack só deve acontecer depois do efeito concluído, e a mensagem não confirmada será reentregue. Cabe ao consumidor tolerar a repetição.
Critério de escolha
Ficaria com a fila em Postgres quando: o volume é de dezenas a poucas centenas de jobs por segundo no pico, o time é pequeno, os jobs são disparados por mudanças de dado e a equipe não tem quem opere um broker com tranquilidade. Nesse cenário, o ganho de simplicidade supera o custo de polling, e a consistência transacional evita uma categoria inteira de bugs.
Escolheria um broker dedicado quando: há vários consumidores independentes para o mesmo evento, a latência precisa ser baixa e previsível, o volume pode crescer muito além do que o banco principal tolera, ou a fila atende sistemas diferentes que não compartilham banco.
E usaria outbox com broker quando o sistema já passou do primeiro cenário, mas ainda não pode abrir mão da garantia de que “se gravou, publica”. É a combinação que mais costuma sobreviver ao crescimento sem reescrita.
Uma regra prática para não errar: comece pela fila em tabela, mas desenhe a interface do produtor e do consumidor como se houvesse um broker atrás. Se a aplicação enfileira por meio de uma função enfileirar(tipo, payload) e os handlers são idempotentes, trocar a implementação depois é uma mudança local, não uma reforma.
Deixe um comentário