- Castro/
- blog/
- FOR UPDATE SKIP LOCKED: usando PostgreSQL para distribuir trabalho entre workers sem processar o mesmo item duas vezes/
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:
| |
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:
| |
com registros aguardando processamento.
Uma primeira versão poderia fazer:
| |
Depois o worker atualizaria os itens escolhidos.
O problema aparece com concorrência.
Dois workers podem executar o SELECT quase ao mesmo tempo:
| |
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:
| |
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:
| |
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:
| |
um worker encontra uma linha que outro worker já bloqueou e simplesmente a ignora naquela seleção.
Exemplo:
| |
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:
| |
E retorna exatamente o conjunto que aquela execução deve processar.
O worker não precisa fazer:
| |
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:
| |
No caso real, o filtro também aceitava registros cujo processamento tinha começado há tempo demais.
Conceitualmente:
| |
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:
| |
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:
| |
O nome do campo pode mudar conforme o domínio.
O importante é separar estados:
| |
Erro de processamento não deveria desaparecer #
Eu também gosto de guardar algo equivalente a:
| |
Isso permite distinguir:
| |
de:
| |
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:
| |
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:
| |
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:
| |
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 é:
| |
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:
| |
Além de:
| |
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
processinge 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 #
| |
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.
Me chama.
Manda o contexto, os sintomas e o que já foi testado. Bora organizar as evidências antes de sair mexendo.