O Lock Otimista no Node.js evita que uma atualização sobrescreva silenciosamente mudanças feitas por outra requisição. Em vez de bloquear o registro durante todo o período entre leitura e gravação, a aplicação inclui uma versão, timestamp ou ETag na operação de atualização. Se o valor mudou desde a leitura, a escrita é rejeitada e o cliente decide recarregar, mesclar ou tentar novamente.
Esse padrão funciona bem quando conflitos são pouco frequentes e usuários ou processos podem editar o mesmo recurso ao mesmo tempo. Ele reduz o tempo de locks no banco, mas exige tratamento explícito de conflitos, respostas HTTP adequadas e testes concorrentes.
Neste guia, você aprenderá a usar coluna de versão no PostgreSQL, updates condicionais, ETags, If-Match, ORMs, retries, interfaces de edição, métricas e estratégias para evitar lost updates.
O que é lock otimista?
Lock otimista é um controle de concorrência que assume que conflitos serão raros. A aplicação lê o recurso junto com um identificador de versão e só confirma a alteração se a versão ainda for a mesma.
A documentação oficial de MVCC do PostgreSQL explica como versões de linhas permitem concorrência. A especificação HTTP Semantics descreve ETag, If-Match e respostas condicionais.
Para transações e isolamento, consulte Transações PostgreSQL no Node.js. Para validators HTTP, veja ETag e Cache HTTP no Node.js.
O problema do lost update
Considere dois usuários editando o mesmo produto:
- Alice lê preço 100.
- Bruno lê preço 100.
- Alice altera para 110.
- Bruno altera descrição e envia também o preço antigo 100.
- A atualização de Bruno sobrescreve o preço 110.
Nenhuma query falhou, mas a mudança de Alice foi perdida.
Coluna de versão
ALTER TABLE products
ADD COLUMN version INTEGER NOT NULL DEFAULT 1;Cada atualização incrementa o valor.
Lendo o recurso
SELECT id,
name,
price_cents,
description,
version
FROM products
WHERE id = $1;A resposta precisa incluir a versão ou transformá-la em ETag.
Update condicional
UPDATE products
SET name = $1,
price_cents = $2,
description = $3,
version = version + 1,
updated_at = now()
WHERE id = $4
AND version = $5
RETURNING id,
name,
price_cents,
description,
version,
updated_at;Se outra operação já atualizou a linha, o WHERE version = $5 não encontra registro.
Implementação com pg
async function updateProduct(input) {
const result = await pool.query(`
UPDATE products
SET name = $1,
price_cents = $2,
description = $3,
version = version + 1,
updated_at = now()
WHERE id = $4
AND version = $5
RETURNING *
`, [
input.name,
input.priceCents,
input.description,
input.id,
input.version
]);
if (result.rowCount === 0) {
throw new OptimisticLockError(input.id);
}
return result.rows[0];
}Recurso inexistente ou conflito?
Um update sem linhas pode significar que o ID não existe ou que a versão mudou. Se o contrato precisa distinguir:
const current = await pool.query(
'SELECT version FROM products WHERE id = $1',
[input.id]
);
if (current.rowCount === 0) {
throw new NotFoundError();
}
throw new OptimisticLockError();Essa consulta extra ocorre apenas no conflito. Evite revelar existência de recursos que o usuário não está autorizado a acessar.
Resposta HTTP 409
Uma API pode retornar:
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"code": "RESOURCE_CONFLICT",
"message": "O recurso foi alterado por outra operação"
}409 representa conflito com o estado atual.
Resposta HTTP 412
Quando o cliente usa If-Match, 412 Precondition Failed é a resposta semântica comum para uma precondição que não corresponde.
ETag com versão
function productEtag(product) {
return `"product-${product.id}-v${product.version}"`;
}Na leitura:
res.setHeader('ETag', productEtag(product));
res.json(serializeProduct(product));If-Match
O cliente envia:
PUT /api/products/42
If-Match: "product-42-v7"A aplicação extrai a versão e executa o update condicional.
Exigindo precondição
Se a rota exige controle de concorrência e o cliente não envia If-Match, a API pode retornar 428 Precondition Required:
{
"code": "PRECONDITION_REQUIRED",
"message": "Envie o ETag atual no header If-Match"
}Não use ETag previsível como autorização
O ETag informa versão, não permissão. Valide autenticação e autorização antes da atualização.
Timestamp como versão
Outra opção usa updated_at:
UPDATE products
SET ...,
updated_at = now()
WHERE id = $1
AND updated_at = $2;Essa estratégia depende de precisão, timezone, serialização e igualdade exata. Uma coluna inteira de versão costuma ser mais simples.
UUID de revisão
Um token aleatório pode representar a revisão:
revision UUID NOT NULLCada alteração gera outro UUID. Isso evita inferir quantidade de versões, mas ocupa mais espaço e não fornece ordenação.
xmin do PostgreSQL
O PostgreSQL expõe a coluna de sistema xmin, que algumas ferramentas usam para concorrência. Porém, ela possui detalhes de wraparound, dumps e semântica interna. Uma coluna de versão explícita é mais portátil e compreensível.
Updates parciais
Mesmo em PATCH, envie a versão:
PATCH /api/products/42
If-Match: "product-42-v7"
{
"description": "Nova descrição"
}Sem o validator, uma mudança parcial ainda pode ser baseada em estado antigo.
Merge automático
Se Alice mudou preço e Bruno mudou descrição, a aplicação poderia mesclar campos diferentes. Isso exige conhecer a versão base e comparar alterações.
Three-way merge
Um merge possui:
- estado base lido pelo cliente;
- estado atual no servidor;
- mudanças propostas.
Campos alterados apenas pelo cliente podem ser aplicados. Campos modificados nos dois lados exigem decisão humana ou regra específica.
Não faça merge genérico em dados críticos
Saldo, estoque, permissão e status de pagamento possuem invariantes. Use operações semânticas e transações, não merge de documentos.
Comandos em vez de substituição
Em vez de enviar todo o objeto:
POST /api/products/42/change-price
{
"priceCents": 11000,
"expectedVersion": 7
}Um comando explícito reduz campos que podem ser sobrescritos.
Operações atômicas
Para contadores e estoque, uma única query condicional pode ser melhor:
UPDATE products
SET stock = stock - $1,
version = version + 1
WHERE id = $2
AND stock >= $1
RETURNING stock, version;Não é necessário ler antes quando a condição pode ser expressa no SQL.
Lock otimista versus pessimista
- Otimista: não mantém lock entre leitura e escrita; detecta conflito no update.
- Pessimista: usa
SELECT FOR UPDATEdentro de uma transação para impedir atualização concorrente.
Quando usar otimista?
- conflitos raros;
- edição por humanos;
- tempo longo entre leitura e gravação;
- APIs stateless;
- interfaces distribuídas;
- necessidade de não manter conexão aberta.
Quando usar pessimista?
- conflitos frequentes;
- operação curta dentro do banco;
- recurso precisa ser reservado;
- ordem de alterações é crítica;
- falha por conflito seria muito cara.
Consulte Transações PostgreSQL no Node.js para FOR UPDATE e isolamento.
Retries automáticos
Não repita cegamente uma edição humana. O cliente precisa ver o estado atual e decidir.
Retry seguro em operação técnica
Um processamento automático pode recarregar e recalcular:
for (let attempt = 0; attempt < 3; attempt += 1) {
const current = await findProduct(id);
const next = calculateUpdate(current, input);
try {
return await updateProduct({
...next,
id,
version: current.version
});
} catch (error) {
if (!(error instanceof OptimisticLockError)) {
throw error;
}
if (attempt === 2) throw error;
}
}O cálculo precisa ser puro e não realizar efeitos externos a cada tentativa.
Backoff
Em alta contenção, um pequeno backoff com jitter reduz colisões repetidas. Se conflitos são constantes, reavalie o desenho.
Idempotência
Lock otimista impede sobrescrita de estado; idempotência impede repetir a mesma intenção. Os padrões resolvem problemas diferentes e podem ser combinados.
Consulte Idempotência em APIs Node.js.
Transação com vários recursos
Se uma operação atualiza pedido e estoque, use transação. Cada update pode incluir sua versão, mas o commit deve ser atômico.
Exemplo transacional
await withTransaction(pool, async client => {
const orderResult = await client.query(`
UPDATE orders
SET status = $1,
version = version + 1
WHERE id = $2
AND version = $3
RETURNING *
`, [status, orderId, orderVersion]);
if (orderResult.rowCount === 0) {
throw new OptimisticLockError('order');
}
const inventoryResult = await client.query(`
UPDATE inventory
SET reserved = reserved + $1,
version = version + 1
WHERE product_id = $2
AND version = $3
RETURNING *
`, [quantity, productId, inventoryVersion]);
if (inventoryResult.rowCount === 0) {
throw new OptimisticLockError('inventory');
}
});Qualquer conflito provoca rollback do conjunto.
ORMs
Alguns ORMs possuem controle de versão integrado; outros exigem condição manual. Verifique o SQL gerado e confirme que a versão participa do WHERE.
Prisma
É possível usar update condicional com campos únicos ou updateMany, conforme schema e versão. Teste count ou exceção de ausência para detectar conflito.
Drizzle
Com Drizzle, inclua and(eq(id), eq(version)) e incremente version no SET. Consulte Drizzle ORM com PostgreSQL.
Kysely
Kysely permite construir update tipado com condições. Consulte Kysely com TypeScript e SQL.
GraphQL
Uma mutation pode receber expectedVersion:
mutation {
updateProduct(
id: "42"
expectedVersion: 7
input: { description: "Nova" }
) {
id
version
}
}O erro deve ser tipado para o cliente distinguir conflito de validação.
Interface de edição
Ao receber 409 ou 412, a interface pode:
- mostrar que o recurso mudou;
- exibir versão atual;
- preservar alterações locais;
- permitir comparação;
- oferecer mesclagem;
- recarregar após confirmação.
Não apenas exiba “erro”
Um conflito é esperado em colaboração. Uma mensagem clara evita que o usuário copie dados novamente ou tente repetidamente.
Formulários longos
Quanto maior o tempo de edição, maior a chance de conflito. Autosave pode usar versões e resolver cada pequena atualização, mas também aumenta volume.
Autosave
Envie o último version retornado. Se ocorrer conflito, pare o autosave e solicite intervenção, em vez de sobrescrever.
Documentação OpenAPI
Documente:
- ETag na resposta;
- If-Match obrigatório;
- 428 sem precondição;
- 412 em versão antiga;
- formato do erro;
- como obter versão atual.
Consulte OpenAPI com Node.js.
Cache e ETag
O mesmo ETag pode servir para cache e concorrência se representa a mesma versão. Cuidado com ETags fracos: If-Match usa comparação forte conforme as regras HTTP.
Versionamento de API
A coluna version do recurso não é a versão do contrato da API. Consulte Versionamento de API no Node.js.
Migrations
Adicionar a coluna em tabela grande deve seguir estratégia segura. Consulte Migrações de Banco no Node.js.
Backfill de versão
Um default constante pode permitir adicionar a coluna. Verifique comportamento da versão do PostgreSQL, tamanho da tabela e locks antes de produção.
Triggers
Um trigger pode incrementar a versão em qualquer update, inclusive alterações feitas por outros sistemas. Porém, o SQL condicional da aplicação ainda deve comparar a versão esperada.
Atualizações fora da aplicação
Scripts administrativos precisam incrementar version. Caso contrário, clientes não detectam a mudança.
Versionar toda mudança?
Decida se campos técnicos, como last_viewed_at, devem invalidar uma edição. Talvez seja melhor separar versões por agregado ou excluir atualizações irrelevantes.
Agregados
Em domínio complexo, a versão pertence ao agregado, não a cada tabela isolada. Alterações em itens de pedido podem incrementar a versão do pedido.
Eventos
Inclua versão do agregado em eventos:
{
"type": "product.updated",
"aggregateId": "42",
"aggregateVersion": 8
}Consumidores podem detectar evento duplicado ou fora de ordem.
Outbox
Grave evento e incremento de versão na mesma transação para evitar divergência.
Observabilidade
Registre:
- tipo do recurso;
- operação;
- versão esperada;
- versão atual, quando seguro;
- conflito;
- retry;
- duração.
Não use ID do recurso como label de métrica.
Métricas
Um counter por recurso e operação mostra taxa de conflitos:
optimistic_lock_conflicts_total{
resource="product",
operation="update"
}Consulte Métricas Prometheus no Node.js.
Taxa alta de conflito
Pode indicar:
- recurso muito compartilhado;
- autosave agressivo;
- payloads de substituição completa;
- retries imediatos;
- versão invalidada por campo irrelevante;
- necessidade de operação atômica.
Logs
Conflito esperado pode ser info ou warn amostrado, não error com stack completa.
Testes de integração
Use PostgreSQL real. Leia a mesma versão em duas conexões, atualize na primeira e confirme que a segunda recebe conflito.
Teste concorrente
test('rejeita atualização baseada em versão antiga', async () => {
const original = await findProduct(productId);
const first = await updateProduct({
...original,
name: 'Nome A'
});
await assert.rejects(
() => updateProduct({
...original,
description: 'Descrição B'
}),
OptimisticLockError
);
assert.equal(first.version, original.version + 1);
});Teste de ETag
Faça GET, capture ETag, atualize o recurso por outra requisição e envie PUT com o ETag antigo. Confirme 412.
Teste de retry
Simule um conflito e confirme que a operação técnica recalcula a partir do novo estado, sem repetir efeitos externos.
Teste de autorização
Um conflito não deve revelar a versão atual de um recurso que o usuário não pode visualizar.
Erros comuns
- Versão apenas no SET: sem condição no WHERE, nada é protegido.
- Ignorar rowCount zero: conflito é tratado como sucesso.
- Retry cego de formulário: mudança alheia é sobrescrita.
- updated_at com baixa precisão: dois updates parecem iguais.
- ETag usado como autorização: acesso indevido continua possível.
- Scripts sem incrementar versão: mudanças não são detectadas.
- Conflito como erro fatal: observabilidade fica ruidosa.
Boas práticas
- Use coluna de versão explícita.
- Compare no WHERE.
- Incremente atomicamente.
- Verifique rowCount.
- Use If-Match em APIs HTTP.
- Retorne 412 ou 409 consistente.
- Não repita edição humana automaticamente.
- Use transação para múltiplos recursos.
- Monitore conflitos.
- Teste concorrência real.
Conclusão
O Lock Otimista no Node.js detecta alterações concorrentes sem manter uma conexão ou lock aberto entre leitura e escrita. Uma versão no recurso participa do update condicional e impede lost updates silenciosos.
Com ETag e If-Match, o mesmo padrão integra naturalmente ao HTTP. A aplicação deve tratar conflito como parte esperada do fluxo, oferecendo recarga ou mesclagem ao usuário. Quando combinado com transações, idempotência e métricas, o lock otimista protege dados em APIs distribuídas com baixo custo de contenção.




