【PostgreSQL】ロングトランザクションがある間は VACUUM を実行しても dead tuple が消えない

【PostgreSQL】ロングトランザクションがある間は VACUUM を実行しても dead tuple が消えない

PostgreSQL のロングトランザクションがVACUUMを妨害する様子を、実際の検証を通じて紹介します。
2026.07.14

PostgreSQL では UPDATE や DELETE を実行すると、古い行が dead tuple としてディスク上に残ります。
この dead tuple は VACUUM によって回収されますが、ロングトランザクションが存在する場合、VACUUM を実行しても dead tuple が回収されないことがあります。

本記事では、実際にロングトランザクションを発生させながら、VACUUM が実際に妨害される様子を検証します。

いきなりまとめ

  • ロングトランザクションが存在する間は、VACUUM を実行しても dead tuple が回収されない
  • これは「他のトランザクションから参照される可能性がある行は削除できない」という PostgreSQL の MVCC の仕組みによるもの
  • ロングトランザクションが終了すると、VACUUM は dead tuple を正常に回収できるようになる

検証環境の準備

本ブログでは、PostgreSQL 17.10 を使用します。また、dead tuple の内容を見るために拡張機能の pageinspect も有効化します。
それぞれのインストール手順については、下記ブログの「検証の準備」の項目をご参照ください。
https://dev.classmethod.jp/articles/postgresql-xmin-xmax-insert-update-delete/

サンプルデータの作成

検証のため、サンプル DB およびテーブルを作成しておきます。

-- データベースを作成
postgres=# CREATE DATABASE blog_sample_db;
CREATE DATABASE

-- blog_sample_dbに接続
postgres=# \c blog_sample_db
You are now connected to database "blog_sample_db" as user "postgres".

-- pageinspect を有効化
blog_sample_db=# CREATE EXTENSION pageinspect;
CREATE EXTENSION

-- pageinspect が有効化されたか確認
blog_sample_db=# SELECT extname, extversion
FROM pg_extension
WHERE extname = 'pageinspect';
   extname   | extversion 
-------------+------------
 pageinspect | 1.12
(1 row)

-- サンプルテーブルを作成
blog_sample_db=# CREATE TABLE long_tx_test (
    id   SERIAL PRIMARY KEY,
    name TEXT
);
CREATE TABLE

-- 10行のデータをINSERT
blog_sample_db=# INSERT INTO long_tx_test (name)
SELECT 'user_' || i FROM generate_series(1, 10) AS i;
INSERT 0 10

-- 挿入したデータを確認
-- xmin=801(挿入したXID)、xmax=0(まだ更新・削除なし)
blog_sample_db=# SELECT xmin, xmax, id, name FROM long_tx_test;
 xmin | xmax | id |  name   
------+------+----+---------
  801 |    0 |  1 | user_1
  801 |    0 |  2 | user_2
  801 |    0 |  3 | user_3
  801 |    0 |  4 | user_4
  801 |    0 |  5 | user_5
  801 |    0 |  6 | user_6
  801 |    0 |  7 | user_7
  801 |    0 |  8 | user_8
  801 |    0 |  9 | user_9
  801 |    0 | 10 | user_10
(10 rows)

なお、xmin/xmax については過去ブログの「xmin/xmax とは」の節をご参照ください。
https://dev.classmethod.jp/articles/postgresql-xmin-xmax-insert-update-delete/

やってみた

※以降の手順ではターミナルを 2 つ用意し、セッション A と B 毎に手順を示しています。

STEP1:ロングトランザクションを開始する(セッションA)

ターミナル1(セッションA)
-- トランザクションを開始し、そのまま放置する
-- このトランザクションがVACUUMの妨害役になる
blog_sample_db=# BEGIN;
BEGIN

-- 現在のXIDを確認
-- このXID(802)がロングトランザクションとして残り続ける
blog_sample_db=*# SELECT pg_current_xact_id();
 pg_current_xact_id 
--------------------
                802
(1 row)

-- ここでセッションAはBEGINしたまま放置する

STEP2:別セッションでUPDATEしてdead tupleを発生させる(セッションB)

ターミナル2(セッションB)
-- 全行をUPDATEする
-- PostgreSQLのUPDATEは「古い行を無効化して新しい行を作成する」動作をするため
-- 古い行がdead tupleとして残る
blog_sample_db=# UPDATE long_tx_test SET name = name || '_updated';
UPDATE 10

-- dead tupleの数を確認
-- n_dead_tup=10:UPDATEによって10件のdead tupleが発生した
-- n_live_tup=10:新しく作成された10件のlive tupleが存在する
blog_sample_db=# SELECT n_dead_tup, n_live_tup
FROM pg_stat_user_tables
WHERE relname = 'long_tx_test';
 n_dead_tup | n_live_tup 
------------+------------
         10 |         10
(1 row)

-- 通常のSELECTではlive tupleのみ見える
-- xmin=803(UPDATEした時のXID)、xmax=0(まだ削除なし)
blog_sample_db=# SELECT xmin, xmax, id, name FROM long_tx_test;
 xmin | xmax | id |      name       
------+------+----+-----------------
  803 |    0 |  1 | user_1_updated
  803 |    0 |  2 | user_2_updated
  803 |    0 |  3 | user_3_updated
  803 |    0 |  4 | user_4_updated
  803 |    0 |  5 | user_5_updated
  803 |    0 |  6 | user_6_updated
  803 |    0 |  7 | user_7_updated
  803 |    0 |  8 | user_8_updated
  803 |    0 |  9 | user_9_updated
  803 |    0 | 10 | user_10_updated
(10 rows)

-- pageinspectでdead tupleも含めて確認
-- lp 1-10  : t_xmin=801, t_xmax=803 → dead tuple(UPDATEで無効化された古い行)
-- lp 11-20 : t_xmin=803, t_xmax=0   → live tuple(UPDATEで新しく作成された行)
blog_sample_db=# SELECT lp, t_xmin, t_xmax
FROM heap_page_items(get_raw_page('long_tx_test', 0));
 lp | t_xmin | t_xmax 
----+--------+--------
  1 |    801 |    803
  2 |    801 |    803
  3 |    801 |    803
  4 |    801 |    803
  5 |    801 |    803
  6 |    801 |    803
  7 |    801 |    803
  8 |    801 |    803
  9 |    801 |    803
 10 |    801 |    803
 11 |    803 |      0
 12 |    803 |      0
 13 |    803 |      0
 14 |    803 |      0
 15 |    803 |      0
 16 |    803 |      0
 17 |    803 |      0
 18 |    803 |      0
 19 |    803 |      0
 20 |    803 |      0
(20 rows)

STEP3:VACUUMを実行する(セッションB)

ターミナル2(セッションB)
-- VACUUMを実行する
-- セッションAのロングトランザクション(XID:802)がまだ生きているため
-- dead tupleは回収されないはず
blog_sample_db=# VACUUM long_tx_test;
VACUUM

-- dead tupleの数を確認
-- n_dead_tup=10:VACUUMを実行したにもかかわらずdead tupleが残っている
blog_sample_db=# SELECT n_dead_tup, n_live_tup
FROM pg_stat_user_tables
WHERE relname = 'long_tx_test';
 n_dead_tup | n_live_tup 
------------+------------
         10 |         10
(1 row)

-- pageinspectでも確認
-- lp 1-10のdead tupleがVACUUM後も残っている
blog_sample_db=# SELECT lp, t_xmin, t_xmax
FROM heap_page_items(get_raw_page('long_tx_test', 0));
 lp | t_xmin | t_xmax 
----+--------+--------
  1 |    801 |    803
  2 |    801 |    803
  3 |    801 |    803
  4 |    801 |    803
  5 |    801 |    803
  6 |    801 |    803
  7 |    801 |    803
  8 |    801 |    803
  9 |    801 |    803
 10 |    801 |    803
 11 |    803 |      0
 12 |    803 |      0
 13 |    803 |      0
 14 |    803 |      0
 15 |    803 |      0
 16 |    803 |      0
 17 |    803 |      0
 18 |    803 |      0
 19 |    803 |      0
 20 |    803 |      0
(20 rows)

上記の通り、VACUUMを実行したにもかかわらず、dead tupleが消えませんでした。
これはセッションAのロングトランザクション(XID:802)がまだ生きているためです。

公式ドキュメントには以下の記載があります。

あるバージョンの行は他のトランザクションから参照される可能性がある場合は削除されてはなりません。

24.1. 定常的なバキューム作業

つまり、セッションAのロングトランザクション(XID:802)が生きている間は、
dead tupleが「まだセッションAから参照される可能性がある」とみなされるため VACUUM をしても dead tuple が回収されない動作となります。

STEP4:ロングトランザクションを終了する(セッションA)

ターミナル1(セッションA)
-- セッションAでトランザクションを終了する
blog_sample_db=*# COMMIT;
COMMIT

STEP5:再度VACUUMを実行する(セッションB)

ターミナル2(セッションB)
-- 再度VACUUMを実行する
-- セッションAのロングトランザクションが終了したため
-- 今度はdead tupleが回収されるはず
blog_sample_db=# VACUUM long_tx_test;
VACUUM

-- dead tupleの数を確認
-- n_dead_tup=0:想定通り今度はdead tupleが回収された
blog_sample_db=# SELECT n_dead_tup, n_live_tup
FROM pg_stat_user_tables
WHERE relname = 'long_tx_test';
 n_dead_tup | n_live_tup 
------------+------------
          0 |         10
(1 row)

-- pageinspectでも確認
-- lp 1-10 : t_xmin, t_xmax が空欄
--   → dead tupleの中身が回収され、再利用可能な領域になった
-- lp 11-20 : t_xmin=803, t_xmax=0
--   → live tupleのみが残っている
blog_sample_db=# SELECT lp, t_xmin, t_xmax
FROM heap_page_items(get_raw_page('long_tx_test', 0));
 lp | t_xmin | t_xmax 
----+--------+--------
  1 |        |       
  2 |        |       
  3 |        |       
  4 |        |       
  5 |        |       
  6 |        |       
  7 |        |       
  8 |        |       
  9 |        |       
 10 |        |       
 11 |    803 |      0
 12 |    803 |      0
 13 |    803 |      0
 14 |    803 |      0
 15 |    803 |      0
 16 |    803 |      0
 17 |    803 |      0
 18 |    803 |      0
 19 |    803 |      0
 20 |    803 |      0
(20 rows)

ロングトランザクションが終了した後にVACUUMを実行すると、無事 dead tupleが回収されました。

終わりに

本記事では、ロングトランザクションが VACUUM を妨害する様子を再現してみました。
なお、ロングトランザクションを放置した場合、最終的には以下のような影響につながります。

ロングトランザクションがVACUUMを妨害する

dead tupleが回収されない

再利用可能な領域が生まれない

新しい行が入るたびに新しいページが追加される

テーブルが不必要に膨張する

クエリが遅くなる・ディスクが枯渇する

ロングトランザクションは気づきにくい問題ですが、放置すると本番環境でのパフォーマンス低下やディスク枯渇につながる可能性があります。
そのため、定期的に実行中のトランザクションを監視し、ロングトランザクションを早期に検知することが重要です。

本記事がどなたかのお役に立てば幸いです。

※補足: 「dead tuple が増えると遅くなる理由」については下記ブログでも説明していますためご参照ください。
https://dev.classmethod.jp/articles/postgresql-dead-tuple-slow-query-verification/#dead-tuple-%25E3%2581%258C%25E5%25A2%2597%25E3%2581%2588%25E3%2582%258B%25E3%2581%25A8%25E9%2581%2585%25E3%2581%258F%25E3%2581%25AA%25E3%2582%258B%25E7%2590%2586%25E7%2594%25B1

参考情報

https://dev.classmethod.jp/articles/postgresql-xmin-xmax-insert-update-delete/
https://www.postgresql.jp/document/17/html/routine-vacuuming.html
https://dev.classmethod.jp/articles/postgresql-dead-tuple-slow-query-verification/

この記事をシェアする

関連記事