Ein langer PostgreSQL-Snapshot kann Zwischenversionen von Zeilen behalten, die er nie liest. Vacuum verwendet einen konservativen Transaktionshorizont, um zu entscheiden, was entfernt werden kann. Diese Anleitung zeigt, warum das so ist, und reproduziert das Verhalten anschließend mit synthetischen Artikelzeilen und node-postgres auf einem Mac.

Die Aufgabe besteht darin, einen Snapshot, der die Bereinigung aufhält, von einem Vacuum zu unterscheiden, das noch nicht ausgeführt wurde. Das Beispiel zielt auf PostgreSQL 18 ab und verwendet zwei Datenbankverbindungen: eine lesende Verbindung, die einen Repeatable-Read-Snapshot hält, und eine schreibende Verbindung, die einen einzelnen Artikel aktualisiert. Die PostgreSQL-Dokumentation nennt Version 18 derzeit als aktuelle unterstützte Hauptversion; die Implementierungsdetails unten beziehen sich ausdrücklich auf deren 18-Zweig. PostgreSQL-18-Dokumentation.

Warum Versionen behalten, die der alte Leser nicht sehen kann?

Bei einer Aktualisierung wird eine neue Zeilenversion erstellt. PostgreSQL behält die vorherige Version, solange eine andere Transaktion sie möglicherweise noch benötigt. Schließlich kann Vacuum veraltete Versionen zurückgewinnen und ihren Speicherplatz wiederverwendbar machen. Das ist der grundlegende MVCC-Lebenszyklus, der unter Routine Vacuuming beschrieben wird.

Der Knackpunkt ist, dass Sichtbarkeit für Snapshots und die physische Entfernbarkeit unterschiedliche Fragen beantworten. Bei der Sichtbarkeit geht es darum, ob eine bestimmte Abfrage eine Version zurückgeben soll. Für die Bereinigung wird ein konservativer Grenzwert verwendet, statt für jede Version genau zu ermitteln, welche vorhandenen Snapshots sie lesen könnten.

In PostgreSQL 18 vergleicht HeapTupleSatisfiesVacuum die Transaktion, durch die eine Version veraltet wurde, mit OldestXmin. Eine bestätigte Löschung oder Ersetzung macht die vorherige Version nicht sofort entfernbar: Versionen, deren löschende Transaktion bei oder nach diesem Grenzwert liegt, werden weiterhin als kürzlich tot eingestuft. Diese Regel ist direkt in der offiziellen Implementierung der Tupelsichtbarkeit dokumentiert.

Betrachten wir die folgende beispielhafte Abfolge. Jede Aktualisierung wird separat bestätigt, und es gibt keine weiteren Leser:

Schritt Aktion Was der alte Leser sieht Neueste bestätigte Version
1 Artikelrevision 0 einfügen — Revision 0
2 Leser erstellt einen Repeatable-Read-Snapshot Revision 0 Revision 0
3 Schreiber bestätigt Revision 1 Revision 0 Revision 1
4 Schreiber bestätigt Revision 2 Revision 0 Revision 2
5 Schreiber bestätigt Revision 3 Revision 0 Revision 3

Revision 0 wird direkt vom Leser benötigt. Die Revisionen 1 und 2 sind Zwischenversionen: Dieser Leser kann sie nicht sehen, weil sie nach seinem Snapshot erstellt wurden. Dennoch liegen auch ihre Ersetzungstransaktionen nach dem beibehaltenen Bereinigungsgrenzwert. Aus der dokumentierten Grenzwertregel lässt sich erklären, warum diese Versionen neben Revision 0 bestehen bleiben können. Ihre Beibehaltung bedeutet nicht, dass der Leser sie als historische Abfrageergebnisse durchlaufen kann. Implementierung der Tupelsichtbarkeit in PostgreSQL 18.

Der Horizont umfasst mehr als nur die vom Leser ausgewählte Zeile. PostgreSQL 18 berechnet relationsspezifische Horizonte anhand von Transaktions-IDs der Backend-Prozesse und Snapshot-Horizonten; bei gewöhnlichen Tabellendaten werden im Allgemeinen Sitzungen in derselben Datenbank berücksichtigt. Er verfolgt nicht nur die Zeilen, die eine bestimmte SELECT zurückgegeben hat. Das erklärt, warum eine kleine Abfrage die Bereinigung an anderer Stelle in derselben Datenbank beeinflussen kann. Für gemeinsam genutzte Relationen und andere Relationstypen gelten eigene Regeln. Daher wäre die Aussage „ein Snapshot stoppt Vacuum überall“ zu weit gefasst. Implementierung der Horizonte in PostgreSQL 18.

Welche Transaktion hält tatsächlich einen langen Snapshot?

Repeatable Read erstellt seinen Snapshot mit der ersten Anweisung, die keine Transaktionssteuerung ist, und verwendet ihn dann für nachfolgende Abfragen. BEGIN allein auszuführen genügt für diese Demonstration nicht; erst die erste Artikelabfrage erstellt die Sicht, die beibehalten werden muss. Read Committed, die standardmäßige Isolationsstufe von PostgreSQL, erstellt stattdessen für jeden Befehl einen neuen Snapshot. Transaktionsisolation.

Daher kann eine inaktive Repeatable-Read-Transaktion, die die Tabelle abgefragt hat, ihren Snapshot auch nach Abschluss der Abfrage beibehalten. Eine lang laufende Read-Committed-Anweisung kann während ihrer Ausführung ebenfalls einen Snapshot halten, aber separate, abgeschlossene Anweisungen auf dieser Stufe teilen keine feste Sicht. Allein die Transaktionsdauer zeigt daher nicht, welcher Snapshot beibehalten wird. Transaktionsisolation.

Eine schreibgeschützte Transaktion kann zum Horizont beitragen, ohne selbst eine Transaktions-ID der obersten Ebene zu haben. Die Implementierung berücksichtigt sowohl Transaktions-IDs von Backend-Prozessen als auch Snapshot-Horizonte. In diesem Beispiel ist daher ein Nullwert bei backend_xid zusammen mit einem Nicht-Null-Wert bei backend_xmin damit vereinbar, dass ein Leser einen Snapshot beibehält. Implementierung der Horizonte in PostgreSQL 18.

Das Verhalten im Mac-Terminal reproduzieren

Sie benötigen einen laufenden lokalen PostgreSQL-18-Server, dessen Kommandozeilenwerkzeuge sowie Node.js mit npm. In der Kompatibilitätsdokumentation von node-postgres werden Node.js 20 und 24 aufgeführt. Diese Anleitung setzt voraus, dass Ihre vorhandenen lokalen Verbindungseinstellungen funktionieren. Sie erstellt eine separate Datenbank und verwendet ausschließlich synthetische Artikeltexte. Einführung und Installation von node-postgres.

Erstellen Sie in Terminal ein Arbeitsverzeichnis, installieren Sie den Treiber und erstellen Sie die Beispieldatenbank:

mkdir article-snapshot-lab
cd article-snapshot-lab
npm init -y
npm install pg
createdb snapshot_lab

Speichern Sie das vollständige Programm unten unter snapshot-demo.cjs. Die Verbindungen übernehmen PostgreSQL-Umgebungsvariablen, darunter PGDATABASE, PGHOST und PGPORT, wie unter Verbindungen mit node-postgres dokumentiert. Verwenden Sie für createdb und das Programm dieselben lokalen Verbindungseinstellungen.

Der Leser führt alle Transaktionsanweisungen über einen einzigen dedizierten Client aus. Das ist bei node-postgres erforderlich: Eine Transaktion muss während ihrer gesamten Dauer denselben Client verwenden. Daher eignet sich pool.query() nicht zum Ausführen ihrer Anweisungen. Der zweite Client führt Aktualisierungen und Vacuum-Befehle unabhängig davon aus. Transaktionen mit node-postgres.

Das Beispiel indiziert außerdem revision, das sich bei jeder Aktualisierung ändert. Dadurch können diese Aktualisierungen absichtlich kein HOT verwenden, was die Demonstration leichter verständlich macht. PostgreSQL dokumentiert, dass HOT voraussetzt, dass Aktualisierungen Spalten, auf die gewöhnliche Indizes verweisen, unverändert lassen und dass auf der ursprünglichen Seite genügend Platz vorhanden ist. Heap-Only-Tupel.

const { Client } = require('pg');

async function main() {
  const reader = new Client({
    application_name: 'article_snapshot_reader',
    connectionTimeoutMillis: 5000
  });
  const writer = new Client({
    application_name: 'article_snapshot_writer',
    connectionTimeoutMillis: 5000
  });
  let readerOpen = false;
  writer.on('notice', notice => console.log(notice.message));

  try {
    await reader.connect();
    await writer.connect();
    const version = await writer.query('SHOW server_version_num');
    const major = Math.floor(Number(version.rows[0].server_version_num) / 10000);
    if (major !== 18) throw new Error('This walkthrough targets PostgreSQL 18.');

    await writer.query(
      'CREATE TABLE public.article_snapshot_demo (' +
      'id integer PRIMARY KEY, revision integer NOT NULL, title text NOT NULL)'
    );
    await writer.query(
      'CREATE INDEX article_snapshot_demo_revision_idx ' +
      'ON public.article_snapshot_demo (revision)'
    );
    await writer.query(
      'INSERT INTO public.article_snapshot_demo VALUES (1, 0, $1)',
      ['Article draft']
    );

    await reader.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
    readerOpen = true;
    const selectRow = 'SELECT id, revision, title FROM public.article_snapshot_demo';
    console.log('Reader before:', (await reader.query(selectRow)).rows);

    for (const revision of [1, 2, 3]) {
      await writer.query(
        'UPDATE public.article_snapshot_demo SET revision = $1, title = $2 WHERE id = 1',
        [revision, 'Article revision ' + revision]
      );
    }

    const pid = (await reader.query('SELECT pg_backend_pid() AS pid')).rows[0].pid;
    console.log('Reader activity:', (await writer.query(
      'SELECT state, backend_xid, backend_xmin FROM pg_stat_activity WHERE pid = $1',
      [pid]
    )).rows);
    console.log('Writer latest:', (await writer.query(selectRow)).rows);
    console.log('Reader still:', (await reader.query(selectRow)).rows);

    console.log('Vacuum with snapshot open:');
    await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');

    await reader.query('COMMIT');
    readerOpen = false;

    console.log('Vacuum after reader commit:');
    await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');
    console.log('Reader fresh:', (await reader.query(selectRow)).rows);
  } finally {
    if (readerOpen) await reader.query('ROLLBACK').catch(console.error);
    await Promise.allSettled([reader.end(), writer.end()]);
  }
}

main().catch(error => {
  console.error(error.message);
  process.exitCode = 1;
});

Führen Sie das Programm aus und wählen Sie dabei die Datenbank ausdrücklich aus:

PGDATABASE=snapshot_lab node snapshot-demo.cjs

Die JavaScript-Syntax des Programms wurde mit Node.js geprüft. Die Datenbanksequenz wurde anhand der abgerufenen Dokumentation geprüft, aber für diesen Artikel nicht auf einem PostgreSQL-Server ausgeführt.

Die beiden Vacuum-Durchläufe richtig deuten

Die erwarteten Abfrageergebnisse sind Revision 0 für Reader before, Revision 3 für Writer latest und erneut Revision 0 für Reader still. Nachdem der Leser die Transaktion bestätigt hat, sollte seine nächste eigenständige Abfrage Revision 3 zurückgeben. Dies sind erwartete Ergebnisse der synthetischen Abfolge und keine aufgezeichneten Testergebnisse.

Das Aktivitätsergebnis sollte den Leser als idle in transaction ausweisen, während der Schreiber ihn überprüft, und einen beibehaltenen Wert für backend_xmin zeigen. PostgreSQL definiert diesen Zustand als eine Transaktion, die auf einen weiteren Befehl wartet, und backend_xmin als den aktuellen Horizont des Backend-Prozesses. Das kumulative Statistiksystem.

Prüfen Sie im ersten Vacuum-Bericht, welche veralteten Versionen nicht entfernt werden können. Nachdem der Leser seine Transaktion bestätigt hat, vergleichen Sie den zweiten Bericht und prüfen Sie, ob diese Versionen entfernt wurden. In einer ansonsten ruhigen Datenbank liefern die drei bestätigten Ersetzungen drei veraltete Versionen, die untersucht werden können. Andere beibehaltene Horizonte oder gleichzeitige Wartungsarbeiten können den genauen Bericht beeinflussen. Orientieren Sie sich daher am Vorher-Nachher-Muster und erwarten Sie nicht denselben exakten Ausgabetext.

VACUUM (VERBOSE) erstellt einen ausführlichen Aktivitätsbericht. Das Programm gibt Serverhinweise über das dokumentierte node-postgres-notice-Ereignis aus. Beide Vacuum-Aufrufe laufen außerhalb expliziter Transaktionsblöcke, weil PostgreSQL VACUUM innerhalb eines solchen Blocks nicht zulässt. PostgreSQL VACUUM, Client-API von node-postgres.

Verwenden Sie diese Entscheidungshilfe, wenn das Ergebnis von den Erwartungen abweicht:

Beobachtung Nächste Prüfung
Der Leser sieht Revision 3, bevor er die Transaktion bestätigt Prüfen Sie, ob Repeatable Read gestartet und die erste Artikelabfrage vor den Aktualisierungen ausgeführt wurde.
Das erste Vacuum lässt veraltete Versionen bestehen Vergleichen Sie diesen Durchlauf mit dem nach der Bestätigung durch den Leser; dass die Versionen bei geöffnetem Snapshot bestehen bleiben, ist zu erwarten.
Versionen bleiben auch nach der Bestätigung durch den Leser bestehen Prüfen Sie, ob es einen weiteren beibehaltenen Horizont gibt; das Beenden dieser Lesertransaktion entfernt nur ihren Beitrag.
Eine Tabellenstatistik scheint einen unplausiblen Zählerstand zu zeigen Behandeln Sie n_dead_tup als Schätzwert und vergleichen Sie die Vacuum-Berichte.
Die Tabellendatei wird nicht kleiner Prüfen Sie, ob der Speicherplatz wiederverwendbar wurde; eine Verkleinerung der Datei ist ein separates Ergebnis.

PostgreSQL beschreibt n_dead_tup ausdrücklich als Schätzwert. Auch ein erfasster Vacuum-Zeitpunkt belegt, dass die Wartung ausgeführt wurde, sagt aber nicht aus, dass jede veraltete Version entfernt werden konnte. Diese Felder helfen bei der Diagnose, ersetzen aber weder die Untersuchung des beibehaltenen Snapshots noch des Vacuum-Berichts. Das kumulative Statistiksystem.

Ein gewöhnliches Vacuum macht Speicherplatz normalerweise innerhalb der Tabelle wiederverwendbar, statt ihn an macOS zurückzugeben. Vollständig freie Seiten am Dateiende sind eine dokumentierte Ausnahme. Eine unveränderte Dateigröße nach der Bereinigung belegt daher nicht, dass die Bereinigung fehlgeschlagen ist. Regelmäßiges Vacuuming.

Was sollte sich in der Anwendung ändern?

Wenn mehrere Artikelabfragen dieselbe konsistente Datenbanksicht verwenden müssen, begrenzen Sie die Transaktion auf diese Abfragen und beenden Sie sie, sobald diese abgeschlossen sind. Wenn die Aufgabe es erlaubt, sollten Sie als Folge der Snapshot-Aufbewahrung nicht zusammenhängende Verarbeitung und Interaktionen am Desktop erst nach dem Ende dieser Transaktion ausführen.

Wenn jede Abfrage unabhängig die neueste bestätigte Sicht verwenden kann, liefert Read Committed Snapshots auf Befehlsebene. Ein Wechsel der Isolationsstufe oder das Bestätigen zwischen Stapeln ändert die Konsistenzsemantik: Spätere Abfragen können dann neu bestätigte Artikelrevisionen sehen. Wählen Sie diesen Ansatz nur, wenn diese wechselnden Ergebnisse zur Leseaufgabe passen. Transaktionsisolation.

Eine höhere Vacuum-Häufigkeit kann nicht bewirken, dass eine Version einen unveränderten Entfernbarkeitsgrenzwert passiert. Das Beenden des nicht erforderlichen Snapshots beseitigt dessen Beitrag zur Aufbewahrung. Ebenso ist VACUUM FULL eine Tabellumschreibung, für die eine exklusive Sperre und zusätzlicher Speicherplatz erforderlich sind; für dieses Beispiel ist es nicht die übliche Maßnahme. Implementierung der Tupelsichtbarkeit in PostgreSQL 18, PostgreSQL VACUUM.

Das Skript lässt seine synthetische Tabelle zur Untersuchung bestehen. Es schlägt absichtlich fehl, wenn diese Tabelle bereits existiert, statt vorhandene Inhalte zu ersetzen. Nachdem Sie sie untersucht haben, führen Sie den folgenden Befehl in einer Abfragesitzung aus, die mit snapshot_lab verbunden ist, wenn Sie die Beispieltabelle entfernen möchten. Falls ein Lauf während der Einrichtung teilweise fehlgeschlagen ist, untersuchen Sie dieselbe Tabelle, bevor Sie sie entfernen und den Vorgang erneut ausführen.

DROP TABLE public.article_snapshot_demo;

Die hilfreiche Diagnosefolge lautet: einen alten Snapshot erstellen, mehrere Ersetzungen bestätigen, Vacuum ausführen, den Snapshot beenden und erneut Vacuum ausführen. Wenn die ursprüngliche Revision lesbar bleibt, während Zwischenversionen sich ebenfalls der Bereinigung widersetzen, wird der Unterschied greifbar. Die Sichtbarkeitsregeln von PostgreSQL bestimmen, was die Abfrage zurückgibt; der Bereinigungshorizont bestimmt, wann veralteter Speicherplatz wiederverwendet werden kann.

Quellen