Dois workers consultam a mesma tarefa pendente
Uma rotina prepara documentos para análise por IA. Para aumentar a capacidade, a equipe inicia outro worker. Os dois consultam a primeira linha pendente e começam a trabalhar sobre o mesmo documento. A consulta encontrou trabalho; faltou uma operação que atribuísse esse trabalho a uma única execução.

No PostgreSQL, FOR UPDATE SKIP LOCKED permite tentar bloquear linhas e passar adiante quando outra transação já mantém um bloqueio conflitante. Isso ajuda a distribuir tarefas de uma tabela entre consumidores. A reserva precisa mudar o estado antes de a transação terminar.
Este guia apresenta um laboratório com dados fictícios. O objeto é a retirada concorrente de trabalho de uma tabela PostgreSQL. O controle de concorrência em agentes de IA cobre a validade de mudanças sobre objetos empresariais; o visibility timeout trata a posse temporária nas filas. Aqui, a entrega é uma consulta de reserva e um protocolo de teste entre sessões.
O que a documentação garante
A documentação oficial de SELECT, consultada em 23 de outubro de 2026, informa que SKIP LOCKED pula linhas que não consegue bloquear imediatamente. Também alerta que o resultado oferece uma visão inconsistente para consultas gerais, embora seja útil para consumidores de tabelas usadas como filas.
Esse comportamento tem consequências práticas:
- uma linha elegível pode ficar fora do resultado porque está bloqueada;
- retorno vazio não comprova que todo o trabalho terminou;
- a ordem de início pode mudar quando a primeira linha está ocupada;
- o mecanismo atua nos bloqueios por linha; bloqueios de tabela ainda podem causar espera.
O guia oficial de bloqueios explícitos descreve a duração dos bloqueios por linha até o fim da transação. O laboratório usa essa propriedade para reservar e registrar a posse numa transação curta, encerrada antes de qualquer chamada ao modelo.
Prepare uma base descartável
Use um banco de laboratório sem dados empresariais e três conexões à mesma base. O exercício cria uma tabela chamada tarefas_demo; não execute a preparação numa base compartilhada. A conta precisa de permissão para criar essa tabela e, nos passos seguintes, consultar e atualizar suas linhas.
Execute a preparação uma única vez:
CREATE TABLE tarefas_demo (
id integer PRIMARY KEY,
estado text NOT NULL CHECK (estado IN ('pendente', 'executando', 'concluida')),
worker text,
versao_posse integer NOT NULL DEFAULT 0,
reservada_em timestamptz
);
INSERT INTO tarefas_demo (id, estado) VALUES
(1, 'pendente'), (2, 'pendente');
CREATE INDEX tarefas_demo_pendentes ON tarefas_demo (id)
WHERE estado = 'pendente';
Os IDs são sintéticos. O índice parcial acompanha o filtro do exemplo; a necessidade, o plano de consulta e o custo de manutenção devem ser medidos com a carga real. Uma tabela de fila com muitas atualizações também exige acompanhamento de autovacuum e crescimento.
Reserve e altere o estado na mesma transação
Na sessão A, execute o bloco abaixo e mantenha a transação aberta apenas para observar a disputa do laboratório:
BEGIN;
WITH candidata AS (
SELECT id FROM tarefas_demo
WHERE estado = 'pendente'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE tarefas_demo AS t
SET estado = 'executando',
worker = 'worker-A',
versao_posse = t.versao_posse + 1,
reservada_em = clock_timestamp()
FROM candidata
WHERE t.id = candidata.id
RETURNING t.id, t.worker, t.versao_posse;
O bloqueio está dentro da consulta que escolhe a candidata. A alteração de estado ocorre enquanto a transação ainda mantém esse bloqueio. Separar seleção e atualização em transações diferentes reabre a disputa.
A sessão A deve receber a tarefa 1. Antes de encerrá-la, execute o mesmo bloco na sessão B, trocando apenas worker-A por worker-B. A sessão B deve receber a tarefa 2, pois a linha 1 está ocupada. Confirme a sessão B com COMMIT.
O ORDER BY id torna a preferência explícita, mas não estabelece FIFO estrito: B começou a tarefa 2 enquanto A ainda mantinha a primeira. Processos que exigem sequência por pedido ou cliente precisam de outro contrato de ordenação.
Lock termina na transação; a reserva confirmada permanece
- 1. A reserva a tarefa 1 sem confirmar
Na mesma transação, selecione com FOR UPDATE SKIP LOCKED e atualize estado e proprietário. A mantém o lock aberto apenas para observar a disputa do laboratório.
- 2. B pula 1 e reserva 2
Enquanto A mantém o bloqueio conflitante, B seleciona a próxima linha reservável e atualiza a posse na própria transação.
- 3. B confirma a reserva
COMMIT encerra o lock de B; estado executando e proprietário persistem. O trabalho demorado começa fora da transação de reserva.
- 4. A desfaz a tentativa
ROLLBACK desfaz atualização e incremento de posse de A; libera o lock e mantém a tarefa 1 pendente.
- 5. C reserva 1 e confirma
Depois do rollback de A, C obtém a tarefa 1 e confirma a reserva. A distribuição depende da sobreposição; ORDER BY não garante início FIFO estrito.
- 6. Queda depois do COMMIT
A reserva executando não volta sozinha a pendente. Recuperação exige proprietário, prazo e nova versão; efeito externo pede idempotência e reconciliação.
- 1 → 2 Transações sobrepostas
- 2 → 3 COMMIT de B
- 1 → 4 ROLLBACK de A
- 4 → 5 Linha volta a ser reservável
- 3 → 6 Worker pode cair com posse persistida
- 5 → 6 Fim do lock não encerra a tarefa
Confira rollback e recuperação antes do compromisso
Na sessão A, execute ROLLBACK. Como a reserva ainda não tinha sido confirmada, a tarefa 1 permanece pendente depois da liberação do bloqueio.
Na sessão C, repita o bloco de reserva com worker-C e conclua com COMMIT. A tarefa 1 deve passar para C. Consulte o estado com uma leitura comum:
SELECT id, estado, worker, versao_posse
FROM tarefas_demo ORDER BY id;
O esperado nesse ponto é tarefa 1 com C e tarefa 2 com B, ambas em execução e com versão de posse 1. A tentativa de A foi desfeita integralmente, inclusive o incremento.
Essa sequência testa sobreposição real de transações sem precisar manter um modelo ou uma API externa em funcionamento. Fora do laboratório, o worker deve confirmar a reserva logo após recebê-la e só então iniciar o trabalho demorado.
Retorno vazio não significa fila concluída
Com as duas linhas em executando, outra reserva devolve zero linhas. Ainda existem duas tarefas não concluídas. Também pode haver linhas pendentes temporariamente bloqueadas por outros consumidores.
O consumidor deve interpretar o vazio como ausência de candidata reservável naquela consulta. O encerramento do processo depende de um manifesto de trabalho ou de uma regra de cobertura, além de contagens por estado. Para monitorar o total, use consultas comuns e uma política de consistência apropriada, sem SKIP LOCKED.
Evite polling sem pausa quando não houver candidata. Defina espera limitada, sinais de novo trabalho e supervisão de tarefas antigas conforme o prazo do processo.
O que acontece se o worker cair depois do COMMIT
Depois do compromisso, o bloqueio por linha termina, mas o estado executando permanece. Uma queda do worker deixa uma reserva persistida que a consulta de pendentes não encontrará.
A recuperação exige uma política própria: proprietário, prazo, progresso, condição para declarar abandono e nova versão de posse. Expirar um timestamp e simplesmente colocar a tarefa de volta pode permitir que o worker antigo retorne e atue em paralelo.
O campo versao_posse do exemplo ajuda a rejeitar atualizações locais vencidas quando o serviço exige a versão corrente. Ele não cria cercamento num CRM ou provedor externo que desconhece esse campo. Uma operação já enviada para fora do banco precisa de idempotência e, quando a confirmação se perde, de reconciliação antes de repetir.
Para registrar conclusão local, use uma condição que inclua tarefa, proprietário, versão e estado:
UPDATE tarefas_demo
SET estado = 'concluida'
WHERE id = 1
AND worker = 'worker-C'
AND versao_posse = 1
AND estado = 'executando'
RETURNING id;
Zero linhas significa que a conclusão não foi aceita pelas condições fornecidas. Não registre sucesso por ter recebido uma resposta SQL sem erro. Quando houver efeito externo, confirme esse efeito antes de marcar a unidade como concluída.
Evidência e limites do laboratório
Na preparação editorial original, foram executados os blocos deste artigo em PostgreSQL 17.11, num contêiner descartável, com sessões sobrepostas e dados sintéticos. A consulta devolveu 1 para A, 2 para B e, depois do rollback de A, 1 para C. Outra reserva ficou vazia enquanto duas tarefas continuavam em execução. A conclusão com proprietário incorreto não alterou a linha; a condição correta confirmou a tarefa 1.
A documentação consultada em /docs/current/ identificava PostgreSQL 18. A versão executada foi 17.11. O teste não envolve modelo de IA, pool de conexões, API empresarial, política de recuperação automática ou prova de execução exatamente uma vez.
Antes de usar em produção, teste queda antes e depois do compromisso, perda da conexão durante o compromisso, espera por bloqueio de tabela e retorno do worker antigo. Homologue a conta não privilegiada, o isolamento entre clientes e a confirmação de efeitos no destino.
Quando usar uma tabela de fila
Uma tabela pode ser suficiente quando a operação já depende do PostgreSQL, o volume foi medido e a equipe consegue sustentar reservas, tentativas, recuperação e observabilidade. Um serviço de filas ou um executor maduro pode reduzir trabalho operacional quando esses requisitos crescem.
A escolha deve considerar retenção, atraso aceitável, recuperação e capacidade da equipe. O planejamento de capacidade para agentes ajuda a dimensionar consumidores sem transformar a base transacional num gargalo compartilhado.
O critério de aceite é concreto: consumidores concorrentes recebem unidades distintas no fluxo saudável, reservas abandonadas têm tratamento verificável e nenhuma chamada externa depende apenas da existência de um lock que já terminou.