PostgreSQLの長時間スナップショットは、読み取ることのない中間行バージョンを保持することがあります。Vacuumは保守的なトランザクションの境界を使って、何を削除できるか判断します。この手順では、その理由を説明し、Mac上で合成記事行とnode-postgresを使って動作を再現します。

課題は、クリーンアップを妨げているスナップショットと、まだ実行されていないvacuumを区別することです。この例はPostgreSQL 18を対象とし、2つのデータベース接続を使用します。1つはRepeatable Readスナップショットを保持する読み取り側、もう1つは1件の記事を更新する書き込み側です。PostgreSQLのドキュメントでは、現在サポートされているメジャーバージョンとして18が示されています。以下の実装の詳細は、明示的に18ブランチに基づいています。PostgreSQL 18のドキュメント。

古い読み取り側から見えないバージョンをなぜ保持するのか?

更新によって新しい行バージョンが作成されます。別のトランザクションが必要とする可能性がある間は、PostgreSQLは以前のバージョンを保持します。やがてvacuumが不要になったバージョンを回収し、その領域を再利用可能にします。これが定期的なVacuumで説明されている基本的なMVCCのライフサイクルです。

重要なのは、スナップショットに対する可視性と、物理的に削除可能かどうかは別の問いに答えるという点です。可視性は、特定のクエリがあるバージョンを返すべきかを問います。クリーンアップでは、既存のスナップショットがそれぞれのバージョンを読めるかどうかを個別に厳密に判定するのではなく、保守的な境界値を使います。

PostgreSQL 18では、HeapTupleSatisfiesVacuumが、バージョンを不要にしたトランザクションとOldestXminを比較します。削除または置き換えがコミットされても、以前のバージョンがすぐに削除可能になるわけではありません。削除トランザクションがその境界値以上のバージョンは、最近削除されたものとして分類されたままです。このルールは公式のタプル可視性の実装に直接記載されています。

次の説明用のシーケンスを考えてみましょう。各更新は個別にコミットされ、ほかの読み取り側はいません。

手順 操作 古い読み取り側から見える値 最新のコミット済みバージョン
1 記事のリビジョン0を挿入 — リビジョン0
2 読み取り側がRepeatable Readスナップショットを確立 リビジョン0 リビジョン0
3 書き込み側がリビジョン1をコミット リビジョン0 リビジョン1
4 書き込み側がリビジョン2をコミット リビジョン0 リビジョン2
5 書き込み側がリビジョン3をコミット リビジョン0 リビジョン3

リビジョン0は読み取り側に直接必要です。リビジョン1と2は中間バージョンです。この読み取り側には、スナップショットの確立後に作成されたため見えません。それでも、これらを置き換えたトランザクションも、保持されているクリーンアップ境界より新しいものです。記載されている境界値のルールを当てはめると、リビジョン0とともにこれらのバージョンも保持される理由が分かります。保持されているからといって、読み取り側が過去のクエリ結果としてこれらをたどれるわけではありません。PostgreSQL 18のタプル可視性の実装。

境界値が考慮する範囲は、読み取り側が選択した行より広くなります。PostgreSQL 18は、バックエンドのトランザクションIDとスナップショットの境界から、リレーションに応じた境界値を計算します。通常のテーブルデータでは、一般に同じデータベース内のセッションが考慮されます。特定のSELECTが返した行だけを追跡するわけではありません。そのため、小さなクエリでも同じデータベース内の別の場所のクリーンアップに影響することがあります。共有リレーションやほかの種類のリレーションには別のルールがあるため、「1つのスナップショットがあらゆる場所でのvacuumを止める」というのは言い過ぎです。PostgreSQL 18の境界値の実装。

どのトランザクションが長いスナップショットを保持するのか?

Repeatable Readは、最初のトランザクション制御以外の文が実行されたときにスナップショットを確立し、その後のクエリでも同じスナップショットを使います。このデモでは、BEGINを発行するだけでは不十分です。保持すべきビューを確立するには、最初に記事を問い合わせる必要があります。PostgreSQLのデフォルトの分離レベルであるRead Committedでは、コマンドごとに新しいスナップショットが取得されます。トランザクションの分離。

したがって、テーブルを問い合わせた後にアイドル状態になったRepeatable Readトランザクションは、クエリが完了した後もスナップショットを保持できます。長時間実行されるRead Committed文も、実行中はスナップショットを保持できますが、その分離レベルでは、完了した別々の文が同じ固定ビューを共有することはありません。そのため、トランザクションの継続時間だけでは、どのスナップショットが保持されているかは分かりません。トランザクションの分離。

読み取り専用トランザクションは、それ自体に最上位のトランザクションIDがなくても、境界値に影響することがあります。実装はバックエンドのトランザクションIDとスナップショットの境界の両方を考慮します。この例で、backend_xidがnullである一方、backend_xminがnullでないのは、読み取り側がスナップショットを保持している状況と矛盾しません。PostgreSQL 18の境界値の実装。

Macのターミナルで動作を再現する

稼働中のローカルPostgreSQL 18サーバー、そのコマンドラインツール、npmを備えたNode.jsが必要です。node-postgresの互換性ドキュメントには、Node.js 20または24が記載されています。この手順では、現在のローカル接続設定が機能していることを前提とします。別のデータベースを作成し、合成した記事テキストのみを使用します。node-postgresの概要とインストール。

Terminalで作業ディレクトリを作成し、ドライバーをインストールして、例のデータベースを作成します。

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

以下の完全なプログラムをsnapshot-demo.cjsとして保存します。接続は、node-postgresの接続方法に記載されているPGDATABASE、PGHOST、PGPORTなどのPostgreSQL環境設定を引き継ぎます。createdbとプログラムで同じローカル接続設定を使ってください。

読み取り側は、すべてのトランザクション文を専用の1つのクライアント上で実行します。node-postgresでは、トランザクション内のすべての文で同じクライアントを使う必要があるため、pool.query()を通じて実行する方法は適していません。2つ目のクライアントは、更新とvacuumコマンドを独立して実行します。node-postgresのトランザクション。

この例では、更新のたびに変わるrevisionにもインデックスを設定します。これにより、更新でHOTが使われないようにし、動作を解釈しやすくしています。PostgreSQLのドキュメントによると、HOTを使うには、通常のインデックスが参照する列を更新で変更しないことと、元のページに十分な空き領域があることが必要です。ヒープオンリータプル。

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

データベースを明示して実行します。

PGDATABASE=snapshot_lab node snapshot-demo.cjs

プログラムのJavaScript構文はNode.jsで確認しました。データベースの手順は取得したドキュメントに照らして確認しましたが、この記事ではPostgreSQLサーバー上で実行していません。

2回のvacuumをどう解釈するか

期待されるクエリ結果は、Reader beforeがリビジョン0、Writer latestがリビジョン3、Reader stillが再びリビジョン0です。読み取り側がコミットした後に単独で実行する次のクエリでは、リビジョン3が返されるはずです。これらは合成シーケンスから予想される結果であり、実行して取得したテスト出力ではありません。

書き込み側が確認したとき、アクティビティ結果には読み取り側がidle in transactionと表示され、保持されているbackend_xminが示されるはずです。PostgreSQLでは、この状態はトランザクションが次のコマンドを待っていることを意味し、backend_xminはバックエンドの現在の境界値です。累積統計システム。

最初のvacuumレポートでは、不要になっているものの削除できないままのバージョンを確認します。読み取り側がコミットした後の2回目のレポートと比較し、これらのバージョンが削除されているかを確認します。ほかの境界値の保持や同時に実行されるメンテナンスがなければ、コミット済みの3回の置き換えにより、調べる対象となる不要なバージョンが3つできます。ほかの保持された境界値や同時メンテナンスによって正確なレポートは変わることがあるため、出力テキストが完全に一致することを求めず、前後のパターンを確認してください。

VACUUM (VERBOSE)は詳細なアクティビティレポートを出力します。プログラムは、ドキュメントに記載されているnode-postgresのnoticeイベントを通じてサーバーの通知を表示します。PostgreSQLではVACUUMを明示的なトランザクションブロック内で実行できないため、どちらのvacuum呼び出しもその外で実行します。PostgreSQLのVACUUM、node-postgresのClient API。

結果が予想と異なる場合は、次の判断材料を使ってください。

観察結果 次に確認すること
コミット前に読み取り側がリビジョン3を見る Repeatable Readが開始され、更新前に最初の記事クエリが実行されたことを確認します。
最初のvacuumで不要なバージョンが保持される 読み取り側のコミット後の処理と比較します。スナップショットを開いている間の保持は想定どおりです。
読み取り側のコミット後もバージョンが残る 別の境界値が保持されていないか確認します。この読み取り側を終了しても、取り除かれるのはその影響だけです。
テーブルの統計カウントが一致しないように見える n_dead_tupは推定値として扱い、vacuumレポートと比較します。
テーブルのファイルサイズが小さくならない 領域が再利用可能になったか確認します。ファイルサイズの縮小は別の結果です。

PostgreSQLはn_dead_tupを推定値として明記しています。vacuumの実行時刻が記録されていれば、メンテナンスが実行されたことは分かりますが、すべての不要なバージョンが削除可能だったことまでは分かりません。これらのフィールドは診断の助けになりますが、保持されたスナップショットとvacuumレポートの確認に代わるものではありません。累積統計システム。

通常のvacuumでは、領域はmacOSに返されず、テーブル内部で再利用可能になります。末尾のページが完全に空いている場合は、ドキュメントに記載された例外として領域が返されることがあります。そのため、クリーンアップ後にファイルサイズが変わらなくても、クリーンアップに失敗したとは限りません。定期的なVacuum。

アプリケーション側で何を変えるべきか?

複数の記事クエリで一貫したデータベースビューを共有する必要があるなら、それらのクエリにトランザクションの範囲を絞り、完了したら終了してください。スナップショット保持の仕組みを踏まえたアプリケーション設計として、タスク上可能な場合は、そのトランザクションを終了してから、無関係な処理やデスクトップでの操作を行います。

各クエリで独立して最新のコミット済みビューを使えるなら、Read Committedでコマンドごとにスナップショットを取得できます。分離レベルの変更やバッチ間のコミットは、一貫性の意味を変えます。後続のクエリが、新たにコミットされた記事のリビジョンを参照する可能性があります。結果が変わることが読み取り作業に適している場合に限って、この方法を選んでください。トランザクションの分離。

Vacuumの頻度を増やしても、変わらない削除可能性の境界をバージョンが通過できるわけではありません。不要なスナップショットを終了すれば、それが保持に与える影響を取り除けます。同様に、VACUUM FULLは排他ロックを必要とするテーブルの書き換えであり、追加の領域も必要です。この例に対する通常の対応ではありません。PostgreSQL 18のタプル可視性の実装、PostgreSQLのVACUUM。

このスクリプトは、確認できるよう合成テーブルを残します。既存の内容を置き換えず、そのテーブルがすでに存在する場合は意図的にエラーになります。確認後、例のテーブルを削除する場合は、snapshot_labに接続したクエリセッションで次のコマンドを実行してください。セットアップの途中で実行に失敗した場合は、そのテーブルを確認してから削除し、再実行してください。

DROP TABLE public.article_snapshot_demo;

役立つ診断手順は、古いスナップショットを確立し、置き換えを複数回コミットしてからvacuumを実行し、スナップショットを終了してもう一度vacuumを実行するという変化を追うことです。元のリビジョンを引き続き読み取れる一方で、中間バージョンもクリーンアップに抵抗していることを確認すると、その違いが明確になります。クエリが何を返すかはPostgreSQLの可視性ルールが決め、不要な領域がいつ再利用可能になるかはクリーンアップ境界が決めます。

出典