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:
- Um editor carrega o artigo 1 na revisão 1, e a transação de leitura termina.
- Outro salvamento substitui o texto do artigo e confirma a revisão 2.
- O editor original envia um texto de substituição baseado na revisão 1.
- 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
- 13.2. Transaction Isolation — PostgreSQL Global Development Group.
- UPDATE — PostgreSQL Global Development Group.
- 13.5. Serialization Failure Handling — PostgreSQL Global Development Group.
- 13.3. Explicit Locking — PostgreSQL Global Development Group.
- node-postgres: Introduction and Install — node-postgres.
- ESM — node-postgres.
- Connecting — node-postgres.
- Queries — node-postgres.
- pg.Result — node-postgres.
- Transactions — node-postgres.
- pg.Pool — node-postgres.