O Lock Pessimista no Node.js protege dados concorridos bloqueando registros antes da alteração. Em vez de assumir que conflitos serão raros, a aplicação reserva explicitamente as linhas necessárias e impede que outra transação as modifique até o commit ou rollback.
Esse padrão é útil em estoque, reservas, filas, limites financeiros e recursos disputados. Ele reduz o risco de duas operações confirmarem sobre o mesmo estado, mas aumenta espera, possibilidade de deadlock e consumo de conexões. Por isso, a transação precisa ser curta, previsível e totalmente concentrada no banco.
Neste guia, você aprenderá a usar SELECT FOR UPDATE, NOWAIT, SKIP LOCKED, timeouts, ordem de bloqueio, transações com pg, retries, métricas e testes concorrentes.
O que é lock pessimista?
Lock pessimista presume que duas operações podem disputar o mesmo recurso. A primeira transação adquire o bloqueio, e as demais aguardam ou falham conforme a estratégia.
A documentação oficial de bloqueios explícitos do PostgreSQL descreve modos de lock e conflitos. A documentação de transações do node-postgres mostra como manter as queries na mesma conexão.
Para a base transacional, consulte Transações PostgreSQL no Node.js. Para comparar estratégias, veja Lock Otimista no Node.js.
SELECT FOR UPDATE
SELECT id, stock
FROM products
WHERE id = $1
FOR UPDATE;A linha selecionada fica protegida contra atualizações concorrentes até o final da transação.
Exemplo com pg
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
SELECT id, stock
FROM products
WHERE id = $1
FOR UPDATE
`, [productId]);
const product = result.rows[0];
if (!product || product.stock < quantity) {
throw new Error('Estoque insuficiente');
}
await client.query(`
UPDATE products
SET stock = stock - $1
WHERE id = $2
`, [quantity, productId]);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}O mesmo client deve executar BEGIN, SELECT, UPDATE e COMMIT.
Por que não usar pool.query?
pool.query() pode selecionar outra conexão. A transação pertence à sessão que recebeu o BEGIN. Misturar conexões remove a garantia de atomicidade.
Veja Pool PostgreSQL no Node.js para gerenciamento correto de clientes.
FOR NO KEY UPDATE
Esse modo bloqueia alterações na linha, mas pode permitir certas operações que não modificam chaves usadas por foreign keys. Ele é adquirido automaticamente por alguns UPDATEs.
FOR SHARE
FOR SHARE impede atualizações conflitantes enquanto permite que outras transações também adquiram lock de compartilhamento.
FOR KEY SHARE
É um lock mais leve, usado para proteger chaves referenciadas sem bloquear toda alteração não relacionada.
Escolha o menor lock suficiente
Bloqueios mais fortes reduzem concorrência. Use o modo que realmente protege a invariante.
NOWAIT
SELECT id, stock
FROM products
WHERE id = $1
FOR UPDATE NOWAIT;Em vez de aguardar, a query falha imediatamente se outra transação já possui um lock incompatível.
Quando usar NOWAIT?
- interfaces que precisam responder rápido;
- operações com fallback;
- recursos que podem ser tentados depois;
- processos que preferem erro a fila de espera.
SKIP LOCKED
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10;Linhas bloqueadas por outros workers são ignoradas. Isso permite que vários consumidores retirem trabalhos diferentes da mesma tabela.
Fila com SKIP LOCKED
const jobs = await client.query(`
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY priority DESC, id
FOR UPDATE SKIP LOCKED
LIMIT $1
`, [batchSize]);
await client.query(`
UPDATE jobs
SET status = 'processing',
started_at = now(),
worker_id = $1
WHERE id = ANY($2::bigint[])
`, [workerId, jobs.rows.map(job => job.id)]);A seleção e a marcação precisam ocorrer na mesma transação.
Recuperação de jobs abandonados
Se o worker cair depois de marcar processing, outro processo precisa recuperar trabalhos antigos:
UPDATE jobs
SET status = 'pending',
worker_id = NULL,
started_at = NULL
WHERE status = 'processing'
AND started_at < now() - interval '10 minutes';A operação deve ser idempotente e considerar jobs realmente longos.
Transações curtas
Não mantenha lock enquanto chama APIs externas, espera usuário, envia e-mail ou processa arquivo. Faça apenas o trabalho de banco necessário e confirme rapidamente.
Chamadas externas
Um pagamento remoto não participa da transação PostgreSQL. Use estados intermediários, idempotência e outbox em vez de segurar a linha durante uma chamada HTTP.
Consulte Idempotência em APIs Node.js.
Ordem consistente
Bloqueie múltiplos recursos sempre na mesma ordem:
const accountIds = [sourceId, destinationId]
.sort((a, b) => a - b);
await client.query(`
SELECT id
FROM accounts
WHERE id = ANY($1::bigint[])
ORDER BY id
FOR UPDATE
`, [accountIds]);Se todas as transações seguem essa ordem, o risco de deadlock diminui.
Deadlock
Um deadlock ocorre quando A espera B e B espera A. O PostgreSQL detecta o ciclo e aborta uma transação com SQLSTATE 40P01.
Retry de deadlock
async function withDeadlockRetry(operation) {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await operation();
} catch (error) {
if (error.code !== '40P01' || attempt === 2) {
throw error;
}
await delay(backoffWithJitter(attempt));
}
}
}O callback não pode disparar efeitos externos que seriam repetidos.
lock_timeout
SET LOCAL lock_timeout = '2s';A query falha se esperar além do prazo. Use SET LOCAL dentro da transação para não alterar futuras operações da conexão.
statement_timeout
SET LOCAL statement_timeout = '5s';Limita a duração de cada comando. Um timeout aborta a transação e exige rollback.
idle_in_transaction_session_timeout
Esse parâmetro ajuda a encerrar sessões esquecidas em estado idle in transaction, que podem manter locks e snapshots por muito tempo.
Lock de tabela
Comandos DDL e algumas operações adquirem locks de tabela. Em APIs normais, prefira bloqueios de linha. Um lock amplo pode interromper grande parte do tráfego.
Advisory locks
Locks consultivos protegem recursos definidos pela aplicação que não correspondem diretamente a uma linha. Eles serão tratados em artigo próprio e não substituem constraints.
Constraint ainda é necessária
O lock protege a sequência, mas regras como estoque não negativo devem, quando possível, existir no banco:
ALTER TABLE products
ADD CONSTRAINT products_stock_nonnegative
CHECK (stock >= 0);Update atômico
Às vezes, uma única query elimina a necessidade de leitura bloqueante:
UPDATE products
SET stock = stock - $1
WHERE id = $2
AND stock >= $1
RETURNING stock;Se nenhuma linha retorna, não havia estoque. Essa abordagem costuma ser mais eficiente.
Lock pessimista versus update condicional
Use lock quando precisa ler vários campos e tomar decisões complexas. Use update condicional quando a invariante cabe em uma única expressão SQL.
Isolamento
Locks funcionam em conjunto com o nível de isolamento. Em Read Committed, o SELECT bloqueante vê dados confirmados antes do comando e espera alterações concorrentes necessárias.
Serializable
Em Serializable, o PostgreSQL pode abortar transações mesmo sem deadlock físico para preservar uma ordem serial válida. Planeje retry para SQLSTATE 40001.
Autorização
Valide acesso antes de bloquear. Não mantenha lock enquanto busca dados externos de autorização.
Multi-tenant
Inclua o tenant no WHERE:
SELECT id, stock
FROM products
WHERE id = $1
AND tenant_id = $2
FOR UPDATE;Isso evita bloquear ou alterar recurso de outra organização.
Pagamentos
Bloquear uma linha de pedido pode impedir duas transições de status, mas não garante que o provedor não cobre duas vezes. Combine com chave de idempotência.
Reservas
Para reservar assento, bloqueie a linha, verifique disponibilidade, altere o status e confirme. Defina expiração para reservas abandonadas.
Contadores
Para incrementar um contador, uma única query atômica é melhor que SELECT FOR UPDATE seguido de UPDATE.
Observabilidade
Registre:
- operação;
- tempo de espera;
- duração da transação;
- timeout;
- deadlock;
- retry;
- quantidade de linhas;
- resultado.
Métricas
Crie histograms para duração e counters para timeouts e deadlocks. Veja Métricas Prometheus no Node.js.
Logs
Não registre SQL completo com dados sensíveis. Use operação lógica, SQLSTATE e IDs internos quando necessário.
pg_stat_activity
SELECT pid,
state,
wait_event_type,
wait_event,
now() - xact_start AS age,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;Essa consulta ajuda a encontrar transações longas.
pg_locks
pg_locks mostra locks concedidos e aguardando. Combine com pg_stat_activity em um runbook de incidente.
Testes concorrentes
Use PostgreSQL real. Inicie duas conexões, bloqueie a mesma linha e confirme o comportamento de espera, NOWAIT ou timeout.
Teste de estoque
test('não reserva mais que o estoque', async () => {
const attempts = Array.from({ length: 20 }, () =>
reserveProduct(productId, 1)
);
await Promise.allSettled(attempts);
const product = await findProduct(productId);
assert.ok(product.stock >= 0);
});Teste de deadlock
Adquira locks em ordens opostas em duas transações e confirme que a aplicação repete somente o erro apropriado.
Teste de timeout
Mantenha uma linha bloqueada, execute outra transação com lock_timeout curto e verifique o erro e o rollback.
Erros comuns
- Usar pool.query: a transação se divide entre conexões.
- Chamar API externa: o lock permanece por muito tempo.
- Sem ordem consistente: deadlocks aumentam.
- Sem timeout: requisições aguardam indefinidamente.
- Retry de qualquer erro: falhas permanentes repetem.
- Lock amplo: throughput cai.
- Esquecer rollback: a conexão retorna suja ao pool.
Boas práticas
- Use a mesma conexão.
- Mantenha a transação curta.
- Bloqueie na mesma ordem.
- Defina lock_timeout.
- Use NOWAIT ou SKIP LOCKED quando adequado.
- Prefira update atômico quando possível.
- Mantenha constraints.
- Repita apenas erros transitórios.
- Monitore espera e deadlocks.
- Teste com concorrência real.
Conclusão
O Lock Pessimista no Node.js oferece controle direto sobre recursos disputados. Com SELECT FOR UPDATE, a aplicação impede que duas transações confirmem sobre o mesmo estado ao mesmo tempo.
O custo é contenção. Por isso, locks devem ser pequenos, curtos e adquiridos em ordem consistente. Com timeouts, constraints, retries limitados e métricas, o padrão protege estoque, reservas e filas sem transformar o banco em um gargalo permanente.



