この記事は NTTドコモソリューションズ Advent Calendar 2025 25日目の記事です。
こんにちは。NTTドコモソリューションズで主に PostgreSQL を中心に、社内プロジェクトへの技術支援や、技術調査をしている川本と申します。今年度はとあるプロジェクトに貼りついて開発支援をしています。
今年は、私がそのプロジェクトで体験したトランザクション ID 周回問題のバリエーションを紹介したいと思います。
PostgreSQL を利用するときに注意すべき事柄として取り上げられることの多いトランザクション ID 周回問題。自動 VACUUM に助けられて意識せずに回避してきましたが、今のプロジェクトでは何度もトランザクション ID 周回問題に直面することとなりました。しかもそれぞれ違う原因で。
これまで、調査のためにわざと発生させることはありましたが、実際に直面すると、ホントに起きるんだ…となったので、今回記事にまとめてみました。
PostgreSQL を運用したことがなく、トランザクション ID 周回問題を知らないという方は最後のおまけから読んでみてください。
前提
環境について
・インポートしたデータから、プロシージャによって形式の違う新しいデータを作成するバッチ処理を動作させる環境です。
・1インスタンス上に DB が 10 個以上あります。
・テーブルはインスタンス全体で 300 以上あります。
・レコード件数が数千万を超えるテーブルがゴロゴロしています(初期データもプロシージャで作るデータも)。
・どのレコードの変換でエラーが起きたか把握するため FETCH LOOP で1件変換/INSERTするたびに BEGIN EXCEPTION END を多用しています。
・初期データインポート時には VACUUM (FREEZE, ANALYZE) をしています。
・PostgreSQL のバージョンは 12.6 です。
以降ではトランザクション ID は XID と記述します。
体験した事例
1. autovacuum_freeze_max_age が大きすぎる
詳細
autovacuum_freeze_max_age を 20 億にしていたため、relfrozenxid の 年齢が 20 億にならないと XID 周回問題を防ぐための自動 VACUUM が動かず、動き始めても、ゴロゴロしている大きなテーブルに対する VACUUM よりプロシージャの XID 消費スピードが速く、21 億才になるテーブルが現れて新規の XID 発行が出来ずにエラー発生となりました。
ERROR: database is not accepting commands to avoid wraparound data loss in database "xxx_db"
HINT: Stop the postmaster and vacuum that database in single-user mode.
XID 周回問題対策の基本ですが、ここまで XID を高速に消費する環境に立ち会ったことがなく、消費量を考慮したチューニングをしていませんでした。これが初めて出会ったケースです。
対処
・autovacuum_freeze_max_age を半分の 10 億に。
・autovacuum_max_workers も 4 から倍の 8 に。
・プロシージャで億件越えのテーブルを生成したあとは、次のプロシージャを動かす前にそのテーブルを VACUUM (FREEZE, ANALYZE)
の3点で対応しました。
2. 孤立した一時テーブルの残留
詳細
孤立した一時テーブルとは、その一時テーブルを作成したセッションが切れているのに、何らかの理由でデータベース上に残ってしまっている一時テーブルのことです。英語では orphan temp table と呼ばれます。
(自動) VACUUM は一時テーブルを対象と取らないため、凍結されず、エラー発生となりました。
PostgreSQL は自動 VACUUM で孤立した一時テーブルを見つけると自動で削除をする機能があり、以下のようなログが出力されるようになっています。
LOG: autovacuum: dropping orphan temp table dbname.schemaname.tablename
しかし今回は、XID 周回の WARNING と ERROR でログファイルが膨大になっていてログの確認は出来ませんでした。
シングルユーザモードで VACUUM をかけても解消されず調査していたところ、PGDATA/base/pgsql_tmp のサイズがおかしくて気づけました。
対処
再起動して、通常の一時テーブルをなくした上で、
-
DB 特定
select datname, age(datfrozenxid) from pg_database order by age desc; -
オブジェクト特定
select relname, age(relfrozenxid), relkind, relpersistence from pg_class where age(relfrozenxid) != '2147483647' order by age desc limit 10; -
relkind = 'r'かつrelpersistence = 't'のとき3.1. スキーマ特定
select c.oid, c.relname, n.nspname from pg_class as c join pg_namespace as n on c.relnamespace = n.oid where c.relpersistence = 't';3.2 対象一時テーブルをスキーマごと削除
drop schema pg_temp_xx cascade;
3. インデックス破損
詳細
あるテーブルの INDEX が破損しており、そのテーブルに対する VACUUM が失敗し続けていました。
VACUUM を実行すると以下のようなログとなりました。
ERROR: failed to re-find parent key in index "idx_xxx" for deletion target page 3182
このときは、テーブルの特定はケース 2 で学んでいたので、シングルユーザモードで VACUUM をかけたら上記エラーとなってすぐわかりました。
対処
・当該テーブルに対して REINDEX
PostgreSQL のバージョンが 12.13 以降だったら防げていたんだと思います。
12.13 リリースノートより。
Fix VACUUM to press on if an attempted page deletion in a btree index fails to find the page's parent downlink
おわりに
私が今年の4月から経験した 3 パターンの XID 周回問題との遭遇についてまとめてみました。
自動 VACUUM が動作さえしていれば起きない問題ですが、ロングトランザクション以外にも色々と VACUUM 出来なくなる原因があるもんだと、改めて勉強になりました。
おまけ GPT-5 に聞くトランザクションID周回問題
Q.
あなたの言う、最新系はバージョンいくつですか?
A.
私の知識範囲(2024-10時点)での「最新系」は PostgreSQL 17 です。
というわけで、そんな GPT-5 くんに説明をしてもらいました。
間違ってそうなところはコメント追記してみます。
Q.
あなたはPostgreSQLの専門家です。PostgreSQLにおけるトランザクションID周回問題について、VACUUMの必要性とともに、初学者にもわかりやすく教えてください。
A.
ポイントだけ押さえれば、トランザクションID周回(ラップアラウンド)問題は難しくありません。まず「なぜ起きるのか」「VACUUMが何をしているのか」「どう防ぐのか」を順番に説明します。
-
何が問題なのか(MVCCと32bitのトランザクションID)
-
PostgreSQLはMVCCという仕組みで「行が見えるかどうか」を管理します。各行には「その行を作ったトランザクションID(xmin)」と「その行を消したトランザクションID(xmax)」が記録されています。
-
トランザクションID(XID)は32bitのカウンタで、増え続けると約40億(2^32)で一周して0に戻ります。これは車の走行距離計が0に戻るイメージです。
-
もし何も対策をしないと、「すごく昔のxminを持つ行」が、周回後には「未来のIDっぽく」見えてしまい、正しく可視性判定ができず、最悪「本来見えるはずの行が見えなくなる」という致命的な事態になります。
PostgreSQLでは上限の 2^32 = 約42億 の半分ずつで、過去と未来を分けています。自分(今のトランザクション)より約 21 億古いものは過去のもの、自分より約 21 億新しいものは未来のものとします。もし自身の XID カウンタが 10 億だったとき、過去のものは、0 から 9億999... と 30億999... から 42 億までとなり、未来のものは 10 億 1 から 31 億までとなります。XID は周回するので、0 以上昔をみるときは逆に42億からさかのぼって、今の XID カウンタから 21 億を計算します。
-
-
VACUUMは何をしているのか(2つの大仕事)
-
スペース回収:削除や更新で不要になった古い行(ゴミ)を片付け、テーブル膨張(バージョンでは「バロット」や「ブロート」)を抑えます。
-
凍結(FREEZE):十分に古く、もう過去にコミット済みと断言できる行のxminを「凍結済みの特別なID」に置き換えます。こうすると、XIDが周回してもその行は「永遠に過去の行」として安全に扱われ、見えなくなる事故を防げます。
-
-
自動VACUUM(autovacuum)は必須
-
autovacuumはデフォルトで有効になっていて、通常はこれが「ゴミ掃除」と「凍結」を勝手に進めます。これを止めると周回問題に直結するので、基本的に止めないでください。
-
autovacuumには「ゴミが増えたら走る」トリガーと、「凍結目的で走る」トリガーの2系統があります。後者は、行が古くなってきたとき(行やテーブルが持つ最古のXIDの年齢がしきい値を超えるとき)に、凍結のために全ページをスキャンします。
「凍結目的で走る」トリガーで実行されるVACUUMをaggressive VACUUM(積極的VACUUM)と呼んだりします。
-
代表的な設定(デフォルト値の目安)
- vacuum_freeze_min_age ≈ 50,000,000(この年齢を超えた行を凍結候補にする)
凍結候補という言葉が謎だったので補足です。これはVACUUMコマンドでも自動VACUUMでも同様に効果します。(自動)VACUUMの実行時、不要領域の回収のついでに、同一ページ内にあるレコードを凍結するかどうかの閾値です。(自動)VACUUM実行時に設定値以上の年齢のレコードを見つけたらそのレコードを凍結します。ただし、PostgreSQL は(自動)VACUUMの効率化のため、各ページが不要領域をもっているかどうかを別ファイルで管理していて、(自動)VACUUM は不要領域のないページ読み飛ばす(実行対象としない)ため、あくまで(自動)VACUUM対象の削除済レコードと同じページ内にあるレコードに絞られます。
- vacuum_freeze_table_age ≈ 150,000,000(テーブル全体として古くなったら凍結目的VACUUMを実施)
これも同じくVACUUMコマンドと自動VACUUMどちらにも効果があり、実行時にテーブル内の一番古いレコードの年齢が設定値以上だと、「ゴミ掃除」だけでなく「凍結」も行います。「ゴミ掃除」のVACUUMは、上で書いたように不要領域のないページはスキップしますが、この条件に当てはまると(自動)VACUUM実行対象がすべてのページになります。ただし、すべてのレコードを凍結する VACUUM FREEZE とは違い、凍結対象は vacuum_freeze_min_age の対象となるレコードだけです。
- autovacuum_freeze_max_age ≈ 200,000,000(データベース全体でこの限界に近づくと、周回防止のために優先度高くVACUUMが走る)
データベース全体でこの限界に近づくというのは、age(datfrozenxid) が設定値を超えるとそのデータベースに対して凍結目的の自動 VACUUM を試みるということです。datfrozenxid は、システムカタログ pg_database にあるカラムで、そのデータベース内の一番古い relfrozenxid を示します。relfrozenxid はシステムカタログ pg_class にあるカラムで、そのテーブル内の凍結されていない一番古いレコードの XID を示します。何が言いたかったかというと、データベースに XID が記録されてるわけではなく、あくまでレコード単位で記録されています。
- ポイント:INSERTだけしていて「削除してないからVACUUMは要らない」は誤解。削除しなくても「凍結」は必要です。
-
-
何が起きると危ないのか(警告と強制措置)
-
サーバログに「WARNING: database 'XXX' must be vacuumed within N transactions」といった警告が出たら、周回まで残りが減っている合図です。すぐにVACUUMを進めてください。
-
限界を超えそうになると、そのデータベースへの通常接続を拒否する強制措置が入ります(周回事故防止のため)。復旧にはスーパーユーザで接続してVACUUMを走らせるしかありません。
-
-
日常の監視と手当(初心者向けチェックリスト)
- どれくらい古いかを簡単に見る:
-
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;
-
age(...)の数字が大きいほど古い(危険に近い)です。
-
age が大きいほど危険というか、autovacuum_freeze_max_age を超えていたとき、自動VACUUMに何らかの問題があると判断して調査が必要になるということです。
-
テーブルごとの古さ:
- SELECT relname, age(relfrozenxid) FROM pg_class JOIN pg_namespace ON (...) WHERE relkind IN ('r','m'); などで確認可能。
-
自動VACUUMが動いているか:
- pg_stat_user_tables(last_autovacuum, n_dead_tup)や、pg_stat_progress_vacuumで進捗を確認。
-
ロングトランザクションに注意:
- 長時間「BEGINしたまま」の接続は、最古のXID(oldestXmin)を引き上げてVACUUMの片付けや凍結を妨げます。アプリの接続管理とトランザクション境界を見直しましょう。
- どれくらい古いかを簡単に見る:
-
もし警告が出たら(緊急手順)
-
できるだけ早くクラスタ全体を凍結VACUUM:
-
vacuumdb --all --freeze --jobs=4 --verbose
-
極端に大きいテーブルは時間がかかることがあります。ピーク外の時間帯に計画的に実行するのが安全。
-
-
VACUUM FULLは周回対策には不要です(テーブルをロックして再構成するため重く、目的が違う)。通常のVACUUMとFREEZEで十分です。
一応補足しておくと、VACUUM FULL を実行した後は、ファイルサイズが切り詰めされているだけでなく、すべてのレコードを凍結した状態にもなっています。
-
-
チューニングのヒント(まずは無理のない範囲で)
-
autovacuumを止めない。ワーカー数(autovacuum_max_workers)を適切に増やす、naptime(autovacuum_naptime)を短めにするなど、負荷と相談。
-
しきい値(autovacuum_vacuum_threshold / autovacuum_vacuum_scale_factor)を少し厳しめにして、こまめに掃除するほうが大膨張や凍結の大仕事を避けやすい。
-
大規模な一括ロード直後は VACUUM (FREEZE, ANALYZE) を実施して、凍結と統計更新を早めに済ませる。
-
レプリカ環境では、VACUUMはプライマリで行われます。スタンバイ側の長時間クエリやhot_standby_feedbackの設定が、プライマリのVACUUMに影響することを理解しておくと安全。
-
まとめ
-
トランザクションIDは約40億で周回するので、周回前に「古い行を凍結」しておく必要があります。
-
その役割を担うのがVACUUM(特にautovacuum)。掃除と凍結の2本柱です。
-
自動VACUUMを止めない、ロングトランザクションを避ける、age(...)で古さを監視する、警告が出たら速やかにVACUUMする——これだけ守れば、周回問題は怖くありません。
ということでした。2行しか質問してないのにすごいボリュームが返ってきてすごいなと、しかも割と情報が正確。
※ 記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。