Um das Speichern eines veralteten Dokuments abzulehnen, nimm die ursprünglich vom Editor geladene Revision in die Bedingung des Updates auf und erhöhe sie dann atomar. SERIALIZABLE allein kann diese Anforderung nicht ausdrücken, wenn das Dokument in einer früheren Transaktion geladen wurde.

Dieses Tutorial richtet sich an Entwickler, die im Terminal auf einem Mac arbeiten und einen PostgreSQL-Artikeleditor benötigen, der veraltete Ersatztexte ablehnt. Es verwendet synthetische Artikelzeilen und eine vollständige Demonstration mit node-postgres. Die Anforderung ist eindeutig: Speichern nur dann, wenn die gespeicherte Revision noch mit der Revision übereinstimmt, auf der der eingereichte Entwurf basiert.

Warum kann SERIALIZABLE einen veralteten Ersatz zulassen?

PostgreSQL definiert serialisierbare Ausführung so, dass sie einen Effekt erzeugt, der dem entspricht, die festgeschriebenen Transaktionen nacheinander in einer bestimmten Reihenfolge auszuführen. Diese Garantie betrifft Datenbanktransaktionen und ihre Operationen. PostgreSQL: Transaktionsisolation.

Betrachte folgende beispielhafte Abfolge:

  1. Ein Editor lädt Artikel 1 mit Revision 1, und die Lesetransaktion endet.
  2. Ein anderer Speichervorgang ersetzt den Artikeltext und schreibt Revision 2 fest.
  3. Der ursprüngliche Editor reicht Ersatztext ein, der auf Revision 1 basiert.
  4. Eine neue serialisierbare Transaktion aktualisiert Artikel 1 allein anhand seiner ID.

Die letzte Transaktion kann erfolgreich sein: Die Transaktionen wurden bereits nacheinander ausgeführt. Ein Update, dessen Bedingung nur id = 1 enthält, besagt nicht, dass der eingereichte Text Revision 1 voraussetzt. Dies ist eine Anwendung der dokumentierten Definition von Serialisierbarkeit und keine Ausnahme davon.

Bei sich überschneidenden Transaktionen ist die Lage anders. PostgreSQL kann eine serialisierbare Transaktion ablehnen, wenn ein gleichzeitiges Update mit ihrem Snapshot kollidiert oder Abhängigkeiten eine serielle Ausführung verhindern würden. Der Snapshot wird durch die erste Anweisung festgelegt, die keine Transaktionssteuerungsanweisung ist; allein das frühere Ausführen von BEGIN erhält keinen früheren Lesevorgang des Editors. PostgreSQL: Transaktionsisolation.

Die entscheidende Diagnosefrage lautet daher: Wo verlangt der Speichervorgang ausdrücklich die Revision, die der Editor geladen hat? Wenn die Antwort „nirgendwo“ lautet, fügt ein Wechsel der Isolationsstufe diese fehlende Bedingung nicht hinzu.

Was muss die Revisionsprüfung gewährleisten?

Betrachte jeden geladenen Entwurf als Paar: seinen bearbeitbaren Text und seine Basisrevision. Beim Speichern werden die Artikel-ID, der Ersatztext und die unveränderte Basisrevision übergeben. Das Datenbank-Update muss sowohl mit der ID als auch mit dieser Revision übereinstimmen, den Text ersetzen und die gespeicherte Revision in derselben Anweisung erhöhen.

Zum Beispiel drückt die Bedingung WHERE id = $1 AND revision = $2 die Voraussetzung aus, während SET body = $3, revision = revision + 1 den Übergang ausführt. PostgreSQL aktualisiert Zeilen, die die Bedingung erfüllen, und RETURNING liefert Werte aus tatsächlich aktualisierten Zeilen. Ein Update, das keine Zeilen betrifft, ist ein erfolgreich ausgeführter SQL-Befehl; die Anwendung muss dieses Ergebnis daher ausdrücklich interpretieren. PostgreSQL: UPDATE.

Dieses Muster wird oft als optimistische Nebenläufigkeitskontrolle oder Compare-and-Swap bezeichnet. Für das Design dieses Artikels muss jede Operation, die bearbeitbare Artikelinhalte ändert, die Revision erhöhen. Halte Revisionen auf NOT NULL, setze sie nicht zurück und verwende eine Artikelidentität nicht mit einem alten Revisionstoken erneut. Andernfalls könnte Gleichheit fälschlich den Eindruck erwecken, ein alter Entwurf stimme noch überein.

Ein separates Lesen mit anschließendem Update, das nur die ID verwendet, lässt die Anforderung aus dem Schreibvorgang heraus. Ebenso ändert das Abrufen der neuesten Revision unmittelbar vor dem Speichern und ihr Ersetzen durch die übermittelte Revision die Bedeutung der Anfrage: Es erlaubt den Ersatz einer Version, die der Editor nicht als Grundlage verwendet hat.

Führe ein vollständiges synthetisches Beispiel auf deinem Mac aus

Du brauchst eine laufende PostgreSQL-Instanz, eine vorhandene Testdatenbank und eine aktuelle oder unterstützte LTS-Installation von Node.js mit npm. Öffne Terminal, erstelle einen Arbeitsordner und installiere den Treiber mit dem dokumentierten Befehl npm install pg. node-postgres: Einführung und Installation.

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

Speichere das folgende vollständige Programm in diesem Ordner als revision-demo.mjs. Es verwendet den dokumentierten ESM-kompatiblen Standardimport. Sowohl Client als auch Pool können PostgreSQL-Verbindungseinstellungen aus Umgebungsvariablen lesen. Konfiguriere diese Einstellungen vor der Ausführung für deine vorhandene Testdatenbank. node-postgres: ESM, node-postgres: Verbindung herstellen.

Das Programm vergleicht zwei synthetische Artikel. Für Artikel 1 werden zwei uneingeschränkte serialisierbare Speichervorgänge ausgeführt. Für Artikel 2 werden zwei revisionsgeprüfte Speichervorgänge mit derselben ursprünglichen Revision ausgeführt. Die temporäre Tabelle hält die Demonstration von permanenten Artikeltabellen getrennt. Die JavaScript-Syntax wurde mit Node.js geprüft; die Datenbankdemonstration wurde in der Rechercheumgebung nicht ausgeführt.

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();
}

Wenn deine vorhandene Testdatenbank revision_demo heißt, führe den folgenden Befehl aus. Ersetze den Datenbanknamen durch den tatsächlichen Namen und behalte alle Verbindungseinstellungen für deine lokale Instanz bei. node-postgres: Verbindung herstellen.

PGDATABASE=revision_demo node revision-demo.mjs

Die erwarteten Endwerte sind:

Artikel Inhalt Revision Bedeutung
1 Stale replacement 3 Beide uneingeschränkten Speichervorgänge wurden festgeschrieben
2 First edit 2 Der zweite revisionsgeprüfte Speichervorgang wurde abgelehnt

Der ausgegebene Endstatus sollte stale-or-missing lauten.

Die Abfolge vermeidet bewusst eine von der Ausführungszeit abhängige Nebenläufigkeit. Sie zeigt die zentrale Grenze direkt: Ein veralteter Ersatz kann erfolgreich sein, selbst wenn jede Speichervorgangstransaktion serialisierbar ist, weil die Speichervorgänge in einer gültigen seriellen Reihenfolge ausgeführt werden.

Wie sollte node-postgres mit den einzelnen Ergebnissen umgehen?

Das Programm übergibt Werte getrennt vom SQL-Text und verwendet dafür die dokumentierte API des Treibers für parametrisierte Abfragen. Es prüft außerdem rowCount, das angibt, wie viele Zeilen ein Update verarbeitet hat; ein leeres Array rows allein ist kein allgemeiner Ersatz für die Prüfung der Update-Anzahl. node-postgres: Abfragen, node-postgres: pg.Result.

Verwende beim Anpassen der Funktion die folgende Entscheidungstabelle:

Ergebnis Aktion der Anwendung
Eine Zeile aktualisiert und Commit erfolgreich Speichervorgang akzeptieren und die zurückgegebene Revision beibehalten
Keine Zeilen aktualisiert Diesen Speichervorgang als veraltet oder fehlgeschlagen ablehnen; den eingereichten Entwurf beibehalten
SQLSTATE 40001 Rollback ausführen und die gesamte Transaktion gemäß einer begrenzten Richtlinie erneut versuchen
Anderer Fehler oder Wiederholungsgrenze erreicht Fehler melden; den Speichervorgang nicht als erfolgreich ausgeben

Ein Ergebnis ohne aktualisierte Zeilen zeigt nicht, ob der Artikel gelöscht wurde oder sich seine Revision geändert hat. Die Demonstration fasst diese Fälle bewusst zusammen. Benötigt die Oberfläche genauere Angaben, lade anschließend den aktuellen Artikel und zeige dieses Ergebnis als spätere Beobachtung an. Stelle es nicht als Beweis für den exakten Zustand zum Zeitpunkt des fehlgeschlagenen Updates dar.

Serialisierungsfehler verwenden den SQLSTATE 40001. PostgreSQL verlangt, die gesamte Transaktion erneut auszuführen, einschließlich der Logik, die über Anweisungen und Werte entscheidet. Es können mehrere Versuche erforderlich sein, und ein erfolgreicher Abschluss ist nicht garantiert. Das Beispiel verwendet drei Versuche als beispielhafte Anwendungsrichtlinie. PostgreSQL: Umgang mit Serialisierungsfehlern.

Lass expectedRevision während dieser Versuche unverändert. Es stellt die Voraussetzung des eingereichten Entwurfs dar. Wenn ein anderer Speichervorgang die Revision erhöht hat, kann eine neue Transaktion das alte Token dann mit null aktualisierten Zeilen ablehnen. Wird das Token automatisch durch die neueste Revision ersetzt, entfällt der Schutz vor veralteten Schreibvorgängen.

Die Funktion gibt erst dann Erfolg zurück, wenn COMMIT abgeschlossen ist. Alle Transaktionsanweisungen verwenden denselben Client, wie es node-postgres erfordert. Wenn du den Code an einen Pool anpasst, beziehe mit pool.connect() einen Client, verwende diesen Client während der gesamten Transaktion und gib ihn danach frei. node-postgres: Transaktionen, node-postgres: pg.Pool.

Brauchst du weiterhin SERIALIZABLE oder SELECT FOR UPDATE?

Für dieses eng umrissene bedingte Update einer einzelnen Zeile kann auch Read Committed die Revisionsvoraussetzung durchsetzen: Nachdem auf ein gleichzeitiges Update gewartet wurde, das festgeschrieben wird, wertet PostgreSQL die Update-Bedingung anhand der aktualisierten Zeile erneut aus. Daraus folgt keine Garantie von Read Committed für komplexe Regeln, die mehrere Zeilen betreffen. PostgreSQL: Transaktionsisolation.

Behalte SERIALIZABLE bei, wenn die umgebende Transaktion von umfassenderen Lese- und Schreibvorgängen abhängt, für die diese Garantien erforderlich sind. Die Revisionsbedingung drückt die Voraussetzung des Editors aus; die Isolation regelt die Wechselwirkungen zwischen den Datenbankoperationen der Transaktion. Wähle beides entsprechend der tatsächlichen Aufgabe.

SELECT FOR UPDATE kann eine Transaktion unterstützen, die eine Zeile prüfen und mehrere davon abhängige Operationen ausführen muss. Die Zeilensperre blockiert konkurrierende Schreibvorgänge bis zum Ende der Transaktion. Unter Repeatable Read oder Serializable kann das Sperren einer Zeile, die seit dem Snapshot der Transaktion geändert wurde, einen Fehler verursachen. PostgreSQL: Explizite Sperren.

Wird diese Sperre erst beim Eintreffen eines Speichervorgangs erworben, muss die Anwendung dennoch die übermittelte Basisrevision vergleichen. Die aktuelle Zeile zu sperren, verrät der Datenbank nicht, welche frühere Revision dem Entwurf zugrunde lag. Für den hier gezeigten Ersatz einer einzelnen Zeile kombiniert das bedingte Update Vergleich und Änderung bereits in einem Schritt.

Prüfe vor der Integration des Musters alle Wege, über die Inhalte geschrieben werden, bewahre den Entwurf des Editors bei einer Ablehnung auf und aktualisiere die Basisrevision des Editors erst nach einem bestätigten Speichervorgang. Wird ein veralteter Speichervorgang abgelehnt, biete den aktuellen Artikel zusammen mit dem erhaltenen Entwurf an, damit der Leser den Text abgleichen und einen neuen Speichervorgang auf Grundlage der tatsächlich geprüften Revision einreichen kann.

Quellen