Fila no Postgres com SKIP LOCKED, ou serviço de fila: qual escolher
Dois workers pegando a mesma tarefa é o defeito clássico de fila caseira. A cláusula que resolve, e quando vale trocar o banco por um serviço.
O erro tratado aqui
ERROR: deadlock detected — Process 18442 waits for ShareLock on transaction 9912Ambiente testado
- PostgreSQL 15
- Node 22
- Prisma 6.5
- Upstash QStash
Contexto: o que estava rodando
Uma fila de envio de e-mails transacionais. Uma tabela tarefas, um punhado de
trabalhadores puxando o que está pendente, processando e marcando como feito.
O primeiro desenho foi o que todo mundo escreve:
const tarefa = await prisma.tarefa.findFirst({
where: { status: 'pendente' },
orderBy: { criadaEm: 'asc' },
});
if (tarefa) {
await prisma.tarefa.update({
where: { id: tarefa.id },
data: { status: 'processando' },
});
await processar(tarefa);
}Os erros
Com um trabalhador, perfeito. Com quatro, dois problemas:
worker-2 processando tarefa 8841 → e-mail enviado para ana@exemplo.com
worker-4 processando tarefa 8841 → e-mail enviado para ana@exemplo.comE, ao tentar consertar com transações:
PostgresError: deadlock detected
detail: 'Process 18442 waits for ShareLock on transaction 9912;
blocked by process 18449.
Process 18449 waits for ShareLock on transaction 9915;
blocked by process 18442.'
code: '40P01'Diagnóstico
Ler e depois atualizar é uma corrida
Entre o findFirst e o update existe uma janela. Quatro trabalhadores
consultando ao mesmo tempo recebem a mesma linha, porque nenhum deles marcou
nada ainda. Todos atualizam, todos processam.
A gravidade depende do que a tarefa faz. E-mail duplicado é constrangedor; cobrança duplicada é outro tipo de problema.
FOR UPDATE resolve a corrida e cria a fila de espera
O reflexo seguinte é bloquear a linha na leitura:
select * from tarefas where status = 'pendente'
order by criada_em limit 1
for update;
Agora só um trabalhador pega a linha. Os outros três esperam ela ser liberada — e, quando é, descobrem que já não está pendente e voltam a consultar. Quatro trabalhadores serializados em um.
Pior: quando cada um bloqueia linhas em ordem diferente, dois acabam esperando um
pelo outro. É o deadlock detected acima — o Postgres percebe o ciclo e mata uma
das transações.
SKIP LOCKED é a peça que faltava
A cláusula existe exatamente para este caso: em vez de esperar por uma linha bloqueada, pule para a próxima.
select * from tarefas where status = 'pendente'
order by criada_em limit 1
for update skip locked;
Quatro trabalhadores recebem quatro linhas diferentes, sem espera e sem deadlock. Não é truque nem gambiarra: é a construção que o Postgres oferece para fila.

A solução
-
A tabela, com o que uma fila de verdade precisa.
migracao/01-fila.sqlcreate table tarefas ( id bigint generated always as identity primary key, tipo text not null, carga jsonb not null, status text not null default 'pendente', -- Permite agendar para o futuro e adiar depois de uma falha. rodar_em timestamptz not null default now(), tentativas int not null default 0, max_tentativas int not null default 5, -- Prazo de visibilidade: se o worker morrer, a tarefa volta sozinha. travada_ate timestamptz, ultimo_erro text, criada_em timestamptz not null default now() ); -- Índice parcial: só o que está por fazer. Fica pequeno mesmo com milhões -- de tarefas concluídas na tabela. create index tarefas_prontas_idx on tarefas (rodar_em) where status = 'pendente'; -
Puxar um lote de forma atômica.
update ... returningcombinado com oselect ... skip lockedfaz reserva e leitura numa única viagem ao banco:consultas/puxar-tarefas.sqlupdate tarefas set status = 'processando', travada_ate = now() + interval '5 minutes', tentativas = tentativas + 1 where id in ( select id from tarefas where status = 'pendente' and rodar_em <= now() order by rodar_em limit $1 for update skip locked ) returning *;src/worker/puxar.tsexport async function puxarTarefas(quantidade = 10) { return prisma.$queryRaw<Tarefa[]>` update tarefas set status = 'processando', travada_ate = now() + interval '5 minutes', tentativas = tentativas + 1 where id in ( select id from tarefas where status = 'pendente' and rodar_em <= now() order by rodar_em limit ${quantidade} for update skip locked ) returning *`; } -
Concluir, adiar ou desistir.
src/worker/processar.tsexport async function rodarLote() { const tarefas = await puxarTarefas(10); for (const tarefa of tarefas) { try { await executar(tarefa); await prisma.$executeRaw` update tarefas set status = 'concluida', travada_ate = null where id = ${tarefa.id}`; } catch (err) { const desistiu = tarefa.tentativas >= tarefa.max_tentativas; // Backoff exponencial no próprio agendamento: a tarefa volta a ficar // pendente só depois da espera. const esperaSegundos = Math.min(3600, 2 ** tarefa.tentativas * 10); await prisma.$executeRaw` update tarefas set status = ${desistiu ? 'falhou' : 'pendente'}, rodar_em = now() + make_interval(secs => ${esperaSegundos}), travada_ate = null, ultimo_erro = ${String(err).slice(0, 1000)} where id = ${tarefa.id}`; } } return tarefas.length; } -
Recuperar tarefas de workers que morreram.
Um processo encerrado no meio deixa a tarefa em
processandopara sempre. Otravada_ateé o antídoto:migracao/02-recuperar.sqlselect cron.schedule('recuperar-tarefas-travadas', '* * * * *', $$ update tarefas set status = 'pendente', travada_ate = null where status = 'processando' and travada_ate < now() $$);O prazo precisa ser maior que o tempo máximo de processamento. Curto demais e uma tarefa lenta é reprocessada enquanto ainda roda — o que traz de volta a duplicidade que você acabou de eliminar.
-
Manter a tabela pequena.
select cron.schedule('limpar-tarefas-antigas', '0 4 * * *', $$ delete from tarefas where status = 'concluida' and criada_em < now() - interval '7 days' $$);
Quando trocar por um serviço de fila
A fila no Postgres é excelente até certo ponto. A pergunta que decide não é volume — é quem roda o trabalhador.
| Fila no Postgres | Serviço (QStash, SQS) | |
|---|---|---|
| Precisa de processo rodando | Sim, alguém tem que consultar | Não, ele chama sua URL |
| Transação junto com seus dados | Sim, e é a maior vantagem | Não |
| Custo | Zero a mais | Por mensagem |
| Entrega atrasada | rodar_em |
Nativo |
| Retry automático | Você implementa | Nativo |
| Observabilidade | Consultas SQL suas | Painel pronto |
| Limite prático | Milhares por minuto | Muito acima disso |
| Mais um serviço para cair | Não | Sim |
A vantagem decisiva do Postgres é enfileirar dentro da mesma transação que grava seus dados:
await prisma.$transaction([
prisma.pedido.create({ data: pedido }),
prisma.$executeRaw`insert into tarefas (tipo, carga)
values ('email-confirmacao', ${JSON.stringify({ pedidoId })}::jsonb)`,
]);
Ou os dois acontecem, ou nenhum. Com fila externa, existe o instante em que o pedido foi gravado e a mensagem não foi publicada — e aí você precisa do padrão outbox, que é reimplementar a fila no banco assim mesmo.
A vantagem decisiva do serviço é não precisar de trabalhador. Em arquitetura puramente serverless, alguém tem que acordar para consultar a tabela — na prática, um cron de minuto em minuto, que acrescenta até 60 segundos de latência. O QStash chama sua rota no instante da publicação.
Como confirmar que resolveu
Rode vários workers de propósito e conte:
for i in 1 2 3 4 5 6; do node scripts/worker.mjs & done
wait
-- Precisa devolver zero linhas.
select tipo, carga->>'pedidoId' as pedido, count(*)
from tarefas
where status = 'concluida'
group by 1, 2
having count(*) > 1;
Verifique que ninguém ficou esperando. Durante a carga, esta consulta deve
vir vazia — com skip locked não existe espera por bloqueio:
select pid, wait_event_type, wait_event, query
from pg_stat_activity
where wait_event_type = 'Lock';
Mate um worker no meio. kill -9 num processo que está processando: em até
cinco minutos a tarefa precisa voltar para pendente e ser executada por outro.
Acompanhe a profundidade da fila. É a métrica que avisa antes do usuário:
select status, count(*), min(rodar_em) as mais_antiga
from tarefas
group by status;
Armadilhas que sobram depois disso
order by sem índice mata a fila. Com a tabela grande, ordenar sem o índice
parcial faz cada consulta varrer tudo. O where status = 'pendente' no índice é
o que mantém o custo constante.
Sem skip locked, for update é pior que nada. Ele troca duplicidade por
serialização e deadlock. As duas cláusulas andam juntas.
Consulta em laço apertado esquenta o banco. Um worker consultando a cada
100 ms gera tráfego constante mesmo com fila vazia. Aumente o intervalo quando
não houver trabalho, ou use LISTEN/NOTIFY — lembrando que isso exige conexão direta, não pooler em
modo transação.
Tarefas concluídas acumuladas degradam o índice. Sem limpeza, a tabela cresce
para sempre e o autovacuum passa a trabalhar mais do que precisaria.
max_tentativas sem alerta é falha silenciosa. Tarefa em falhou some do
fluxo e ninguém percebe. Vale um aviso quando a contagem passar de um limiar.
Continue por aqui
APIs & Resiliência
429 em API de IA: fila com concorrência e orçamento de tokens
Promise.all sobre quinhentos itens não é paralelismo, é um ataque. Como respeitar os dois limites que essas APIs impõem sem serializar tudo.
APIs & Resiliência
FUNCTION_INVOCATION_TIMEOUT: quebrando trabalho longo em lotes
Aumentar o maxDuration só empurra a falha para um arquivo maior. A correção é a requisição iniciar o trabalho em vez de executá-lo até o fim.
APIs & Resiliência
Webhook executando duas vezes: como garantir idempotência
Todo provedor de webhook entrega pelo menos uma vez, o que significa às vezes duas. A trava que resolve é uma constraint de banco, não um if.