【PostgreSQL】INSERT/UPDATE/DELETE で xmin/xmax がどう変わるかを確認してみた

【PostgreSQL】INSERT/UPDATE/DELETE で xmin/xmax がどう変わるかを確認してみた

PostgreSQL のシステム列である xmin / xmax が、INSERT / UPDATE / DELETE によってどのように変化するかを pageinspect を使いながら検証しました。
2026.07.05

PostgreSQL はテーブルのすべての行に xmin と xmax というシステム列を自動で記録しています。

本記事では、実際に INSERT / UPDATE / DELETE を実行しながら、それぞれの操作で xmin / xmax にどのような値が記録されるのかを確認していきます。

検証の準備

PostgreSQL のインストール

検証のための実行環境は EC2(AL2023)にインストールした PostgreSQL を使用しました。
EC2 に PostgreSQL をインストールする手順としては下記をご参照ください。

https://dev.classmethod.jp/articles/installing-postgresql-on-ec2-al2023/

PostgreSQL 17.10 を利用し、検証していきます。

[ec2-user@ip-xx-xx-xx-xx ~]$ sudo -u postgres psql
psql (17.10)
Type "help" for help.

postgres=# select * from version();
                                                    version                                                    
---------------------------------------------------------------------------------------------------------------
 PostgreSQL 17.10 on x86_64-amazon-linux-gnu, compiled by gcc (GCC) 11.5.0 20240719 (Red Hat 11.5.0-5), 64-bit
(1 row)

拡張機能 pageinspect の有効化

pageinspect を入れなくても xmin/xmax を確認するだけならできますが、
dead tuple については、この拡張機能が無いと確認ができないので、先にインストールしておきます。

pageinspect は postgresql17-contrib パッケージに含まれています。
EC2 に接続し、以下のコマンドでインストールします。

$ sudo dnf install postgresql17-contrib -y

インストールができたら、PostgreSQL に接続します。

# PostgreSQLに接続
$ sudo -u postgres psql

PostgreSQL 接続後、pageinspect を有効化します。

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

-- データベースに接続
\c blog_sample_db

-- まずpageinspectが有効化されているか確認
SELECT extname, extversion
FROM pg_extension
WHERE extname = 'pageinspect';

-- 結果:0 rows(まだ有効化されていない)
 extname | extversion
---------+------------
(0 rows)

-- pageinspect拡張機能を有効化
CREATE EXTENSION pageinspect;

-- 再度確認
SELECT extname, extversion
FROM pg_extension
WHERE extname = 'pageinspect';

-- 結果:有効化された
   extname   | extversion
-------------+------------
 pageinspect | 1.12
(1 row)

以上で、準備完了です。

xmin/xmax とは

PostgreSQL はテーブルのすべての行(live/dead tuple 全て)に以下のシステム列を自動で記録しています。

列名 意味
xmin その行を作成したトランザクションID(XID)
xmax その行を削除・更新したトランザクションID(XID)。削除されていない場合は 0

xmin
この行バージョンの挿入トランザクションの識別情報(トランザクションID)です。 (行バージョンとは、行の個別の状態です。 行が更新される度に、同一の論理的な行に対する新しい行バージョンが作成されます。)
...
xmax
削除トランザクションの識別情報(トランザクションID)です。 削除されていない行バージョンではゼロです。 可視の行バージョンでこの列が非ゼロの場合があります。 これは通常、削除トランザクションがまだコミットされていないこと、または、削除の試行がロールバックされたことを意味しています。

5.6. システム列

この2つの列は、PostgreSQL が MVCC(多版型同時実行制御) を実現するための仕組みの一部となっています。
PostgreSQL はデータを更新・削除する際に、既存の行を即座に物理削除するのではなく、xmin/xmax に操作したトランザクションの情報を記録することで、「どのトランザクションからどの行が見えるか」 を管理しています。

トランザクションID(XID)とは

PostgreSQL はトランザクションが発生するたびに XID(トランザクションID) を連番で採番します。
現在のトランザクション ID は以下の関数で特定ができます。

pg_current_xact_id()
現在のトランザクションのIDを返します。

9.27.8. トランザクションIDとスナップショット情報関数

以下に pg_current_xact_id() の使用例を記載します。
BEGINからCOMMITまでの間は同じXIDが使われます。つまり1つのトランザクション = 1つのXIDとなります。
また、SELECT自体も暗黙のトランザクションとして実行されるため以下のように SELECT を呼び出す度にカウントが増えていきます。

使い方の例
-- トランザクション外でXIDを確認
blog_sample_db=# SELECT pg_current_xact_id();
 pg_current_xact_id 
--------------------
                779
(1 row)

-- トランザクションを開始
blog_sample_db=# BEGIN;
BEGIN

-- トランザクション内でXIDを確認
blog_sample_db=*# SELECT pg_current_xact_id();
 pg_current_xact_id 
--------------------
                780  -- トランザクション開始時にXIDが採番される
(1 row)

-- 同じトランザクション内で再度確認
blog_sample_db=*# SELECT pg_current_xact_id();
 pg_current_xact_id 
--------------------
                780  -- 同じトランザクション内ではXIDは変わらない
(1 row)

blog_sample_db=*# COMMIT;
COMMIT

-- トランザクション終了後に再度確認
blog_sample_db=# SELECT pg_current_xact_id();
 pg_current_xact_id 
--------------------
                781  -- 新しいトランザクションには新しいXIDが採番される
(1 row)

やってみた

0. サンプルテーブル作成

まず適当なテーブルを作成しておきます。

-- テーブル作成
CREATE TABLE xmin_test (
    id   SERIAL PRIMARY KEY,
    name TEXT
);

1. INSERTして xmin/xmax を確認

作成したテーブルにデータを INSERT した時の xmin/xmax がどんな値を取るか確認してみます。

-- テーブルの中身を確認(この時点では空)
blog_sample_db=# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name 
------+------+----+------
(0 rows)

-- トランザクションを開始
blog_sample_db=# BEGIN;
BEGIN

-- 現在のXIDを確認(このXIDがxminに入るはず)
blog_sample_db=*# SELECT pg_current_xact_id();
 pg_current_xact_id 
--------------------
                794  -- XID: 794 が採番された
(1 row)

-- 1行INSERT
blog_sample_db=*# INSERT INTO xmin_test (name) VALUES ('Alice');
INSERT 0 1

-- INSERT後(COMMIT前)のxmin/xmaxを確認
blog_sample_db=*# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name  
------+------+----+-------
  794 |    0 |  1 | Alice  -- xmin=794(挿入したXID)、xmax=0(削除・更新なし)
(1 row)

-- コミット
blog_sample_db=*# COMMIT;
COMMIT

-- COMMIT後のxmin/xmaxを確認
blog_sample_db=# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name  
------+------+----+-------
  794 |    0 |  1 | Alice  -- COMMIT後も変化なし
(1 row)

上記より、INSERT 後のレコードを確認すると、以下のことがわかります。

  • xmin = 794 : この行を作成したトランザクション(XID:794)が記録されている
  • xmax = 0 : まだ削除・更新されていないため 0

xmin には、そのレコードを挿入したトランザクションの ID が入ることがわかりました。

2. UPDATEして xmin/xmax を確認

続いて、先ほど挿入した name=Alice のレコードを UPDATE します。

PostgreSQL の UPDATE は 「既存の行を書き換える」のではなく、「古い行を無効化して新しい行を作成する」 という動作をします。そのため UPDATE 後は以下の2つの行が存在します。

  • live tuple UPDATE後に新しく作成された行(後述の Bobの行)
  • dead tuple UPDATEによって無効化された古い行(後述の Aliceの行)
タプルについては過去ブログを参照ください。

なお、タプルとはテーブルの 1 行のことです。
以下公式ドキュメントより、タプルは「属性を一定の順序で集めたもの」とありますが、本ブログにおいては、「タプル = 行」という理解で問題ありません。
...
また、タプルには以下の2種類があります。

  • live tuple:有効な(生きている)行。SELECT で取得できる。
  • dead tuple:DELETE や UPDATE で無効になった(死んでいる)行。SELECT では見えないが、ディスクには残っている。

【PostgreSQL】DELETEしてもディスク容量が減らない挙動を検証してみた

通常の SELECT では live tuple しか見えませんが、pageinspect を使うことで dead tuple も確認できます。

以下で実際にやってみます。

-- UPDATE前の状態を確認
blog_sample_db=# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name  
------+------+----+-------
  794 |    0 |  1 | Alice  -- xmin=794(INSERTしたXID)、xmax=0(まだ更新・削除なし)
(1 row)

-- トランザクションを開始
blog_sample_db=# BEGIN;
BEGIN

-- 現在のXIDを確認(このXIDが新しい行のxminと古い行のxmaxに入るはず)
blog_sample_db=*# SELECT pg_current_xact_id();
 pg_current_xact_id 
--------------------
                795  -- XID: 795 が採番された
(1 row)

-- name を 'Alice' から 'Bob' に UPDATE
blog_sample_db=*# UPDATE xmin_test SET name = 'Bob' WHERE id = 1;
UPDATE 1

-- UPDATE後(COMMIT前)のxmin/xmaxを確認
blog_sample_db=*# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name 
------+------+----+------
  795 |    0 |  1 | Bob  -- xmin=795(更新したXID)、xmax=0(削除なし)
(1 row)

-- コミット
blog_sample_db=*# COMMIT;
COMMIT

-- COMMIT後のxmin/xmaxを確認
blog_sample_db=# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name 
------+------+----+------
  795 |    0 |  1 | Bob  -- COMMIT後も変化なし
(1 row)

-- 通常のSELECTではlive tupleしか見えない
blog_sample_db=# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name 
------+------+----+------
  795 |    0 |  1 | Bob  -- Aliceの行(dead tuple)は見えない
(1 row)

-- pageinspectを使うことでdead tupleも含めて確認できる
blog_sample_db=# SELECT lp, t_xmin, t_xmax, t_data
FROM heap_page_items(get_raw_page('xmin_test', 0));
 lp | t_xmin | t_xmax |         t_data         
----+--------+--------+------------------------
  1 |    794 |    795 | \x010000000d416c696365  -- dead tuple(Aliceの行)t_xmax=795(更新したXID)
  2 |    795 |      0 | \x0100000009426f62      -- live tuple(Bobの行)t_xmax=0(削除なし)
(2 rows)

-- 上記の "t_data" カラムの表示は16進数なので、デコードするとname列の値が確認できる
blog_sample_db=# SELECT convert_from('\x416c696365', 'UTF8');
 convert_from 
--------------
 Alice
(1 row)

blog_sample_db=# SELECT convert_from('\x426f62', 'UTF8');
 convert_from 
--------------
 Bob
(1 row)

上記の結果から以下のことがわかります。

  • dead tuple(Aliceの行) : t_xmax=795 が記録されており、XID:795 によって dead tuple になった
  • live tuple(Bobの行) : t_xmin=795 が記録されており、XID:795 によって新しく作成された。t_xmax=0 のため、まだ削除・更新されていない

この動作は、公式ドキュメントの以下の説明とも一致しますね。

xmin この行バージョンの挿入トランザクションの識別情報(トランザクションID)です。
...
xmax 削除トランザクションの識別情報(トランザクションID)です。削除されていない行バージョンではゼロです。

5.6. システム列

3. DELETEして xmin/xmax を確認

最後に、先ほど UPDATE した name=Bob の行を DELETE します。

-- DELETE前の状態を確認
blog_sample_db=# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name 
------+------+----+------
  795 |    0 |  1 | Bob  -- xmin=795(UPDATEしたXID)、xmax=0(まだ削除なし)
(1 row)

-- トランザクションを開始
blog_sample_db=# BEGIN;
BEGIN

-- 現在のXIDを確認(このXIDがxmaxに入るはず)
blog_sample_db=*# SELECT pg_current_xact_id();
 pg_current_xact_id 
--------------------
                796  -- XID: 796 が採番された
(1 row)

-- Bobの行をDELETE
blog_sample_db=*# DELETE FROM xmin_test WHERE id = 1;
DELETE 1

-- DELETE後(COMMIT前)のxmin/xmaxを確認
blog_sample_db=*# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name 
------+------+----+------
(0 rows)  -- 通常のSELECTでは既に見えない

-- コミット
blog_sample_db=*# COMMIT;
COMMIT

-- COMMIT後のxmin/xmaxを確認
blog_sample_db=# SELECT xmin, xmax, id, name FROM xmin_test;
 xmin | xmax | id | name 
------+------+----+------
(0 rows)  -- COMMIT後も見えない

-- pageinspectでdead tupleも確認
blog_sample_db=# SELECT lp, t_xmin, t_xmax, t_data
FROM heap_page_items(get_raw_page('xmin_test', 0));
 lp | t_xmin | t_xmax |         t_data         
----+--------+--------+------------------------
  1 |    794 |    795 | \x010000000d416c696365  -- dead tuple(Aliceの行)UPDATE(前述)で dead tupleになった
  2 |    795 |    796 | \x0100000009426f62      -- dead tuple(Bobの行)t_xmax=796(DELETEしたXID)
(2 rows)

上記より、DELETE 後は通常の SELECT では行が見えなくなっていますが、pageinspect を使うことで dead tuple として行が物理的に残っていることがまずは確認できます。

そして xmin/xmax に着目すると下記の通りとなりました。

  • dead tuple(Bobの行) : t_xmax=796 が記録されており、XID:796 のトランザクションによって dead tuple になった

DELETE されると、xmax に削除を実施したトランザクションの XID が記録され、その行が dead tuple になるということがわかりました。

終わりに

本記事では、PostgreSQL のシステム列である xmin と xmax が INSERT / UPDATE / DELETE によってどのように変化するかを検証しました。以下が結果の表です。

操作 tuple の種類 xmin xmax
INSERT live tuple 挿入したXID 0
UPDATE live tuple(新しい行) 更新したXID 0
UPDATE dead tuple(古い行) 変化なし 更新したXID
DELETE dead tuple 変化なし 削除したXID

公式ドキュメントには xmin / xmax の定義は記載されていますが、個人的には実際に INSERT / UPDATE / DELETE を通じて値がどう変わるかは手を動かしてみないとイメージしづらい部分がありました。
今回の検証を通じて、それぞれの操作でどのような値が記録されるのかを具体的に確認することができて、とても有意義でした。

本記事が xmin / xmax の挙動を理解する助けになれば幸いです。

参考情報

https://dev.classmethod.jp/articles/installing-postgresql-on-ec2-al2023/
https://www.postgresql.jp/document/17/html/ddl-system-columns.html
https://www.postgresql.jp/document/17/html/functions-info.html#FUNCTIONS-INFO-SNAPSHOT
https://dev.classmethod.jp/articles/postgresql-delete-disk-space-not-reduced-vacuum/#%25E3%2582%25BF%25E3%2583%2597%25E3%2583%25AB%25E4%25BB%25B6%25E6%2595%25B0%25E3%2581%25AE%25E7%25A2%25BA%25E8%25AA%258D

この記事をシェアする

関連記事