O time precisa enviar e-mails de confirmação, gerar PDFs e chamar três APIs de terceiros depois que um pedido é gravado. O banco já é Postgres. A pergunta que aparece na revisão de arquitetura é se vale subir um Redis só para ter fila, ou se uma tabela jobs resolve. A resposta depende de duas coisas que raramente são medidas antes da decisão: quantos jobs por segundo no pico e se o job precisa nascer na mesma transação do dado de negócio.
O que a fila em Postgres entrega de graça
O ganho central é atomicidade. Quando o pedido e o job são inseridos na mesma transação, não existe a janela em que o pedido foi commitado e o enfileiramento falhou, nem o inverso. Com Redis como fila, essa garantia exige padrão outbox: gravar o evento numa tabela, ter um processo que o lê e publica, e tratar duplicidade no consumidor. É mais uma peça para operar, e é exatamente a peça que costuma ser esquecida até o primeiro incidente de pedido sem e-mail.
O segundo ganho é visibilidade. Job é linha de tabela: dá para fazer SELECT com filtro por status, tempo de espera e tipo, juntar com a tabela de clientes e montar o painel de fila sem ferramenta adicional. Reprocessar um lote é um UPDATE. Backup, replicação e retenção já estão resolvidos porque são os do banco.
O mecanismo que torna isso viável é o FOR UPDATE SKIP LOCKED: cada worker pega uma linha pendente sem esperar as que outro worker já travou.
WITH proximo AS (
SELECT id FROM jobs
WHERE status = 'pendente' AND executar_em <= now()
ORDER BY prioridade DESC, id
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs SET status = 'rodando', iniciado_em = now(), tentativas = tentativas + 1
FROM proximo WHERE jobs.id = proximo.id
RETURNING jobs.*;
Índice parcial em (prioridade DESC, id) WHERE status = 'pendente' mantém a consulta barata mesmo com milhões de jobs concluídos na tabela.
Onde Redis com BullMQ ganha
Throughput e latência de entrega. Uma fila em memória entrega o job ao worker em milissegundos, sem polling e sem pressão sobre o banco principal. Em Postgres, a alternativa ao polling é LISTEN/NOTIFY, que funciona bem para acordar workers, mas não substitui a leitura transacional e tem limites quando há muitas conexões escutando. Para ordem de grandeza: uma tabela bem indexada sustenta milhares de jobs por segundo em hardware modesto, e o ponto de dor aparece quando os jobs são muitos, curtos e disputam as mesmas páginas e o mesmo autovacuum das tabelas de negócio. O número exato varia com o tamanho da linha, o hardware e a configuração, então meça com a carga real.
O segundo ponto a favor do Redis é o recurso pronto: limitação de taxa por fila, jobs repetitivos, atraso, prioridade, dependência entre jobs e fluxos pai-filho. Dá para reproduzir tudo isso em SQL, mas cada item vira código seu para manter.
O custo é a durabilidade. Redis persiste por snapshot ou por AOF, e com AOF em everysec uma queda pode perder cerca de um segundo de escritas. Para job de e-mail, aceitável. Para job que representa cobrança, não.
O problema que ninguém mede: churn de linhas
Fila em Postgres é a carga mais hostil para o MVCC. Cada job é inserido, atualizado uma ou duas vezes e removido ou arquivado. Isso gera tuplas mortas em volume, e o autovacuum precisa acompanhar. Três práticas evitam o pior cenário:
- Separar a tabela de fila da tabela de histórico e mover os concluídos em lote, ou particionar por dia e descartar partições antigas.
- Ajustar
autovacuum_vacuum_scale_factorsó nessa tabela, com valor bem menor que o padrão. - Evitar transações longas no mesmo banco, porque uma transação aberta há uma hora impede a limpeza das tuplas mortas da fila inteira.
Quando o time ignora isso, o sintoma é uma consulta de dequeue que levava 2 ms e passa a levar centenas, sem nenhuma mudança de código.
Retry, idempotência e job travado
Independente da tecnologia, o worker vai morrer no meio de um job, e o sistema precisa decidir o que acontece depois. Em Postgres, a convenção é gravar iniciado_em e ter um processo que devolve para pendente os jobs rodando há mais que o timeout, com contador de tentativas e fila morta ao estourar o limite. Em BullMQ existe o conceito de lock renovável e de jobs “stalled”, com comportamento equivalente.
O ponto que importa: em ambos os casos a entrega é pelo menos uma vez. O job pode rodar duas vezes. Todo handler precisa ser idempotente, seja com chave de idempotência enviada à API externa, seja com verificação de estado antes de agir. Quem escolhe Redis esperando “exatamente uma vez” está comprando uma garantia que nenhuma das duas oferece.
Operação: o que cada opção cobra no plantão
O custo operacional é onde a comparação costuma ficar desonesta, porque o Redis aparece como “simples” até alguém precisar mantê-lo. Um Redis de fila em produção exige decisão sobre persistência, política de memória (noeviction é a única segura para fila, senão chaves de jobs podem ser descartadas sob pressão), failover e monitoramento de memória. Em serviço gerenciado, boa parte disso vira cobrança mensal, e o volume de memória cresce com o backlog: uma fila que acumula por falha de um consumidor pode encher a instância.
A fila em Postgres herda a operação que o time já tem, mas compartilha recursos com o tráfego principal. Um backlog que dispara numa madrugada de importação compete por I/O e conexões com as consultas dos usuários. A mitigação é limitar o número de workers e, se o volume crescer, apontar a fila para um banco separado, mantendo a mesma interface.
| Critério | Postgres (SKIP LOCKED) | Redis (BullMQ) |
|---|---|---|
| Job na mesma transação do dado | Nativo | Exige outbox |
| Latência de entrega | Polling ou NOTIFY, dezenas a centenas de ms | Milissegundos |
| Durabilidade | A do banco | Depende de AOF e réplica |
| Rate limit, fluxos, repetição | Você implementa | Pronto |
| Consulta ad hoc da fila | SQL comum | Painel ou comandos Redis |
| Risco principal | Churn e autovacuum | Memória e perda na queda |
Um cenário comum para calibrar: 40 jobs por segundo no pico, cada um com duração média de 300 ms, exige em torno de 12 workers concorrentes (40 × 0,3). Isso são 12 conexões de banco ocupadas só pela fila, o que num Postgres com pool de 50 já é um quarto da capacidade. Aqui a conta pesa a favor do Redis ou de um pool dedicado. Com 5 jobs por segundo, a mesma conta dá dois workers, e a fila em Postgres é irrelevante para o orçamento do banco.
Critério de decisão
Postgres como fila quando: o volume de pico fica abaixo de algumas centenas de jobs por segundo, o job precisa ser consistente com a transação que o originou, o time é pequeno e cada componente novo é um plantão a mais. É o caso da maioria dos sistemas de gestão, e-commerce de porte médio e SaaS B2B.
Redis com BullMQ quando: o volume é alto e em rajadas, a latência entre o evento e a execução importa em tempo de segundos ou menos, há necessidade real de rate limit, fluxos de jobs dependentes ou agendamentos recorrentes complexos, e a perda de uma janela curta de escritas é tolerável ou mitigada com outbox.
A escolha mais comum e mais defensável para um time de até dez pessoas é começar em Postgres, isolar o enfileiramento atrás de uma interface de duas funções (enfileirar e consumir) e migrar quando o autovacuum ou o volume provarem que é hora. A interface pequena é o que torna a migração um projeto de uma semana em vez de um semestre. Migrar por antecipação, sem métrica, costuma trocar um problema que o time sabe resolver por outro que ainda não conhece.
Deixe um comentário