↓Pular para o conteúdo principal
  1. blog/
ARTIGO TÉCNICO

FOR UPDATE SKIP LOCKED: usando PostgreSQL para distribuir trabalho entre workers sem processar o mesmo item duas vezes

Um padrão prático de claim atômico com FOR UPDATE SKIP LOCKED para workers concorrentes processarem filas persistidas no PostgreSQL.

Nem toda fila operacional precisa começar com Redis, RabbitMQ, SQS ou Kafka.

Em uma automação que já tinha PostgreSQL como armazenamento de estado, eu precisava resolver um problema simples:

duas execuções do worker podem encontrar o mesmo item pendente; como garantir que apenas uma delas fique responsável por ele?

A solução usada foi um claim atômico baseado em:

1
FOR UPDATE SKIP LOCKED

Esse padrão é particularmente útil quando a unidade de trabalho já está representada por uma linha no banco.

O problema da consulta ingênua #

Imagine uma tabela:

1
operational_windows

com registros aguardando processamento.

Uma primeira versão poderia fazer:

1
2
3
4
5
SELECT id
FROM operational_windows
WHERE processed = false
ORDER BY created_at
LIMIT 5;

Depois o worker atualizaria os itens escolhidos.

O problema aparece com concorrência.

Dois workers podem executar o SELECT quase ao mesmo tempo:

1
2
worker A → encontrou 10, 11, 12
worker B → encontrou 10, 11, 12

Se o claim não for atômico, os dois podem processar os mesmos itens.

O lock precisa acontecer durante a seleção #

No fluxo real, o padrão era equivalente a:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
WITH candidate AS (
  SELECT id
  FROM operational_windows
  WHERE final_sent = false
    AND close_after_at <= now()
  ORDER BY close_after_at
  LIMIT 5
  FOR UPDATE SKIP LOCKED
)
UPDATE operational_windows w
SET
  processing = true,
  processing_started_at = now()
FROM candidate c
WHERE w.id = c.id
RETURNING w.*;

A seleção e a marcação do trabalho acontecem como uma operação coerente.

O que FOR UPDATE faz #

Quando uma linha é selecionada com:

1
FOR UPDATE

ela recebe um lock incompatível com outro worker tentando assumir a mesma linha daquela forma enquanto a transação estiver aberta.

Isso é útil, mas sozinho pode gerar espera.

Worker B poderia ficar bloqueado esperando Worker A liberar o item.

Para uma fila, muitas vezes isso não é o comportamento desejado.

O que SKIP LOCKED muda #

Com:

1
FOR UPDATE SKIP LOCKED

um worker encontra uma linha que outro worker já bloqueou e simplesmente a ignora naquela seleção.

Exemplo:

1
2
3
4
5
6
7
8
fila: 10 11 12 13 14 15

worker A
→ lock 10 11 12

worker B
→ pula 10 11 12
→ pega 13 14 15

Isso cria uma distribuição de trabalho muito simples.

Por que usar um CTE + UPDATE #

Uma vantagem do padrão com WITH candidate é que ele não apenas encontra o trabalho.

Ele já marca as linhas como assumidas:

1
2
processing = true
processing_started_at = now()

E retorna exatamente o conjunto que aquela execução deve processar.

O worker não precisa fazer:

1
2
3
SELECT
→ sair da transação
→ UPDATE depois

abrindo uma janela de corrida entre as duas operações.

processing=true sozinho não resolve tudo #

Mesmo com uma flag de processamento existe outro problema:

e se o worker morrer depois de marcar a linha?

O item pode ficar preso eternamente como:

1
processing = true

No caso real, o filtro também aceitava registros cujo processamento tinha começado há tempo demais.

Conceitualmente:

1
2
3
4
WHERE
  processing IS NOT TRUE
  OR processing_started_at IS NULL
  OR processing_started_at < now() - interval 'N minutes'

Isso implementa uma forma de lease/timeout.

Não é suficiente para todos os sistemas, mas já evita o lock lógico eterno.

O timeout precisa ser maior que a execução normal #

Se o worker normalmente leva quatro minutos e você considera abandonado depois de dois, pode criar processamento duplicado legítimo.

Então o timeout deve ser definido com base em observação:

1
2
3
P95/P99 de duração
+
margem operacional

E não em um número arbitrário.

Finalização também precisa ser explícita #

Depois do processamento, o registro muda de estado.

Algo como:

1
2
3
4
5
6
UPDATE operational_windows
SET
  final_sent = true,
  processing = false,
  processing_finished_at = now()
WHERE id = $1;

O nome do campo pode mudar conforme o domínio.

O importante é separar estados:

1
2
3
4
5
pendente
assumido
concluído
falhou
abandonado/recuperável

Erro de processamento não deveria desaparecer #

Eu também gosto de guardar algo equivalente a:

1
processing_error

Isso permite distinguir:

1
nunca processado

de:

1
tentou processar e falhou

Uma fila operacional precisa ser auditável.

Isso substitui um broker? #

Não universalmente.

PostgreSQL com SKIP LOCKED funciona muito bem quando:

  • já existe PostgreSQL na arquitetura;
  • o volume é moderado;
  • o trabalho já possui estado relacional;
  • você precisa de transação junto com dados de negócio;
  • os consumers são poucos/moderados;
  • latência de milissegundos não é requisito crítico.

Eu avaliaria um broker dedicado quando houver necessidades como:

  • throughput muito alto;
  • fan-out sofisticado;
  • roteamento complexo;
  • retenção/replay em grande escala;
  • milhões de mensagens concorrentes;
  • semântica de streaming;
  • consumidores muito desacoplados.

A pergunta não é “PostgreSQL ou Kafka?”.

É:

qual é a complexidade real da fila que eu tenho?

O padrão funciona além do n8n #

Qualquer worker que consiga abrir uma transação no PostgreSQL pode usar a mesma ideia:

1
2
3
4
5
6
7
8
Python
Node.js
Go
Java
cron
n8n
worker interno
job Kubernetes

O banco é responsável pelo lock, não o framework.

Lote pequeno ajuda #

No fluxo que originou este artigo, o claim limitava a quantidade de itens por execução.

Isso é útil para:

  • evitar monopolizar a fila;
  • manter transações pequenas;
  • controlar blast radius;
  • reduzir duração do job;
  • permitir concorrência entre workers.

Exemplo:

1
LIMIT 5

O número correto depende do custo de cada item.

Idempotência continua necessária #

SKIP LOCKED reduz processamento concorrente duplicado.

Ele não resolve todas as formas de duplicidade.

Um worker pode:

1
2
3
4
executar efeito externo
→ cair antes de marcar concluído
→ item voltar para a fila
→ efeito externo ser executado novamente

Por isso operações externas ainda precisam de mecanismos como:

  • idempotency key;
  • external key;
  • chave de correlação;
  • verificação de estado antes de repetir;
  • upsert;
  • deduplicação no destino.

Lock e idempotência resolvem problemas diferentes.

Não segure a transação durante trabalho lento sem necessidade #

Outro cuidado importante: não faz sentido manter um lock SQL aberto enquanto o worker passa minutos chamando APIs externas.

O padrão que prefiro é:

1
2
3
4
5
transação curta
→ selecionar + marcar claim
→ commit
→ trabalho externo
→ nova transação para finalizar

A coluna processing/lease preserva a posse lógica depois que o lock físico da transação foi liberado.

Isso evita conexões e locks longos desnecessários.

Observabilidade mínima da fila #

Eu monitoraria:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- pendentes
SELECT count(*)
FROM operational_windows
WHERE final_sent = false;

-- presos em processing
SELECT count(*)
FROM operational_windows
WHERE processing = true
  AND processing_started_at < now() - interval 'N minutes';

-- idade do item mais antigo
SELECT now() - min(created_at)
FROM operational_windows
WHERE final_sent = false;

Além de:

1
2
3
4
5
itens processados/min
falhas/min
retries
tempo de processamento
idade P95 da fila

O que a implementação real comprova #

A automação que serviu de fonte usa PostgreSQL para manter janelas de eventos operacionais e possui um worker agendado que:

  • seleciona itens vencidos;
  • limita o lote;
  • usa FOR UPDATE SKIP LOCKED;
  • marca processing e horário de início;
  • aceita recuperar processamento antigo;
  • retorna os itens assumidos;
  • executa processamento externo;
  • finaliza o estado depois.

O artigo remove nomes, endpoints, credenciais e identificadores do ambiente original.

Checklist #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[ ] claim ocorre dentro da transação
[ ] usa FOR UPDATE quando precisa excluir concorrência
[ ] SKIP LOCKED é adequado ao comportamento de fila
[ ] lote tem limite
[ ] estado de processing fica persistido
[ ] processing tem timeout/lease
[ ] finalização é explícita
[ ] erro é observável
[ ] efeitos externos são idempotentes
[ ] transação não fica aberta durante trabalho lento
[ ] backlog e stuck jobs são monitorados

O principal aprendizado #

Uma fila não é definida pela ferramenta usada.

Ela é definida pelo problema de concorrência, posse, retry, idempotência e estado que precisa resolver.

Quando o trabalho já vive no PostgreSQL, FOR UPDATE SKIP LOCKED pode ser uma solução extremamente eficiente para transformar linhas pendentes em unidades de trabalho distribuídas entre workers — sem processar tudo duas vezes e sem adicionar infraestrutura antes de precisar dela.

TEM UM CENÁRIO PARECIDO?

Me chama.

Manda o contexto, os sintomas e o que já foi testado. Bora organizar as evidências antes de sair mexendo.

Falar com Castro →
Sem enrolação. Com evidência.