Home > Blog > Desenvolvimento Web
Desenvolvimento Web
JavaScript
Programação

Lock Otimista no Node.js

Atualizado em: 25 de agosto de 2026

Estação de trabalho usada no desenvolvimento de aplicações Node.js

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:

  1. Alice lê preço 100.
  2. Bruno lê preço 100.
  3. Alice altera para 110.
  4. Bruno altera descrição e envia também o preço antigo 100.
  5. 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 NULL

Cada 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 UPDATE dentro 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.

10 melhores cursos de programação em 2026

Descubra os melhores cursos de programação. Aprenda a escolher o curso ideal para iniciar ou avançar na carreira de desenvolvedor

POSTS RELACIONADOS

Ver todos

Seta para a direita