Per rifiutare un salvataggio di documento obsoleto, includere la revisione originariamente caricata dall'editor nella condizione di aggiornamento, quindi incrementarla atomicamente. SERIALIZABLE da solo non può esprimere tale requisito quando il documento è stato caricato in una transazione precedente.
Questa guida è per uno sviluppatore che lavora in Terminale su un Mac e ha bisogno di un editor di articoli PostgreSQL per rifiutare il testo di sostituzione obsoleto. Utilizza righe di articoli sintetiche e una dimostrazione completa di node-postgres. L'obiettivo è preciso: salvare solo quando la revisione memorizzata corrisponde ancora alla revisione su cui si basava la bozza inviata.
Perché SERIALIZABLE può consentire una sostituzione obsoleta?
PostgreSQL definisce l'esecuzione serializzabile come la produzione di un effetto equivalente all'esecuzione delle transazioni confermate una alla volta in un certo ordine. Tale garanzia riguarda le transazioni del database e le loro operazioni. PostgreSQL: Transaction Isolation.
Considerate questa sequenza illustrativa:
- Un editor carica l'articolo 1 alla revisione 1 e la transazione di lettura termina.
- Un altro salvataggio sostituisce il testo dell'articolo e conferma la revisione 2.
- L'editor originale invia un testo di sostituzione basato sulla revisione 1.
- Una nuova transazione serializzabile aggiorna l'articolo 1 utilizzando solo il suo ID.
L'ultima transazione può avere successo: le transazioni sono già state eseguite in sequenza. Nulla in un aggiornamento condizionato solo su id = 1 dice che il testo inviato richiede la revisione 1. Questo è un'applicazione della definizione documentata di serializzabilità, piuttosto che un'eccezione a essa.
Le transazioni sovrapposte sono diverse. PostgreSQL può rifiutare una transazione serializzabile quando un aggiornamento concorrente confligge con il suo snapshot o quando le dipendenze impedirebbero l'esecuzione seriale. Lo snapshot viene stabilito dalla prima istruzione non di controllo della transazione; il semplice fatto di emettere BEGIN in precedenza non preserva una lettura precedente dell'editor. PostgreSQL: Transaction Isolation.
La domanda diagnostica è quindi: Dove il salvataggio richiede esplicitamente la revisione caricata dall'editor? Se la risposta è da nessuna parte, cambiare il livello di isolamento non fornisce quella condizione mancante.
Cosa deve garantire il controllo della revisione?
Trattate ogni bozza caricata come una coppia: il suo testo modificabile e la sua revisione di base. Il salvataggio porta l'ID dell'articolo, il testo di sostituzione e la revisione di base invariata. L'aggiornamento del database deve corrispondere sia all'ID che a quella revisione, sostituire il testo e incrementare la revisione memorizzata nella stessa istruzione.
Ad esempio, la condizione WHERE id = $1 AND revision = $2 esprime il prerequisito, mentre SET body = $3, revision = revision + 1 esegue la transizione. PostgreSQL aggiorna le righe che soddisfano la condizione, e RETURNING fornisce i valori dalle righe effettivamente aggiornate. Un aggiornamento a zero righe è un comando SQL riuscito, quindi l'applicazione deve interpretarlo esplicitamente. PostgreSQL: UPDATE.
Questo pattern è spesso chiamato controllo ottimistico della concorrenza o compare-and-swap. Per il design di questo articolo, ogni operazione che modifica il contenuto modificabile dell'articolo deve far avanzare la revisione. Mantenete le revisioni non null, non reimpostatele e non riutilizzate un'identità di articolo con un vecchio token di revisione. Altrimenti, l'uguaglianza potrebbe suggerire falsamente che una vecchia bozza corrisponda ancora.
Una lettura separata seguita da un aggiornamento che utilizza solo l'ID lascia il requisito fuori dalla scrittura stessa. Analogamente, recuperare la revisione più recente appena prima di salvare e sostituirla alla revisione inviata cambia il significato della richiesta: consente la sostituzione di una versione che l'editor non ha usato come base.
Eseguire un esempio sintetico completo sul proprio Mac
Disponete di un'istanza PostgreSQL in esecuzione, un database di prova esistente e un'installazione Node.js corrente o LTS supportata con npm. Aprite Terminale, create una cartella di lavoro e installate il driver con il comando documentato npm install pg. node-postgres: Introduction and Install.
mkdir revision-write-demo
cd revision-write-demo
npm install pg
Salvate il seguente programma completo come revision-demo.mjs in quella cartella. Utilizza l'importazione predefinita compatibile ESM documentata. Sia Client che Pool possono leggere le impostazioni di connessione PostgreSQL dalle variabili d'ambiente; configurate tali impostazioni per il vostro database di prova esistente prima di eseguirlo. node-postgres: ESM, node-postgres: Connecting.
Il programma confronta due articoli sintetici. L'articolo 1 riceve due salvataggi serializzabili incondizionati. L'articolo 2 riceve due salvataggi controllati per revisione utilizzando la stessa revisione originale. La sua tabella temporanea mantiene la dimostrazione separata dalle tabelle degli articoli permanenti. La sintassi JavaScript è stata verificata con Node.js; la dimostrazione del database non è stata eseguita nell'ambiente di ricerca.
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 il database di prova esistente si chiama revision_demo, eseguite il comando sottostante. Sostituite il nome effettivo del database e mantenete tutte le impostazioni d'ambiente di connessione richieste dalla vostra istanza locale. node-postgres: Connecting.
PGDATABASE=revision_demo node revision-demo.mjs
I valori finali attesi sono:
| Article | Body | Revision | Meaning |
|---|---|---|---|
| 1 | Sostituzione obsoleta |
3 | Entrambi i salvataggi incondizionati confermati |
| 2 | Prima modifica |
2 | Il secondo salvataggio controllato per revisione è stato rifiutato |
Lo stato finale stampato dovrebbe essere stale-or-missing.
La sequenza evita deliberatamente la concorrenza dipendente dal tempo. Dimostra direttamente il confine centrale: una sostituzione obsoleta può riuscire anche quando ogni transazione di salvataggio è serializzabile, perché i salvataggi vengono eseguiti in un ordine seriale valido.
Come deve gestire node-postgres ogni esito?
Il programma passa i valori separatamente dal testo SQL utilizzando l'API di query parametrizzata documentata del driver. Controlla anche rowCount, che riporta quante righe un aggiornamento ha elaborato; un array rows vuoto da solo non è una sostituzione generale per il controllo dei conteggi di aggiornamento. node-postgres: Queries, node-postgres: pg.Result.
Utilizzate la seguente tabella decisionale quando adattate la funzione:
| Outcome | Application action |
|---|---|
| Una riga aggiornata e commit riuscito | Accettare il salvataggio e conservare la revisione restituita |
| Zero righe aggiornate | Rifiutare questo salvataggio come obsoleto o mancante; preservare la bozza inviata |
SQLSTATE 40001 |
Eseguire rollback e ritentare la transazione completa, soggetta a una politica limitata |
| Altro errore, o limite di tentativi raggiunto | Segnalare il fallimento; non riportare un salvataggio riuscito |
Un risultato a zero righe non stabilisce se l'articolo è stato eliminato o la sua revisione è cambiata. La dimostrazione combina intenzionalmente quei risultati. Se l'interfaccia necessita di più dettagli, caricate l'articolo corrente in seguito e presentate quel risultato come un'osservazione successiva. Non descrivetelo come prova dello stato preciso al momento dell'aggiornamento fallito.
I fallimenti di serializzazione usano SQLSTATE 40001. PostgreSQL richiede di ripetere l'intera transazione, inclusa la logica che decide istruzioni e valori. Il nuovo tentativo può richiedere più tentativi e non garantisce il completamento finale. L'esempio usa tre tentativi come politica applicativa illustrativa. PostgreSQL: Serialization Failure Handling.
Mantenete expectedRevision invariato in quei tentativi. Rappresenta il prerequisito della bozza inviata. Se un altro salvataggio ha fatto avanzare la revisione, una nuova transazione può quindi rifiutare il vecchio token con zero righe aggiornate. Sostituire automaticamente il token con la revisione più recente rimuoverebbe la protezione contro le scritture obsolete.
La funzione restituisce successo solo dopo che COMMIT completa. Tutte le istruzioni della transazione usano lo stesso Client, come richiesto da node-postgres. Quando adattate questo a un pool, acquisite un client con pool.connect(), usate quel client per tutta la transazione e rilasciatelo dopo. node-postgres: Transactions, node-postgres: pg.Pool.
Avete ancora bisogno di SERIALIZABLE o SELECT FOR UPDATE?
Per questo aggiornamento condizionale su singola riga con ambito ristretto, Read Committed può imporre anche il prerequisito della revisione: dopo aver atteso un aggiornatore concorrente che conferma, PostgreSQL rivaluta la condizione dell'aggiornamento rispetto alla riga aggiornata. Ciò non estende Read Committed a una garanzia per regole complesse multi-riga. PostgreSQL: Transaction Isolation.
Mantenete SERIALIZABLE quando la transazione circostante dipende da letture e scritture più ampie che richiedono le sue garanzie. La condizione di revisione esprime il prerequisito dell'editor; l'isolamento governa le interazioni tra le operazioni di database della transazione. Scegliete entrambi in base al compito effettivo.
SELECT FOR UPDATE può supportare una transazione che ha bisogno di ispezionare una riga ed eseguire diverse operazioni dipendenti. Il suo blocco di riga blocca gli scrittori in conflitto fino alla fine della transazione. Sotto Repeatable Read o Serializable, il blocco di una riga modificata dallo snapshot della transazione può produrre un errore. PostgreSQL: Explicit Locking.
Acquisire quel blocco solo quando arriva un salvataggio lascia comunque l'applicazione responsabile del confronto della revisione di base inviata. Bloccare la riga corrente non può dire al database quale revisione precedente ha prodotto la bozza. Per la sostituzione su singola riga mostrata qui, l'aggiornamento condizionale combina già il confronto e il cambiamento.
Prima di integrare il pattern, verificate ogni percorso di scrittura del contenuto, preservate la bozza dell'editor in caso di rifiuto e aggiornate la revisione di base dell'editor solo dopo un salvataggio confermato. Quando un salvataggio obsoleto viene rifiutato, offrite l'articolo corrente accanto alla bozza preservata in modo che il lettore possa riconciliare il testo e inviare un nuovo salvataggio basato sulla revisione effettivamente rivista.
Fonti
- 13.2. Transaction Isolation — Gruppo di sviluppo globale di PostgreSQL.
- UPDATE — Gruppo di sviluppo globale di PostgreSQL.
- 13.5. Serialization Failure Handling — Gruppo di sviluppo globale di PostgreSQL.
- 13.3. Explicit Locking — Gruppo di sviluppo globale di PostgreSQL.
- 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.