この記事のポイント
VACUUM コマンドや 自動VACUUM は成功するのだけれども(コマンド応答にもログにも ERROR などは出ないけれども)、実際にはデッドタプルが回収されない状況が発生することがあります。
VACUUMが期待通りに動作しない場合、以下を確認することで原因特定が可能です。
- ログに「are dead but not yet removable」が出ているか(*)
- ログの「removable cutoff / oldest xmin」の数字が進んでいないか(*)
- 以下のビューを確認する(ストリーミングレプリケーション環境の場合はスタンバイ側も)
- pg_stat_activity
- pg_replication_slots
- pg_prepared_xacts
主な原因は以下の3つです。
- ロングトランザクション
- レプリケーションスロット
- プリペアドトランザクション
対策の本質は 「oldest xminを固定している要因を解消すること」 です。
(*) log_autovacuum_min_duration パラメータを0や正の数で設定して自動VACUUM実行結果をログに出力する必要があります。
はじめに
VACUUMが実行する二つの主要な処理として、削除された行(dead tuples)の領域回収と、トランザクションID(XIDと表記される)周回防止のための凍結があります。
一つ目は、UPDATEやDELETEなどで削除された行(dead tuples)を回収して再利用可能な領域にする処理で、この機能が正常に働くことにより追記型のPostgreSQLで必要以上にデータベースサイズが大きくなることが防止されます。
二つ目は、32bitの非負数の整数(約42億)のトランザクションID(XID)では現在のXIDから前半分が過去、後ろ半分が未来というルールで管理されています。過去の行が現在のXIDより約21億前の半分を過ぎると突然未来になって見えなくなる状況を防ぐために必要なのがトランザクションIDの凍結(FREEZE)で、凍結処理が行われた行はどのトランザクションからも見えるようになります。
本記事では、VACUUMによるこの二つの働きを阻害する原因、検知と対応について説明します。
VACUUMによる行回収処理
PostgreSQLは「追記型」アーキテクチャ
PostgreSQLデータベースの特徴として「追記型」アーキテクチャであることが挙げられます。他のデータベースではUPDATEによる行の更新方法が更新前の行を更新後の値で書き換える「書き換え型」が多く採用されていますが、PostgreSQLではUPDATE時に更新前の行のxmaxにトランザクションIDを記録して「削除された行」という印をつけておき、更新後の行を別の場所に挿入する「追記型」を採用しています。またDELETEも実行時には該当行のxmaxにトランザクションIDを記録して「削除された行」という印をつけるのみです。この方法により、削除される前から「削除された行」を参照しているトランザクションがUPDATEと並行して実行を継続することができます。
VACUUMの行回収
この「追記型」ゆえに発生した「削除された行(dead tuples)」の領域を回収して再利用可能な領域とするために、PostgreSQLには「VACUUM」という機能があり、あらかじめ決められた一定の条件を満たすとテーブルに対して自動的にVACUUMを実行する「自動VACUUM」がデフォルトで有効になっています。
「自動VACUUM」が実行される条件が成立すると、自動VACUUMが実行されて「削除された行」が回収されて再利用可能な領域になり、次のUPDATEやINSERTなどで利用できます。この機能が有効に働くことで、PostgreSQLのファイルが無駄に肥大化し続ける状況が防げます。
ロングトランザクションによる行回収処理阻害
「自動VACUUM」が実行される条件が成立すると、自動VACUUMが実行されて阻害要因が無いと「削除された行」が回収されますが、「削除された行」が回収できない阻害要因が存在すると回収できない状況となります。その要因となる一番の原因が「ロングトランザクション」の存在です。「削除された行」を参照しているトランザクションが長時間実行され続ける「ロングトランザクション」が存在して回収を阻害している状況では、自動VACUUMを実行しても「削除された行」が回収できません。その状況では、自動VACUUMの実行が完了しても「削除された行」が回収できないためまた自動VACUUMが実行され、回収できない阻害要因を排除するまで無駄な自動VACUUMの実行が繰り返し行われてしまいます。この状況がずっと続くと、「トランザクションIDの周回問題」が発生してしまう深刻な状況に陥ります。
トランザクションID(XID)の周回問題
トランザクションIDの凍結処理と阻害された時の影響
PostgreSQLのトランザクションID(XID)は32bitの非負数で約42億と循環する値で管理されており、基準となるXIDより古い値の21億が過去、基準点となるXIDより新しい値の21億が未来となっています。基準となるXIDより古い値の21億よりも値が古くなると(XIDが進むと)、突然未来となってしまい基準となるXIDから見ると未来の範囲に入るため、該当行が見えなくなってしまいます。そのため、XIDが21億進んでしまうまでの間に、VACUUMによる凍結(FREEZE)処理が必要となります。具体的は、行が作成された時のXIDが記録されるxminに凍結した印をつける処理をします。この処理が進まない場合、データベース内で凍結されていないXIDの下限値であるdatfrozenxidから現在のトランザクションIDまでのトランザクション数を計算するage(datfrozenxid)が増加し続けて、最悪の場合データベースが参照のみとなり更新不可の状態となります。
この問題の詳細な仕組みについては、弊社の下記のBlog記事が非常に分かりやすく整理されています。
(参考)トランザクションID周回問題について
なお、トランザクションIDの凍結(FREEZE)処理も、削除された行(dead tuples)の回収と同様に、「古いxminが残り続けること」によって進まなくなります。そのため、ロングトランザクションなどの存在は、領域回収の阻害だけでなく、トランザクションIDの周回防止処理の遅延の原因となります。
トランザクションIDの周回防止処理の阻害要因は、削除された行(dead tuples)の回収を阻害する要因と同じですので、以下では行回収阻害について説明します。
行回収阻害発生時に出るログ
実際にロングトランザクションが存在してVACUUMによる「削除された行(dead tuples)」の回収が阻害された時に出るログの該当箇所を下記に示します。
最も着目すべきログの箇所
PostgreSQL 15までとPostgreSQL 16からログ表記が変わってますので、下記では現時点でもっとも古いサポート対象メジャーバージョンのPostgreSQL 14.22でのログと、現時点でもっとも新しいサポート対象メジャーバージョンのPostgreSQL 18.3でのログを使って説明します。
行回収が阻害されている場合、VACUUMのログには以下の特徴的なメッセージが出力されます。特に「are dead but not yet removable」は、行回収阻害を判断する最も重要な指標です。
(PostgreSQL 14.22) tuples: 0 removed, 1499989 remain, 500000 are dead but not yet removable, oldest xmin: 737 (PostgreSQL 18.3) tuples: 0 removed, 1499989 remain, 500000 are dead but not yet removable removable cutoff: 1124, which was 1 XIDs old when operation ended
回収が阻害された行数は「are dead but not yet removable」の前の数字がその行数である
行回収阻害を表すのは「are dead but not yet removable」の文字列、意味は「~は削除されているが回収(消去/再利用可能に)できない」で行回収阻害そのものを説明しています。この文字列の前にある数字(14.22も18.3も500000)が、削除されたが回収できなかった行数で、その原因はこれらの行をまだ参照する可能性がある古いトランザクション(ロングトランザクション)が残っているためです。
古いトランザクションIDの表記: PostgreSQL 15までは「oldest xmin」PostgreSQL 16からは「removable cutoff」
その古いトランザクションのトランザクションIDの表記は、PostgreSQL 15までは「are dead but not yet removable」と同じ行でこの後に「oldest xmin」が配置されていて、PostgreSQL 16からは「are dead but not yet removable」の次の行に「removable cutoff」が配置されています。
PostgreSQL 16からは、その後に「which was NNN XIDs old when operation ended」と「removable cutoff」のXIDが、操作が終わってから現在のXIDとの差異のXIDとともに配置されています。
PostgreSQLのソースコードでは、xmin horizonという単語も使われています。
(参考) 実行ログ全体
上記のメッセージを含むVACUUM実行全体のログは下記となります。PostgreSQL 18ではPostgreSQL 14に比べて出力される内容が増えていて、removable cutoff、frozen、visibility mapの行が追加されていることが分かります。
PostgreSQL 14.22 でのメッセージ
2026-03-26 17:05:36.524 JST [14062] LOG: automatic vacuum of table "db1.public.vacuum_test": index scans: 0 pages: 0 removed, 14019 remain, 0 skipped due to pins, 0 skipped frozen tuples: 0 removed, 1499989 remain, 500000 are dead but not yet removable, oldest xmin: 737 index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 5.784 MB/s, avg write rate: 12.032 MB/s buffer usage: 17334 hits, 1448 misses, 3012 dirtied WAL usage: 1 records, 0 full page images, 188 bytes system usage: CPU: user: 0.13 s, system: 0.02 s, elapsed: 1.95 s
PostgreSQL 18.3 でのメッセージ
2026-03-26 17:09:16.385 JST [14250] LOG: automatic vacuum of table "db1.public.vacuum_test": index scans: 0 pages: 0 removed, 14019 remain, 9347 scanned (66.67% of total), 0 eagerly scanned tuples: 0 removed, 1499989 remain, 500000 are dead but not yet removable removable cutoff: 1124, which was 1 XIDs old when operation ended frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 14.030 MB/s, avg write rate: 15.176 MB/s buffer usage: 17137 hits, 1615 reads, 1747 dirtied WAL usage: 1 records, 0 full page images, 258 bytes, 0 buffers full system usage: CPU: user: 0.07 s, system: 0.03 s, elapsed: 0.89 s
自動VACUUM実行ログ出力設定
上記の自動VACUUMの実行状況をPostgreSQLログに出力するには、postgresql.conf で設定した値以上の時間がかかった自動VACUUM実行をログ出力するlog_autovacuum_min_durationパラメータを設定します。PostgreSQL 14までは-1(無効、まったく出さない)、PostgreSQL 15からは10min(10分)ですが、この値を0(数字のゼロ)に設定することで、すべての自動VACUUMと自動ANALYZEの実行がPostgreSQLログに記録されますので、自動VACUUMの実行状況が把握でき、削除された行回収が阻害されている時も検知が可能となります。
log_autovacuum_min_duration = 0
本記事では、上記の設定値ですべての自動VACUUM/自動ANALYZEがPostgreSQLログに出力される前提としています。
ちなみに、VACUUMコマンドを実行する時もVACUUM (VERBOSE)と詳細ログを出力するようにしてください。
また余談となりますが、上記の設定と下記のチェックポイント実行状況をログに出力する
log_checkpoints = on
の設定(PostgreSQL 15からデフォルトon)で、すべての自動VACUUM/自動ANALYZEの実行状況と、すべてのチェックポイントの実行状況を把握できますので、これらの設定値にすることをお勧めします。
行回収を阻害する「oldest xmin/removable cutoff」の見つけ方とその原因
VACUUMによる行回収を阻害する「oldest xmin」や「removable cutoff」を、どのように見つけるのか、また何が原因なのかについて説明します。
原因としては、大きく三つあります。これらの原因はいずれも「xminを古い位置に固定してしまう」という共通点があります。
1. ロングトランザクション(同一サーバ上/hot_standby_feedback = on のスタンバイ)(原因1)
2. 未使用のレプリケーションスロット(原因2)
3. 放置されたプリペアドトランザクション(PREPARED TRANSACTIONにより作成)(原因3)
ロングトランザクション(原因その1)
ロングトランザクションとは「長い」トランザクションのことですが、まずはトランザクションについて説明します。
トランザクション
データベースにおけるトランザクションとは、複数の処理を「不可分なひとつのまとまり」として扱う単位で、複数のすべての処理をおこなうか、処理をまったく行わないかのどちらかを保証します。一部の処理は実行され残りの処理は実行されないという状況は決して起こりません。PostgreSQLでのトランザクションの実行方法は、トランザクションを構成するSQL文をBEGINとCOMMITで囲って明示的にトランザクションを実行する方法と、自動COMMIT機能を備えたpsqlなどのツールを使って単一のSQL文を暗黙的にトランザクションとして実行する方法の二通りがあります。
ロングトランザクション
ロングトランザクションとは、明示的にトランザクションを実行する方法で、BEGINでトランザクションを開始してから長い時間(明示的な定義は無いですが、典型的には数時間以上もしくは1日(数日)以上)トランザクションをCOMMITやROLLBACKでコミットやロールバックせずに継続した状態が長く続いているトランザクションのことを指します。
ロングトランザクションの存在による弊害
ロングトランザクションなどのVACUUMを阻害する原因が存在すると、VACUUMを実行しても削除されたタプル領域の回収が出来ません。
理由は、ロングトランザクションが保持する最も古いトランザクションID(oldest xmin)以降に削除されたタプル領域は、ロングトランザクションが参照している可能性があるためです。
そのため、削除されたタプル領域が回収されて再び利用可能となった領域が全く無いため、そのテーブルへのINSERTやUPDATEでタプルを配置するにはテーブルが使用するファイル末尾の新規の領域を使用する必要があります。
そのため、テーブルが使用するファイルサイズが肥大し続けてしまい、結果的にPostgreSQLが使用するディスク使用率が増え続けます。
また、VACUUMを実行しても削除されたタプル領域の回収が出ないため、VACUUMが実行され続けてしまいますので、CPU使用率やDisk I/Oがずっと高い状況となってしまい、性能にも影響します。
ロングトランザクションの検知と対策
ロングトランザクションを検知する方法は、pg_stat_activityのstate列がトランザクションを開始したけれども何も処理をしていない「idle in transaction」状態が長く続く接続を見つけることです。
対応は、ロングトランザクション状態のpidを見つけ出し、その接続を SELECT pg_terminate_backend(<接続のpid>); で強制終了することです。例えば1日以上経過した idle in transaction 状態のロングトランザクションがあれば、そのpidを引数にした SELECT pg_terminate_backend(<接続のpid>); で強制終了します。
ロングトランザクションによるVACUUMの阻害はVACUUMが実行されるサーバ上のロングトランザクションだけでなく、ストリーミングレプリケーションのスタンバイ側でhot_standby_feedback = on と設定した場合のスタンバイ側でのロングトランザクションが影響します。まずは、レプリケーションスロットを使わない場合について説明します。
2026-03-26 21:19:40.572 JST [23346] LOG: automatic vacuum of table "db1.public.vacuum_test": index scans: 0 pages: 0 removed, 14019 remain, 9347 scanned (66.67% of total), 0 eagerly scanned tuples: 0 removed, 1499989 remain, 500000 are dead but not yet removable removable cutoff: 1131, which was 1 XIDs old when operation ended frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 4.694 MB/s, avg write rate: 17.083 MB/s buffer usage: 17259 hits, 1493 reads, 5434 dirtied WAL usage: 2 records, 2 full page images, 10076 bytes, 0 buffers full system usage: CPU: user: 0.14 s, system: 0.02 s, elapsed: 2.48 s
→ tuples: に 500000 行が削除されているが整理できない状態(500000 are dead but not yet removable)と出ています。
removable cutoff: 1131 に出ている 1131 が阻害している oldest xmin になります。
db1=# SELECT * FROM pg_stat_activity;
datid | datname | pid | leader_pid | usesysid | usename | application_name | client_addr | client_hostname | client_port | backend_start | xact_start | query_start | state_change | wait_event_type | wait_event | state | backend_xid | backend_xmin | query_id | query | backend_type
-------+---------+-------+------------+----------+----------+------------------+-------------+-----------------+-------------+-------------------------------+------------------------------+-------------------------------+-------------------------------+-----------------+-------------------+--------+-------------+--------------+----------+-----------------------------------------+--------------
| | 22943 | | 10 | postgres | walreceiver | | | -1 | 2026-03-26 21:14:46.048588+09 | | 2026-03-26 21:14:46.050372+09 | 2026-03-26 21:14:46.050385+09 | Activity | WalSenderMain | active | | 1131 | | START_REPLICATION 0/60000000 TIMELINE 1 | walsender
→ レプリケーションスロットを使用しない場合、プライマリ側のpg_stat_activityでは、walsenderプロセスのbackend_xminが1131になっていることがわかります。この場合、walsenderプロセスを終了しても問題の解決とはならず、スタンバイ側から新規にストリーミングレプリケーション接続が来てbackend_xminが1131の新たなwalsenderプロセスが起動するだけです。
解決するには、スタンバイ側のpg_stat_activityで、backend_xminが1131のidle in transaction状態の接続を見つけ出し、そのpidを引数にpg_terminate_bakendを実行する必要があります。
(スタンバイ側) db1=# SELECT * FROM pg_stat_activity; datid | datname | pid | leader_pid | usesysid | usename | application_name | client_addr | client_hostname | client_port | backend_start | xact_start | query_start | state_change | wait_event_type | wait_event | state | backend_xid | backend_xmin | query_id | query | backend_type -------+---------+-------+------------+----------+----------+------------------+-------------+-----------------+-------------+-------------------------------+-----------------------------+-------------------------------+------------------------------+-----------------+-------------------+---------------------+-------------+--------------+----------+-----------------------------------+---------------- 17009 | db1 | 23240 | | 10 | postgres | psql | | | -1 | 2026-03-26 21:18:55.374729+09 | 2026-03-26 21:18:58.5415+09 | 2026-03-26 21:19:02.709309+09 | 2026-03-26 21:19:02.776459+09| Client | ClientRead | idle in transaction | | 1131 | | SELECT COUNT(*) FROM vacuum_test; | client backend :(略) db1=# SELECT pg_terminate_backend(23240); pg_terminate_backend ---------------------- t (1 row) db1=# SELECT * FROM pg_stat_activity WHERE pid = 23240; datid | datname | pid | leader_pid | usesysid | usename | application_name | client_addr | client_hostname | client_port | backend_start | xact_start | query_start | state_change | wait_event_type | wait_event | state | backend_xid | backend_xmin | query_id | query | backend_type -------+---------+-------+------------+----------+----------+------------------+-------------+-----------------+-------------+-------------------------------+-----------------------------+-------------------------------+------------------------------+-----------------+-------------------+---------------------+-------------+--------------+----------+-----------------------------------+---------------- (0 rows) db1=#
→すると、VACUUMを阻害していた要因であるスタンバイ側のロングトランザクションが終了しましたので、再度VACUUMが実行された時には500000行の「削除された行(dead tuples)」の領域が無事回収されました。
2026-03-26 21:34:44.148 JST [24529] LOG: automatic vacuum of table "db1.public.vacuum_test": index scans: 1 pages: 0 removed, 14019 remain, 9347 scanned (66.67% of total), 0 eagerly scanned tuples: 500000 removed, 750064 remain, 0 are dead but not yet removable removable cutoff: 1133, which was 0 XIDs old when operation ended frozen: 1 pages from table (0.01% of total) had 11 tuples frozen visibility map: 9347 pages set all-visible, 4673 pages set all-frozen (0 were all-visible) index scan needed: 4673 pages from table (33.33% of total) had 500000 dead item identifiers removed index "vacuum_test_pkey": pages: 4116 in total, 0 newly deleted, 0 currently deleted, 0 reusable avg read rate: 2.484 MB/s, avg write rate: 16.056 MB/s buffer usage: 25674 hits, 1870 reads, 12085 dirtied WAL usage: 21431 records, 12085 full page images, 52668632 bytes, 1633 buffers full system usage: CPU: user: 0.19 s, system: 0.17 s, elapsed: 5.88 s
次に、レプリケーションスロットを使った場合に、スタンバイ側のロングトランザクションがどのように影響するかについて説明します。
2026-03-26 21:58:39.091 JST [26089] LOG: automatic vacuum of table "db1.public.vacuum_test": index scans: 0 pages: 0 removed, 14019 remain, 9347 scanned (66.67% of total), 0 eagerly scanned tuples: 0 removed, 1253147 remain, 500000 are dead but not yet removable removable cutoff: 1137, which was 2 XIDs old when operation ended frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s buffer usage: 18751 hits, 0 reads, 0 dirtied WAL usage: 1 records, 0 full page images, 258 bytes, 0 buffers full system usage: CPU: user: 0.00 s, system: 0.07 s, elapsed: 0.34 s
→ tuples に 500000 行が削除されているが整理できない状態(500000 are dead but not yet removable)と出ています。
removable cutoff: 1137 に出ている 1137 が阻害している oldest xmin になります。
今回は、pg_stat_activityを検索しても、1137は出てきません。
datid | datname | pid | leader_pid | usesysid | usename | application_name | client_addr | client_hostname | client_port | backend_start | xact_start | query_start | state_change | wait_event_type | wait_event | state | backend_xid | backend_xmin | query_id | query | backend_type -------+---------+-------+------------+----------+----------+------------------+-------------+-----------------+-------------+-------------------------------+------------------------------+-------------------------------+-------------------------------+-----------------+-------------------+---------------------+-------------+--------------+----------+-----------------------------------------------------------------------------+---------------- 17009 | db1 | 26121 | | 10 | postgres | psql | | | -1 | 2026-03-26 22:00:12.794388+09 | 2026-03-26 22:01:26.60796+09 | 2026-03-26 22:01:26.60796+09 | 2026-03-26 22:01:26.607966+09 | | | active | | 1139 | | SELECT * FROM pg_stat_activity WHERE backend_xmin != 0 or backend_xid != 0; | client backend (1 row)
レプリケーションスロットを使用している場合は、pg_replication_slotsを検索する必要があります。
db1=# SELECT * FROM pg_replication_slots; slot_name | plugin | slot_type | datoid | database | temporary | active | active_pid | xmin | catalog_xmin | restart_lsn | confirmed_flush_lsn | wal_status | safe_wal_size | two_phase | two_phase_at | inactive_since | conflicting | invalidation_reason | failover | synced -----------+--------+-----------+--------+----------+-----------+--------+------------+------+--------------+-------------+---------------------+------------+---------------+-----------+--------------+-------------------------------+-------------+---------------------+----------+-------- slot1 | | physical | | | f | t | 24976 | 1137 | | 0/8E267DC0 | | reserved | | f | | | | | f | f (1 row) db1=#
→ pg_replication_slotsのxmin列が1137となっているのが見つかりました。スタンバイ側のロングトランザクションが参照しているbackend_xminがこのxminに反映されていますので、こちらも対応は先ほどと同様に、スタンバイ側のロングトランザクションを終了させることになります。
db1=# SELECT * FROM pg_stat_activity WHERE (backend_xmin != 0 or backend_xid != 0) AND pid <> pg_backend_pid(); datid | datname | pid | leader_pid | usesysid | usename | application_name | client_addr | client_hostname | client_port | backend_start | xact_start | query_start | state_change | wait_event_type | wait_event | state | backend_xid | backend_xmin | query_id | query | backend_type -------+---------+-------+------------+----------+----------+------------------+-------------+-----------------+-------------+-------------------------------+-------------------------------+-------------------------------+-------------------------------+-----------------+-------------------+---------------------+-------------+--------------+----------+-----------------------------------+---------------- 17009 | db1 | 25106 | | 10 | postgres | psql | | | -1 | 2026-03-26 21:51:45.786398+09 | 2026-03-26 21:51:52.469941+09 | 2026-03-26 21:51:58.301049+09 | 2026-03-26 21:51:58.357012+09 | Client | ClientRead | idle in transaction | | 1137 | | SELECT COUNT(*) FROM vacuum_test; | client backend (1 row) db1=# SELECT pg_terminate_backend(25106); pg_terminate_backend ---------------------- t (1 row) db1=#
未使用のレプリケーションスロット(原因その2)
レプリケーションスロットはストリーミングレプリケーションでスタンバイが使用するために必要なWALファイルが削除されないようキープしておく機能があります。
レプリケーションスロットを使用するスタンバイがアクティブな場合は、スタンバイが必要なWALファイルはスタンバイに転送され続けますので、プライマリ側にWALファイルが溜まることはありませんが、
先ほどのhot_standby_feedback=onの設定のスタンバイ上でロングトランザクションが存在する状態からスタンバイを停止すると、スタンバイからの接続が無いのに、xminはロングトランザクションが参照していた値(下記の例では1139)が残ってしまいます。activeがfとなっていますので使用されていない状況です。
スタンバイを停止した状況が続いたり、スタンバイを使用しなくなった際にレプリケーションスロットの削除を忘れてしまうと、スタンバイが保持するxminによってVACUUMによる「削除された行(dead tuples)」の回収が出来なくなり、かつトランザクションID(XID)の凍結も出来ない状態が続きます。さらに悪いことに、レプリケーションスロットはスタンバイが使用するためのWALを保持し続けますので、スタンバイが使用しなくなったタイミング以降、pg_walディレクトリの下にWALファイルがどんどんたまり続けて最終的にはストレージの空き容量が無くなる弊害もあります。
db1=# SELECT * FROM pg_replication_slots; slot_name | plugin | slot_type | datoid | database | temporary | active | active_pid | xmin | catalog_xmin | restart_lsn | confirmed_flush_lsn | wal_status | safe_wal_size | two_phase | two_phase_at | inactive_since | conflicting | invalidation_reason | failover | synced -----------+--------+-----------+--------+----------+-----------+--------+------------+------+--------------+-------------+---------------------+------------+---------------+-----------+--------------+-------------------------------+-------------+---------------------+----------+-------- slot1 | | physical | | | f | f | | 1139 | | 0/914DA680 | | reserved | | f | | 2026-03-26 22:08:48.294834+09 | | | f | f (1 row)
上記の状況(レプリケーションスロットにxmin 1139が存在する状態)で、xid_wraparoundを使ってxidを1000000000進めたところ、先ほどのvacuum_testテーブルのVACUUMログは積極的なVACUUMを示す「aggressive」が付いたメッセージに変わりました。
2026-03-26 22:21:04.193 JST [26659] LOG: automatic aggressive vacuum to prevent wraparound of table "db1.public.vacuum_test": index scans: 0 pages: 0 removed, 14019 remain, 14019 scanned (100.00% of total), 0 eagerly scanned tuples: 0 removed, 1500000 remain, 500000 are dead but not yet removable removable cutoff: 1139, which was 1000000004 XIDs old when operation ended new relfrozenxid: 1139, which is 4 XIDs ahead of previous value frozen: 9346 pages from table (66.67% of total) had 999989 tuples frozen visibility map: 0 pages set all-visible, 4672 pages set all-frozen (4672 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 3.869 MB/s, avg write rate: 14.969 MB/s buffer usage: 25679 hits, 2416 reads, 9347 dirtied WAL usage: 14019 records, 9347 full page images, 77042801 bytes, 1694 buffers full system usage: CPU: user: 0.16 s, system: 0.06 s, elapsed: 4.87 s 2026-03-26 22:21:04.193 JST [26659] WARNING: cutoff for removing and freezing tuples is far in the past 2026-03-26 22:21:04.193 JST [26659] HINT: Close open transactions soon to avoid wraparound problems. You might also need to commit or roll back old prepared transactions, or drop stale replication slots.
[対策]
未使用のレプリケーションスロットの削除
上記の未使用のレプリケーションスロットであるslot1を削除します。
db1=# SELECT * FROM pg_drop_replication_slot('slot1');
pg_drop_replication_slot
--------------------------
(1 row)
db1=# SELECT * FROM pg_replication_slots;
slot_name | plugin | slot_type | datoid | database | temporary | active | active_pid | xmin | catalog_xmin | restart_lsn | confirmed_flush_lsn | wal_status | safe_wal_size | two_phase | two_phase_at | inactive_since | conflicting | invalidation_reason | failover | synced
-----------+--------+-----------+--------+----------+-----------+--------+------------+------+--------------+-------------+---------------------+------------+---------------+-----------+--------------+-------------------------------+-------------+---------------------+----------+--------
(0 rows)
db1=#
すると、削除できなかった500000行が削除されて(tuples: 500000 removed)、削除できなかった行は0になりました(0 are dead but not yet removable)
2026-03-26 22:25:33.378 JST [27413] LOG: automatic aggressive vacuum to prevent wraparound of table "db1.public.vacuum_test": index scans: 1 pages: 0 removed, 14019 remain, 9347 scanned (66.67% of total), 0 eagerly scanned tuples: 500000 removed, 833358 remain, 0 are dead but not yet removable removable cutoff: 1143, which was 1000000000 XIDs old when operation ended new relfrozenxid: 1143, which is 4 XIDs ahead of previous value frozen: 4674 pages from table (33.34% of total) had 500000 tuples frozen visibility map: 9347 pages set all-visible, 9347 pages set all-frozen (0 were all-visible) index scan needed: 4674 pages from table (33.34% of total) had 499967 dead item identifiers removed index "vacuum_test_pkey": pages: 4116 in total, 0 newly deleted, 0 currently deleted, 0 reusable avg read rate: 3.471 MB/s, avg write rate: 18.201 MB/s buffer usage: 25789 hits, 1755 reads, 9203 dirtied WAL usage: 26106 records, 12086 full page images, 52913197 bytes, 2741 buffers full system usage: CPU: user: 0.30 s, system: 0.07 s, elapsed: 3.95 s
放置されたプリペアドトランザクション(原因3)
プリペアドトランザクション(PREPARE TRANSACTION)は、2フェーズコミットで使用されるトランザクションであり、COMMIT PREPARED または ROLLBACK PREPARED が実行されるまでトランザクションが完了しません。
そのため、長期間放置されたプリペアドトランザクションは、xminを保持し続け、VACUUMによる行回収を阻害する原因となります。
postgresql.confでmax_prepated_transaction=10と設定し再起動します。
その後、下記のSQLを端末1で実行します。
db1=# BEGIN; BEGIN db1=*# PREPARE TRANSACTION 'transaction1'; PREPARE TRANSACTION db1=#
別の端末2で下記の更新を実行します。
db1=# UPDATE vacuum_test SET data = md5(random()::text) WHERE id <= 500000; UPDATE 500000 db1=#
VACUUMのログは下記のようになります。削除された行の500000行が回収できない、removable cutoffは1000001143と出ています。
2026-03-26 22:36:16.987 JST [27867] LOG: automatic vacuum of table "db1.public.vacuum_test": index scans: 0 pages: 0 removed, 14019 remain, 9347 scanned (66.67% of total), 0 eagerly scanned tuples: 0 removed, 1277823 remain, 500000 are dead but not yet removable removable cutoff: 1000001143, which was 3 XIDs old when operation ended frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s buffer usage: 18751 hits, 0 reads, 0 dirtied WAL usage: 1 records, 0 full page images, 258 bytes, 0 buffers full system usage: CPU: user: 0.06 s, system: 0.00 s, elapsed: 0.31 s
しかし、pg_stat_activityにもpg_replication_slotsにも1000001143は出てきません。
db1=# SELECT * FROM pg_stat_activity WHERE (backend_xmin != 0 or backend_xid != 0) AND pid <> pg_backend_pid(); datid | datname | pid | leader_pid | usesysid | usename | application_name | client_addr | client_hostname | client_port | backend_start | xact_start | query_start | state_change | wait_event_type | wait_event | state | backend_xid | backend_xmin | query_id | query | backend_type -------+---------+-------+------------+----------+----------+------------------+-------------+-----------------+-------------+-------------------------------+-------------------------------+-------------------------------+-------------------------------+-----------------+-------------------+---------------------+-------------+--------------+----------+-----------------------------------+---------------- (0 rows) db1=# SELECT * FROM pg_replication_slots; slot_name | plugin | slot_type | datoid | database | temporary | active | active_pid | xmin | catalog_xmin | restart_lsn | confirmed_flush_lsn | wal_status | safe_wal_size | two_phase | two_phase_at | inactive_since | conflicting | invalidation_reason | failover | synced -----------+--------+-----------+--------+----------+-----------+--------+------------+------+--------------+-------------+---------------------+------------+---------------+-----------+--------------+-------------------------------+-------------+---------------------+----------+-------- (0 rows) db1=#
[対策]
pg_prepared_xacts ビューを確認することで、未完了のプリペアドトランザクションを確認できます。transaction列が1000001143となっていますので、これが原因です。このプリペアドトランザクションをロールバックすることで、xminの固定は解消され、VACUUM処理が進みます。
db1=# SELECT * FROM pg_prepared_xacts; transaction | gid | prepared | owner | database -------------+--------------+-------------------------------+----------+---------- 1000001143 | transaction1 | 2026-03-26 22:33:40.521517+09 | postgres | db1 (1 row) db1=# ROLLBACK PREPARED 'transaction1'; ROLLBACK PREPARED db1=# SELECT * FROM pg_prepared_xacts; transaction | gid | prepared | owner | database -------------+--------------+-------------------------------+----------+---------- (0 rows) db1=#
VACUUMのログを確認すると、削除できなかった500000行は回収され、削除できない行の残りは0行となりました。
2026-03-26 22:42:23.149 JST [28009] LOG: automatic vacuum of table "db1.public.vacuum_test": index scans: 1 pages: 0 removed, 14019 remain, 9347 scanned (66.67% of total), 0 eagerly scanned tuples: 500000 removed, 750102 remain, 0 are dead but not yet removable removable cutoff: 1000001146, which was 0 XIDs old when operation ended new relfrozenxid: 1000001144, which is 1 XIDs ahead of previous value frozen: 2 pages from table (0.01% of total) had 74 tuples frozen visibility map: 9347 pages set all-visible, 4674 pages set all-frozen (0 were all-visible) index scan needed: 4673 pages from table (33.33% of total) had 499967 dead item identifiers removed index "vacuum_test_pkey": pages: 4116 in total, 0 newly deleted, 0 currently deleted, 0 reusable avg read rate: 1.675 MB/s, avg write rate: 14.766 MB/s buffer usage: 26172 hits, 1371 reads, 12085 dirtied WAL usage: 21432 records, 12085 full page images, 52668618 bytes, 4692 buffers full
まとめ
最初に「この記事のポイント」でお伝えした内容を繰り返します。
VACUUMが期待通りに動作しない場合、以下を確認することで原因特定が可能です。
- ログに「are dead but not yet removable」が出ているか
- ログの「removable cutoff / oldest xmin」の数字が進んでいないか
- 以下のビューを確認する(ストリーミングレプリケーション環境の場合はスタンバイ側も)
- pg_stat_activity
- pg_replication_slots
- pg_prepared_xacts
主な原因は以下の3つです。
- ロングトランザクション
- レプリケーションスロット
- プリペアドトランザクション
対策の本質は 「xminを固定している要因を解消すること」 です。
(おまけ)検証方法
端末1でdb1に接続して、下記のSQL文を実行します。
db1=# CREATE TABLE vacuum_test ( id bigserial PRIMARY KEY, data text ); INSERT INTO vacuum_test(data) SELECT md5(i::text) FROM generate_series(1, 1000000) i; VACUUM (ANALYZE,VERBOSE) vacuum_test;
実行ログは下記の通りです。
CREATE TABLE INSERT 0 1000000 INFO: vacuuming "db1.public.vacuum_test" INFO: finished vacuuming "db1.public.vacuum_test": index scans: 0 pages: 0 removed, 9346 remain, 9346 scanned (100.00% of total), 0 eagerly scanned tuples: 0 removed, 1000000 remain, 0 are dead but not yet removable removable cutoff: 1134, which was 2 XIDs old when operation ended frozen: 0 pages from table (0.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed avg read rate: 0.000 MB/s, avg write rate: 0.344 MB/s buffer usage: 18714 hits, 0 reads, 2 dirtied WAL usage: 3 records, 2 full page images, 16740 bytes, 0 buffers full system usage: CPU: user: 0.03 s, system: 0.00 s, elapsed: 0.04 s INFO: vacuuming "db1.pg_toast.pg_toast_17023" INFO: finished vacuuming "db1.pg_toast.pg_toast_17023": index scans: 0 pages: 0 removed, 0 remain, 0 scanned (100.00% of total), 0 eagerly scanned tuples: 0 removed, 0 remain, 0 are dead but not yet removable removable cutoff: 1134, which was 2 XIDs old when operation ended frozen: 0 pages from table (100.00% of total) had 0 tuples frozen visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible) index scan not needed: 0 pages from table (100.00% of total) had 0 dead item identifiers removed avg read rate: 12.245 MB/s, avg write rate: 0.000 MB/s buffer usage: 25 hits, 1 reads, 0 dirtied WAL usage: 1 records, 0 full page images, 258 bytes, 0 buffers full system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s INFO: analyzing "public.vacuum_test" INFO: "vacuum_test": scanned 9346 of 9346 pages, containing 1000000 live rows and 0 dead rows; 30000 rows in sample, 1000000 estimated total rows INFO: finished analyzing table "db1.public.vacuum_test" avg read rate: 0.638 MB/s, avg write rate: 0.957 MB/s buffer usage: 9478 hits, 4 reads, 6 dirtied WAL usage: 10 records, 5 full page images, 23354 bytes, 0 buffers full system usage: CPU: user: 0.04 s, system: 0.00 s, elapsed: 0.04 s VACUUM db1=#
次に、別の端末2を立ち上げてdb1に接続し、REPEATABLE READ レベルでトランザクションを開始、先ほど作成したvacuum_testテーブルを参照して放置します。idle in transaction状態となります。
db1=# BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN db1=* # SELECT COUNT(*) FROM vacuum_test; count --------- 1000000 (1 row) db1=*#
元の端末1に戻って、500000件UPDATEします。すると「削除された行(dead tuples)」が500000行発生し、自動VACUUMが実行されます。
db1=# UPDATE vacuum_test SET data = md5(random()::text) WHERE id <= 500000; UPDATE 500000 db1=#
しかし、端末2のidle状態のトランザクションにより、「削除された行(dead tuples)」は回収されずに残ったままとなります。この端末2のidle状態のトランザクション(idle in transaction)が長い間続くとロングトランザクションと呼ばれます。