PostgreSQL のトランザクション ID 周回:VACUUM が行を凍結する理由と age(datfrozenxid) の読み方

PostgreSQL の32ビットのトランザクション ID が一周すると何が起きるのか、VACUUM の凍結と relfrozenxid・datfrozenxid、周回防止の autovacuum、警告と書き込み拒否の段階、復旧手順を PostgreSQL 18 の公式ドキュメントで整理しました。

開発データベースPostgreSQL

監視ダッシュボードに age(datfrozenxid) という数値があり、何もしていないのに毎日少しずつ増えている、という状況を考えます。この数値が何を数えているのか、増え続けるとどこで何が起きるのか、その前に PostgreSQL が自分で何をするのかがこの記事のテーマです。基準にしたのは PostgreSQL 18 のドキュメントの 24.1 Routine Vacuuming で、主に 24.1.5 Preventing Transaction ID Wraparound Failures と 24.1.6 The Autovacuum Daemon です。2026年10月19日に確認した時点で、このページは18を現行バージョン、19を開発版として表示していました。ほかのバージョンの動作は扱わず、設定値はこのページに書かれているものだけを引いています。

32ビットのカウンターを円の上で比べる

PostgreSQL の MVCC は、トランザクション ID(XID)を比べて行バージョンが見えるかどうかを決めます。挿入した XID が現在のトランザクションの XID より大きい行バージョンは「未来」のものなので、見えてはいけません。各文がどの行バージョンを見るかについては READ COMMITTED とスナップショット で別に扱いました。

問題は XID が32ビットだということです。ドキュメントによると、長く運用したクラスタが40億を超えるトランザクションを処理すると XID カウンターが0に戻り、過去だったトランザクションが突然未来に見えるようになります。するとそのトランザクションが書いた行が見えなくなります。ドキュメントはこれを壊滅的なデータ損失と呼び、データは実際には残っているが、取り出せないのなら慰めにはならないと付け加えています。

通常の XID は 2³² を法とする演算で比較されます。そのため、どの通常の XID にも、それより「古い」XID が20億個、「新しい」XID が20億個あります。ドキュメントの表現では、通常の XID 空間は終点のない円です。

              現在のトランザクションの XID
                         │
   ◀── 約20億:過去 ─────┼───── 約20億:未来 ──▶
   (円なので両端はつながっている)

ある行バージョンが特定の XID で作られると、その後20億トランザクションの間は過去に見えます。その行が20億トランザクションを超えて残っていると、突然未来側に移ります。固定幅の整数があふれると値が一周する点は 整数オーバーフロー の記事と同じですが、ここで反転するのは計算結果ではなく、行が見えるかどうかです。

凍結:いつまでも過去にある XID

解決策は VACUUM による凍結(freeze)です。凍結された行には、挿入したトランザクションが十分昔にコミットされていて、現在と将来のすべてのトランザクションから見える、という印が付きます。

そのために PostgreSQL は特別な XID である FrozenTransactionId を予約しています。この XID は通常の比較規則に従わず、常にすべての通常の XID より古いものとして扱われます。凍結された行バージョンは挿入 XID が FrozenTransactionId であるかのように扱われるので、周回とは関係なく、削除されるまでずっと過去にあります。

実装についてドキュメントが書き残していることもあります。9.4より前のバージョンは行の挿入 XID を実際に FrozenTransactionId(値は2)に置き換えていて、この値が xmin システム列に見えていました。それ以降のバージョンはフラグビットを立てるだけで、元の xmin は事後調査に使えるよう残します。そのため9.4より前から pg_upgrade したデータベースには、xmin が2の行がまだ残っていることがあります。システムカタログには initdb の最初の段階で入った xmin = 1(BootstrapTransactionId)の行もあり、これもすべての通常の XID より古いものとして扱われます。

つまり各テーブルは、20億トランザクションが過ぎる前に VACUUM で古い行を凍結しておく必要があります。ドキュメントは、すべてのデータベースのすべてのテーブルを少なくとも20億トランザクションごとに1回は VACUUM しなければならないと書いています。

relfrozenxid と datfrozenxid が数えているもの

VACUUM は、まだ凍結されていない最も古い XID を追跡するために、2つのシステムテーブルに値を残します。

  • pg_class.relfrozenxid:この値を実際に前進させた直近の VACUUM(通常は直近のアグレッシブ VACUUM)が終わった時点で、テーブルに残っていた最も古い未凍結 XID。
  • pg_database.datfrozenxid:そのデータベースに現れる未凍結 XID の下限。データベース内のテーブルの relfrozenxid の最小値です。

age() は、この基準の XID から現在のトランザクションの XID まで何トランザクション経ったかを返します。冒頭のダッシュボードの数値がこれです。ドキュメントが例に挙げているクエリは次の2つです。

SELECT c.oid::regclass as table_name,
       greatest(age(c.relfrozenxid),age(t.relfrozenxid)) as age
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relkind IN ('r', 'm');

SELECT datname, age(datfrozenxid) FROM pg_database;

1つ目はテーブルごとに、reltoastrelid でつながる TOAST テーブルと比べて大きいほうの年齢を表示します。2つ目はデータベース単位です。データベースの数値が大きければ、1つ目のクエリでどのテーブルが押し上げているかを探せます。

VACUUM はいつ relfrozenxid を前進させるか

VACUUM を頻繁に実行しても、この年齢が下がらないことがあります。VACUUM は可視性マップ(visibility map)を見てスキャンするページを選び、通常は不要な行バージョンがないページを飛ばします。そのページに古い XID が残っていても同じです。そのため通常の VACUUM がテーブルの古い行をすべて凍結する保証はなく、いずれアグレッシブ(aggressive)VACUUM が必要になります。アグレッシブ VACUUM は、all-visible だが all-frozen ではないページも含めて、凍結できる未凍結の XID と MXID をすべて凍結します。

PostgreSQL 18 のドキュメントはその中間の段階も説明しています。all-visible だが all-frozen ではないページがたまってきていると、通常の VACUUM も飛ばせるページの一部をあえてスキャンして凍結を試みることがあり、これを eager scan と呼びます。vacuum_max_eager_freeze_failure_rate を上げると、より多く試みるようにできます。ここで凍結に成功したページは次のアグレッシブ VACUUM で飛ばせますが、ほとんどのテーブルには依然として定期的なアグレッシブ VACUUM が必要だとドキュメントは書いています。

relfrozenxid が前進するのは、未凍結の XID を含みうるすべてのページをスキャンしたときだけです。ドキュメントが挙げるのは次の3つの場合です。

  • relfrozenxid が vacuum_freeze_table_age より古いとき
  • VACUUM の FREEZE オプションを使ったとき
  • まだ all-frozen でないページが、たまたますべて不要行の除去のためにスキャン対象になったとき

こうして全体をスキャンすると、age(relfrozenxid) はそのとき使った vacuum_freeze_min_age より少し大きい値になります。VACUUM の開始後に始まったトランザクションの数だけ大きくなります。VACUUM はテーブルに残った最も古い XID を relfrozenxid に記録するので、結果が必要以上に新しい値になることもあります。

関係する2つの設定の役割は次のとおりです。

  • vacuum_freeze_min_age:XID がどれだけ古くなったら、その XID を持つ行を凍結するか。上げるとすぐにまた更新される行を凍結する無駄を減らせ、下げると次に必ず VACUUM しなければならない時点までの余裕が増えます。
  • vacuum_freeze_table_age:いつアグレッシブ VACUUM を行うか。0にすると VACUUM は常にアグレッシブな戦略を使います。実効上の最大値は autovacuum_freeze_max_age の0.95倍で、それより大きい値はそこまでに制限されます。

周回防止の autovacuum

ドキュメントによると、テーブルが VACUUM なしで耐えられる最大の期間は、20億トランザクションから、最後のアグレッシブ VACUUM のときの vacuum_freeze_min_age を引いた分です。これを超えないよう、PostgreSQL は autovacuum_freeze_max_age より古い未凍結 XID を含みうるテーブルに autovacuum を実行します。24.1.6節の表現では、relfrozenxid が autovacuum_freeze_max_age より古いテーブルは常に VACUUM されます。これは autovacuum を無効にしていても起きるとドキュメントは明記しています。

この autovacuum は通常の autovacuum と2点で違います。

  1. 通常の autovacuum は、SHARE UPDATE EXCLUSIVE と競合するロックをほかのプロセスが要求すると中断されます。周回を防ぐために動いている autovacuum は自動では中断されません。
  2. pg_stat_activity で、この autovacuum のクエリ名は (to prevent wraparound) で終わります。
SELECT pid, datname, query
FROM pg_stat_activity
WHERE query LIKE '%(to prevent wraparound)';

このクエリはドキュメントの説明をそのまま条件にしただけです。この表示が付いた処理は、更新量ではなくテーブルの年齢が理由で始まったものです。更新や削除がなく挿入だけを受けるテーブルのように、領域回収のための VACUUM が要らない静的なテーブルでも、おおよそ autovacuum_freeze_max_age から vacuum_freeze_min_age を引いたトランザクションごとに1回この autovacuum が動く、とドキュメントは説明しています。

ドキュメントには警告もあります。SHARE UPDATE EXCLUSIVE と競合するロックを取るコマンド(たとえば ANALYZE)を定期的に実行していると、autovacuum がいつまでも完了しないことがあります。

autovacuum_freeze_max_age を上げる代償

ドキュメントが挙げる唯一の欠点は記憶域です。autovacuum_freeze_max_age(とあわせて vacuum_freeze_table_age)を上げると、その範囲までのすべてのトランザクションのコミット状態を pg_xact に、track_commit_timestamp が有効ならタイムスタンプを pg_commit_ts に保持する必要があります。

autovacuum_freeze_max_age pg_xact pg_commit_ts
デフォルトの2億 約50MB 約2GB
許容最大値の20億 約0.5GB 約20GB

この大きさがデータベース全体に比べてわずかなら最大値を推奨し、そうでなければこの記憶域をどこまで許容するかで決めるよう、ドキュメントは書いています。逆に vacuum_freeze_min_age を下げすぎると、すぐにまた更新される行を凍結する無駄が生じます。どちらについても、すべての環境に合う数値はドキュメントにはありません。

警告、そして書き込みの拒否

autovacuum が何らかの理由で古い XID を片付けられないと、データベースの最も古い XID が周回点まで4千万トランザクションに達した時点から警告が出始めます。

WARNING:  database "mydb" must be vacuumed within 39985967 transactions
HINT:  To avoid XID assignment failures, execute a database-wide VACUUM in that database.

この段階なら、ヒントのとおり手動の VACUUM で解決できます。ただしその VACUUM はスーパーユーザーが実行する必要があるとドキュメントは書いています。そうでないとシステムカタログを処理できず、datfrozenxid を前進させられません。

警告を無視して、残りが3百万トランザクションを切ると、新しい XID の割り当てを拒否します。

ERROR:  database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database "mydb"
HINT:  Execute a database-wide VACUUM in that database.

このとき、すでに実行中のトランザクションは続けられますが、新しく開始できるのは読み取り専用トランザクションだけです。レコードを変更する操作やリレーションを TRUNCATE する操作は失敗します。VACUUM コマンドは通常どおり実行できます。

書き込み拒否の状態から復旧する手順

PostgreSQL 18 のドキュメントは、以前のリリースで勧められていたのとは違い、postmaster を止めたりシングルユーザーモードに入ったりする必要はなく、望ましくもないと書いています。代わりに次の手順をとります。

  1. 古いプリペアドトランザクションを解決します。pg_prepared_xacts で age(transactionid) が大きい行を探し、コミットかロールバックをします。
  2. 長時間開いたままのトランザクションを終わらせます。pg_stat_activity で age(backend_xid) か age(backend_xmin) が大きい行を探し、コミットかロールバックをするか、pg_terminate_backend でセッションを終了します。
  3. 古いレプリケーションスロットを削除します。pg_replication_slots で age(xmin) か age(catalog_xmin) が大きいスロットを探します。ドキュメントは、こうしたスロットはもう存在しないサーバーや長く停止しているサーバーのために作られたものが多いとしたうえで、まだ存在していてそのスロットに再接続しようとするかもしれないサーバーのスロットを削除すると、そのレプリカを再構築する必要が出ることがあると注意しています。
  4. 対象データベースでデータベース全体の VACUUM を実行します。VACUUM FULL は XID を必要とし、VACUUM FREEZE は通常運用の回復に必要な最小限より多くの作業をするので、どちらも使わないようにと書かれています。

通常の状態に戻ったら、対象データベースの autovacuum が適切に設定されているか確かめる、というのがドキュメントの最後の手順です。1〜3を VACUUM より先に行う理由を、ドキュメントは特に説明していません。ただしマルチトランザクションの節に「トランザクション ID の周回とは違い、レプリケーションスロットはマルチトランザクションの整理を直接引き止めない」とあることから、少なくともレプリケーションスロットは XID 側では整理を引き止める要因として扱われていると読めます。

ドキュメントによると、シングルユーザーモードはシステムを停止する必要があり、データ損失を防ぐための周回の安全装置を無効にするので、より危険です。この場面で使う理由があるとすれば、不要なテーブルを TRUNCATE か DROP して VACUUM の必要そのものをなくしたい場合だけで、3百万トランザクションの余裕は管理者がその作業をできるように残してあるものだとしています。

マルチトランザクション ID も同じ構造

複数のトランザクションが同時に1つの行をロックすると、その情報はマルチトランザクション ID(MXID)として符号化されます。MXID も32ビットのカウンターなので、同じ種類の管理が必要です。テーブルごとには pg_class.relminmxid が最も古い MXID を保持し、年齢は mxid_age() で見ます。警告は周回点まで4千万に達したとき、新しい MXID の生成の拒否は3百万を切ったとき、という基準も XID と同じです。

違いもあります。XID が枯渇するとすべての書き込みトランザクションが止まりますが、MXID が枯渇した場合に止まるのは、MXID を必要とする行ロックを伴う書き込みだけです。復旧手順もほぼ同じですが、いくつかが外れます。実行中のトランザクションとプリペアドトランザクションは、マルチトランザクションに現れる可能性がなければ無視してよく、レプリケーションスロットはマルチトランザクションの整理を直接引き止めないので、スロットの削除は通常は関係ありません。MXID の情報は pg_stat_activity のようなビューには直接見えませんが、古い XID を探すことが原因のトランザクションを見つけるよい方法だとドキュメントは書いています。

普段から見ておくもの

ドキュメントから言えることだけを集めると、次のくらいです。

  • 上の2つのクエリで、データベースごと・テーブルごとの age() を追います。判断の基準になる数値はドキュメントにあるもの、つまり autovacuum_freeze_max_age(強制 autovacuum の開始)、周回まで4千万(警告)、3百万(書き込み拒否)です。
  • pg_stat_activity で、(to prevent wraparound) の autovacuum がいつどのテーブルで動いているかを見ます。
  • VACUUM (VERBOSE) や、log_autovacuum_min_duration で出力される autovacuum のログには、relfrozenxid と relminmxid がどれだけ前進したか、新しく凍結されたページがいくつかが出ます。
  • 復旧手順で先に片付けるよう書かれている3つ、つまり pg_prepared_xacts、pg_stat_activity、pg_replication_slots の age() の値は、問題が起きる前から同じ方法で問い合わせられます。

autovacuum_freeze_max_age のデフォルトの2億は、ドキュメントが pg_xact の大きさを説明するときに挙げている値です。これを上げるかどうかは、記憶域と、周回防止の autovacuum がどれくらいの頻度で動いても構わないかを踏まえて、それぞれの環境で判断することになります。

参考にしたドキュメント

  • PostgreSQL 18 — 24.1 Routine Vacuuming:24.1.4 Updating the Visibility Map、24.1.5 Preventing Transaction ID Wraparound Failures、24.1.5.1 Multixacts and Wraparound、24.1.6 The Autovacuum Daemon

リンクと内容は2026年10月19日に確認しました。

ブログへ