Pular para o conteúdo
upgbp

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.

11 min de leitura

O erro tratado aqui

ERROR: deadlock detected — Process 18442 waits for ShareLock on transaction 9912

Ambiente testado

  • PostgreSQL 15
  • Node 22
  • Prisma 6.5
  • Upstash QStash
Neste artigo (10)

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:

src/worker/puxar.ts
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:

logs dos workersexit 1
worker-2  processando tarefa 8841 → e-mail enviado para ana@exemplo.com
worker-4  processando tarefa 8841 → e-mail enviado para ana@exemplo.com

E, ao tentar consertar com transações:

logs dos workersexit 1
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.

À esquerda, com FOR UPDATE, quatro workers apontam para a mesma linha e três ficam esperando. À direita, com SKIP LOCKED, cada worker pega uma linha diferente.
Sem SKIP LOCKED, FOR UPDATE troca duplicidade por serialização e deadlock. As duas cláusulas andam juntas.

A solução

  1. A tabela, com o que uma fila de verdade precisa.

    migracao/01-fila.sql
    create 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';
  2. Puxar um lote de forma atômica.

    update ... returning combinado com o select ... skip locked faz reserva e leitura numa única viagem ao banco:

    consultas/puxar-tarefas.sql
    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 $1
      for update skip locked
    )
    returning *;
    src/worker/puxar.ts
    export 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 *`;
    }
  3. Concluir, adiar ou desistir.

    src/worker/processar.ts
    export 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;
    }
  4. Recuperar tarefas de workers que morreram.

    Um processo encerrado no meio deixa a tarefa em processando para sempre. O travada_ate é o antídoto:

    migracao/02-recuperar.sql
    select 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.

  5. 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