Fila de jobs em Postgres ou Redis: onde o SKIP LOCKED deixa de bastar

Escrito por

em

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.

Na prática

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_factor só 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.

Comentários

Deixe um comentário

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