Para rejeitar o salvamento de um documento desatualizado, inclua na condição da atualização a revisão originalmente carregada pelo editor e, em seguida, incremente-a atomicamente. SERIALIZABLE, por si só, não consegue expressar esse requisito quando o documento foi carregado em uma transação anterior.

Este tutorial é para um desenvolvedor que trabalha no Terminal de um Mac e precisa que um editor de artigos em PostgreSQL rejeite textos de substituição desatualizados. Ele usa linhas de artigos sintéticas e uma demonstração completa com node-postgres. A tarefa é precisa: salvar somente quando a revisão armazenada ainda corresponder à revisão que serviu de base para o rascunho enviado.

Por que SERIALIZABLE pode permitir uma substituição desatualizada?

O PostgreSQL define a execução serializável como aquela que produz um efeito equivalente à execução das transações confirmadas uma de cada vez, em alguma ordem. Essa garantia diz respeito às transações do banco de dados e às operações nelas realizadas. PostgreSQL: Transaction Isolation.

Considere esta sequência ilustrativa:

  1. Um editor carrega o artigo 1 na revisão 1, e a transação de leitura termina.
  2. Outro salvamento substitui o texto do artigo e confirma a revisão 2.
  3. O editor original envia um texto de substituição baseado na revisão 1.
  4. Uma nova transação serializável atualiza o artigo 1 usando somente seu ID.

A última transação pode ter sucesso: as transações já foram executadas sequencialmente. Nada em uma atualização condicionada apenas a id = 1 indica que o texto enviado exige a revisão 1. Isso é uma aplicação da definição documentada de serializabilidade, não uma exceção a ela.

Transações sobrepostas são diferentes. O PostgreSQL pode rejeitar uma transação serializável quando uma atualização concorrente entra em conflito com seu snapshot ou quando as dependências impediriam a execução serial. O snapshot é estabelecido pela primeira instrução que não seja de controle de transação; simplesmente executar BEGIN antes não preserva uma leitura anterior do editor. PostgreSQL: Transaction Isolation.

A pergunta diagnóstica é, portanto: Onde o salvamento exige explicitamente a revisão carregada pelo editor? Se a resposta for “em lugar nenhum”, mudar o nível de isolamento não acrescentará essa condição ausente.

O que a verificação da revisão precisa garantir?

Trate cada rascunho carregado como um par: seu texto editável e sua revisão-base. O salvamento envia o ID do artigo, o texto de substituição e a revisão-base sem alterações. A atualização no banco de dados precisa corresponder tanto ao ID quanto a essa revisão, substituir o texto e incrementar a revisão armazenada na mesma instrução.

Por exemplo, a condição WHERE id = $1 AND revision = $2 expressa o pré-requisito, enquanto SET body = $3, revision = revision + 1 realiza a transição. O PostgreSQL atualiza as linhas que satisfazem a condição, e RETURNING fornece valores das linhas efetivamente atualizadas. Uma atualização de zero linhas é um comando SQL bem-sucedido, portanto a aplicação precisa interpretá-la explicitamente. PostgreSQL: UPDATE.

Esse padrão costuma ser chamado de controle de concorrência otimista ou compare-and-swap. No projeto deste artigo, toda operação que altera o conteúdo editável do artigo precisa avançar a revisão. Mantenha as revisões não nulas, não as redefina e não reutilize uma identidade de artigo com um token de revisão antigo. Caso contrário, a igualdade poderia indicar falsamente que um rascunho antigo ainda corresponde à versão atual.

Uma leitura separada seguida de uma atualização que use somente o ID deixa o requisito fora da própria gravação. Da mesma forma, buscar a revisão mais recente pouco antes de salvar e usá-la no lugar da revisão enviada altera o sentido da solicitação: permite substituir uma versão que o editor não usou como base.

Execute um exemplo sintético completo no seu Mac

Tenha uma instância do PostgreSQL em execução, um banco de dados temporário existente e uma instalação atual ou LTS compatível do Node.js com npm. Abra o Terminal, crie uma pasta de trabalho e instale o driver com o comando documentado npm install pg. node-postgres: Introduction and Install.

mkdir revision-write-demo
cd revision-write-demo
npm install pg

Salve o programa completo a seguir como revision-demo.mjs nessa pasta. Ele usa a importação padrão compatível com ESM documentada. Tanto Client quanto Pool podem ler as configurações de conexão do PostgreSQL a partir de variáveis de ambiente; configure essas opções para o banco de dados temporário existente antes de executá-lo. node-postgres: ESM, node-postgres: Connecting.

O programa compara dois artigos sintéticos. O artigo 1 recebe dois salvamentos SERIALIZABLE incondicionais. O artigo 2 recebe dois salvamentos com verificação de revisão, ambos usando a mesma revisão original. Sua tabela temporária mantém a demonstração separada das tabelas permanentes de artigos. A sintaxe de JavaScript foi verificada com Node.js; a demonstração com o banco de dados não foi executada no ambiente de pesquisa.

import pg from 'pg';
import assert from 'node:assert/strict';
const { Client } = pg;
const client = new Client();

async function saveArticle(id, expectedRevision, body) {
  for (let attempt = 1; attempt <= 3; attempt += 1) {
    let active = false;
    try {
      await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
      active = true;
      const result = await client.query(
        `UPDATE revision_demo_articles
         SET body = $3, revision = revision + 1
         WHERE id = $1 AND revision = $2
         RETURNING id, body, revision`,
        [id, expectedRevision, body]
      );
      if (result.rowCount === 0) {
        await client.query('ROLLBACK');
        active = false;
        return { status: 'stale-or-missing' };
      }
      await client.query('COMMIT');
      active = false;
      return { status: 'saved', article: result.rows[0] };
    } catch (error) {
      if (active) await client.query('ROLLBACK');
      if (error.code !== '40001' || attempt === 3) throw error;
    }
  }
}

await client.connect();
try {
  await client.query(`
    CREATE TEMP TABLE revision_demo_articles (
      id integer PRIMARY KEY,
      body text NOT NULL,
      revision integer NOT NULL CHECK (revision > 0)
    )
  `);
  await client.query(
    `INSERT INTO revision_demo_articles (id, body, revision)
     VALUES (1, 'Opening draft', 1), (2, 'Opening draft', 1)`
  );
  const loaded = await client.query(
    'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
  );
  const expectedRevision = loaded.rows[1].revision;

  // Illustrative blind saves: each is a separate transaction.
  for (const body of ['First edit', 'Stale replacement']) {
    await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
    try {
      await client.query(
        `UPDATE revision_demo_articles
         SET body = $1, revision = revision + 1 WHERE id = 1`,
        [body]
      );
      await client.query('COMMIT');
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    }
  }

  const first = await saveArticle(2, expectedRevision, 'First edit');
  const stale = await saveArticle(2, expectedRevision, 'Stale replacement');
  assert.equal(first.status, 'saved');
  assert.equal(first.article.revision, 2);
  assert.equal(stale.status, 'stale-or-missing');

  const final = await client.query(
    'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
  );
  assert.deepEqual(final.rows, [
    { id: 1, body: 'Stale replacement', revision: 3 },
    { id: 2, body: 'First edit', revision: 2 }
  ]);
  console.table(final.rows);
  console.log(stale.status);
} finally {
  await client.end();
}

Se o banco de dados temporário existente se chamar revision_demo, execute o comando abaixo. Substitua pelo nome real do seu banco de dados e mantenha as configurações de conexão do ambiente necessárias para sua instância local. node-postgres: Connecting.

PGDATABASE=revision_demo node revision-demo.mjs

Os valores finais esperados são:

Artigo Corpo Revisão Significado
1 Stale replacement 3 Ambos os salvamentos incondicionais foram confirmados
2 First edit 2 O segundo salvamento com verificação de revisão foi rejeitado

O status final impresso deve ser stale-or-missing.

A sequência evita deliberadamente a concorrência dependente do tempo. Ela demonstra diretamente o limite central: uma substituição desatualizada pode ter sucesso mesmo quando todas as transações de salvamento são serializáveis, pois os salvamentos são executados em uma ordem serial válida.

Como o node-postgres deve tratar cada resultado?

O programa passa os valores separadamente do texto SQL usando a API documentada do driver para consultas parametrizadas. Ele também verifica rowCount, que informa quantas linhas uma atualização processou; um array rows vazio, por si só, não substitui de forma geral a verificação da quantidade de linhas atualizadas. node-postgres: Queries, node-postgres: pg.Result.

Use a seguinte tabela de decisão ao adaptar a função:

Resultado Ação da aplicação
Uma linha atualizada e confirmação bem-sucedida Aceite o salvamento e mantenha a revisão retornada
Nenhuma linha atualizada Rejeite o salvamento por estar desatualizado ou por o artigo não existir; preserve o rascunho enviado
SQLSTATE 40001 Reverta e tente novamente a transação completa, respeitando uma política com limite de tentativas
Outro erro ou limite de tentativas atingido Apresente a falha; não informe que o salvamento foi bem-sucedido

Um resultado de zero linhas não permite determinar se o artigo foi excluído ou se sua revisão mudou. A demonstração combina intencionalmente esses resultados. Se a interface precisar de mais detalhes, carregue o artigo atual depois e apresente o resultado como uma observação posterior. Não o descreva como prova do estado exato no momento da atualização que falhou.

Falhas de serialização usam o SQLSTATE 40001. O PostgreSQL exige que se tente novamente a transação completa, incluindo a lógica que decide as instruções e os valores. Podem ser necessárias várias tentativas, e elas não garantem que a operação será concluída. O exemplo usa três tentativas como política ilustrativa da aplicação. PostgreSQL: Serialization Failure Handling.

Mantenha expectedRevision sem alterações entre essas tentativas. Ele representa o pré-requisito do rascunho enviado. Se outro salvamento avançou a revisão, uma nova transação poderá rejeitar o token antigo, atualizando zero linhas. Substituir automaticamente o token pela revisão mais recente removeria a proteção contra gravações desatualizadas.

A função só retorna sucesso depois que COMMIT termina. Todas as instruções da transação usam o mesmo Client, conforme exigido pelo node-postgres. Ao adaptar isso para um pool, obtenha um cliente com pool.connect(), use esse cliente durante toda a transação e libere-o depois. node-postgres: Transactions, node-postgres: pg.Pool.

Você ainda precisa de SERIALIZABLE ou SELECT FOR UPDATE?

Para essa atualização condicional de uma única linha e escopo restrito, Read Committed também pode impor o pré-requisito da revisão: depois de aguardar a confirmação de uma atualização concorrente, o PostgreSQL reavalia a condição da atualização em relação à linha alterada. Isso não amplia o Read Committed para oferecer garantias sobre regras complexas envolvendo várias linhas. PostgreSQL: Transaction Isolation.

Mantenha SERIALIZABLE quando a transação ao redor depender de leituras e gravações mais abrangentes que exigem suas garantias. A condição da revisão expressa o pré-requisito do editor; o isolamento rege as interações entre as operações do banco de dados na transação. Escolha ambos conforme a tarefa real.

SELECT FOR UPDATE pode dar suporte a uma transação que precise inspecionar uma linha e executar várias operações dependentes. Seu bloqueio de linha impede gravações conflitantes até o fim da transação. Em Repeatable Read ou Serializable, bloquear uma linha alterada desde o snapshot da transação pode gerar um erro. PostgreSQL: Explicit Locking.

Adquirir esse bloqueio somente quando chega um salvamento ainda deixa a cargo da aplicação comparar a revisão-base enviada. Bloquear a linha atual não informa ao banco de dados qual revisão anterior deu origem ao rascunho. Para a substituição de uma linha mostrada aqui, a atualização condicional já combina a comparação e a alteração.

Antes de integrar esse padrão, verifique todos os caminhos que gravam conteúdo, preserve o rascunho do editor em caso de rejeição e atualize a revisão-base do editor somente após a confirmação do salvamento. Quando um salvamento desatualizado for rejeitado, ofereça o artigo atual junto com o rascunho preservado para que o leitor possa reconciliar o texto e enviar um novo salvamento com base na revisão que realmente analisou.

Fontes