Um worker que processa cobranças, um endpoint que dispara e-mails, uma rotina de conciliação que não pode rodar duas vezes ao mesmo tempo. Em algum momento o time escreve a frase “precisamos de uma fila” e a discussão pula direto para RabbitMQ ou Redis, sem perguntar se o PostgreSQL que já está no ar resolve o problema. Na maioria dos sistemas abaixo de algumas centenas de jobs por segundo, resolve. A decisão real é onde o custo operacional compensa a latência e as garantias que você ganha.
O que cada opção realmente entrega
Há duas coisas diferentes sendo chamadas de “fila de Postgres”. A primeira é o LISTEN/NOTIFY, um mecanismo de notificação: o produtor emite um aviso e quem estiver conectado e escutando recebe. Não há persistência da mensagem. Se o consumidor estava desconectado no instante do NOTIFY, o aviso se perdeu. A segunda é a tabela de jobs com SELECT ... FOR UPDATE SKIP LOCKED, que é uma fila de verdade: o job fica gravado, tem estado, sobrevive a reinício e participa da mesma transação do dado de negócio.
Redis (com listas ou streams) e RabbitMQ entregam o que o Postgres não faz de graça: consumo com muito mais vazão, roteamento flexível, confirmação por mensagem, dead-letter nativo e fan-out para vários consumidores. Em troca, são mais um serviço para monitorar, atualizar, dimensionar e restaurar.
| Critério | Tabela + SKIP LOCKED | Broker dedicado (RabbitMQ/Redis Streams) |
|---|---|---|
| Consistência com o dado de negócio | Mesma transação: o job só existe se o commit aconteceu | Exige padrão outbox para não perder ou duplicar |
| Vazão | Boa até a ordem de milhares de jobs por segundo, conforme o hardware | Muito acima, com escala horizontal |
| Latência de entrega | Depende do polling (ou de NOTIFY como gatilho) | Push, na casa de milissegundos |
| Operação | Nenhum serviço novo | Cluster, filas, políticas, alertas |
| Fan-out e roteamento | Manual | Nativo |
A fila em tabela, sem mágica
O padrão que funciona em produção é pequeno. Uma tabela com estado, uma consulta que reserva o próximo job sem bloquear os demais consumidores e um prazo de visibilidade para recuperar quem morreu no meio.
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
tipo text NOT NULL,
payload jsonb NOT NULL,
estado text NOT NULL DEFAULT 'pendente',
tentativas int NOT NULL DEFAULT 0,
executar_em timestamptz NOT NULL DEFAULT now(),
reservado_ate timestamptz
);
CREATE INDEX jobs_fila_idx ON jobs (executar_em)
WHERE estado = 'pendente';
-- reserva atômica de um job
UPDATE jobs SET estado = 'em_execucao',
tentativas = tentativas + 1,
reservado_ate = now() + interval '5 minutes'
WHERE id = (
SELECT id FROM jobs
WHERE estado = 'pendente' AND executar_em <= now()
ORDER BY executar_em
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;
estado = 'pendente' é o que mantém a consulta barata quando a tabela acumula milhões de jobs concluídos. Sem ele, o planner varre histórico. Mova os concluídos para uma tabela de arquivo ou particione por data.Um processo separado devolve para pendente os jobs cujo reservado_ate venceu. Depois de N tentativas, o job vai para o estado morto e alguém é alertado. É o equivalente a um dead-letter, escrito em dez linhas.
Onde o NOTIFY entra
O polling a cada segundo adiciona até um segundo de latência e consome conexões à toa. O uso correto do NOTIFY é como gatilho, não como transporte: o produtor insere o job e emite o aviso na mesma transação; o consumidor acorda, executa a consulta de reserva e volta a dormir. Se o aviso se perder, o polling de segurança a cada 30 ou 60 segundos pega o que ficou para trás. A fila continua correta mesmo sem o NOTIFY; ele só a deixa rápida.
Duas ressalvas conhecidas. Conexões que escutam precisam ser sessões dedicadas, o que conflita com pooler em modo de transação, como o PgBouncer configurado dessa forma. E o NOTIFY tem limite de tamanho de payload, então mande só o id do job.
O argumento da consistência pesa mais que a vazão
O ganho que mais se subestima é a atomicidade. Com a fila na mesma base, “criar pedido e agendar o e-mail de confirmação” é uma única transação. Com um broker externo, existe uma janela entre o commit no banco e a publicação da mensagem em que a aplicação pode cair. O resultado é pedido sem e-mail, ou, pior, e-mail de pedido que sofreu rollback. A saída é o padrão outbox: gravar o evento numa tabela dentro da transação e ter um relé publicando no broker. Ou seja, quem usa broker acaba construindo uma tabela de jobs de qualquer forma, só que com mais uma peça.
O custo oculto do lado Postgres é carga. Jobs com muita escrita (atualizar estado, incrementar tentativas) geram bloat e pressão de autovacuum na mesma instância que atende o tráfego transacional. Fila de alto giro numa base compartilhada com o OLTP é o cenário em que esse modelo falha, e falha de forma lenta e difícil de diagnosticar.
Quando trocar
Os sinais concretos de que a tabela passou do ponto: a tabela de jobs aparece no topo de pg_stat_user_tables por tuplas mortas; o autovacuum não acompanha; mais de um sistema precisa consumir o mesmo evento; ou a latência exigida está abaixo do que o polling com NOTIFY entrega com folga. Nenhum deles é “o volume ainda é baixo, mas vai crescer”. Escalar para um cenário hipotético é como se paga complexidade antes da hora.
Retentativa e idempotência: o trabalho que nenhuma opção faz por você
Qualquer fila entrega ao menos uma vez, então o worker vai receber o mesmo job duas vezes em algum momento: o processo caiu depois de cobrar o cartão e antes de marcar o job como concluído, e a reserva expirou. A defesa é uma chave de idempotência por job, gravada junto do efeito colateral. Para cobrança, isso significa enviar ao gateway uma chave derivada do id do job, para que a segunda tentativa devolva o resultado da primeira em vez de cobrar de novo. Para e-mail, significa uma tabela de envios com restrição de unicidade no par destinatário e tipo de mensagem.
A política de retentativa também precisa de decisão explícita. Recuo exponencial com teto (por exemplo, 30 segundos, 2 minutos, 10 minutos) evita martelar um serviço externo que está fora do ar. Erros permanentes, como payload inválido, não devem ser repetidos: vão direto para o estado morto, com o motivo registrado. Misturar os dois tipos na mesma contagem de tentativas é a causa mais comum de fila entupida por um único job ruim.
Três métricas que avisam antes do incidente
Idade do job pendente mais antigo é a métrica principal: mede o atraso real percebido, independe de vazão e dispara antes de qualquer reclamação. Em seguida, quantidade de jobs mortos por tipo, que separa bug de código de instabilidade externa. Por fim, tempo de execução no percentil 95 por tipo, porque um job que dobrou de duração normalmente indica consulta degradada ou dependência lenta. As três saem de uma consulta SQL simples na abordagem em tabela, e de exportadores específicos no caso do broker. Quem opta pelo broker herda a tarefa de instrumentar isso.
Critério de escolha
Use tabela com SKIP LOCKED e NOTIFY como gatilho quando o job nasce de uma escrita no próprio banco, o volume está abaixo de alguns milhares por segundo e a equipe é pequena demais para operar um broker com disciplina. Esse é o caso de boa parte dos SaaS B2B. Use RabbitMQ quando houver vários consumidores independentes para o mesmo evento, roteamento por tipo ou prioridade, ou integração entre serviços de times diferentes. Use Redis Streams quando a prioridade for vazão e latência baixa e a perda eventual de uma mensagem, mitigada por reprocessamento idempotente, for aceitável. Em qualquer dos três, o consumidor precisa ser idempotente: entrega ao menos uma vez é o que todos oferecem na prática.
Deixe um comentário