PostgreSQLでTRUNCATEをROLLBACKした際の内部の挙動を追いかけてみた

2020.02.15

好きなRDBはPostgreSQLのCX事業本部@大阪の岩田です。

PostgreSQLの内部アーキテクチャについて少し調べてみたので内容をご紹介します。

環境

今回利用した環境です。

  • OS: AmazonLinux2 (ami-0af1df87db7b650f4)
  • PostgreSQL: 11.5-5

PostgreSQLの内部アーキテクチャ概要

初めて知った時はビックリしたのですが、PostgreSQLはDML以外にDDLもROLLBACKすることが可能です。つまりトランザクション内でTRUNCATEやDROP TABLEといったSQLを発行した場合もROLLBACKが可能です。これはPostgreSQL固有の内部アーキテクチャによって実現されています。

PostgreSQLはOracleやMySQLといったRDBとは違い、追記型アーキテクチャを採用しています。ざっくり説明すると追記型アーキテクチャでは行データの更新を行った際に、元データを更新するのではなく、元データに削除済みフラグを付け新たに更新後のデータを追加するような動作をします。10列中1列だけ更新するようなケースでも更新対象外の列まで含んだ10列分のデータを追加するので、更新性能が出づらいというデメリットはありますが、ファイル内に複数バージョンの行データが存在するので、読み取り一貫性の実現が容易というメリットもあります。

PostgreSQLの行データは実際のユーザーデータ(各列のデータ)に加えて以下の構造を持つHeapTupleHeaderDataというヘッダ情報等から構成されています。

フィールド 長さ 説明
t_xmin TransactionId 4バイト 挿入XIDスタンプ
t_xmax TransactionId 4バイト 削除XIDスタンプ
t_cid CommandId 4バイト 挿入、削除の両方または片方のCIDスタンプ(t_xvacと共有)
t_xvac TransactionId 4バイト 行バージョンを移すVACUUM操作用XID
t_ctid ItemPointerData 6バイト この行または最新バージョンの行の現在のTID
t_infomask2 uint16 2バイト 属性の数と各種フラグビット
t_infomask uint16 2バイト 様々なフラグビット
t_hoff uint8 1バイト ユーザデータに対するオフセット

PostgreSQL 11.5文書 68.6. データベースページのレイアウト

このヘッダ情報のt_xmint_xmaxを参照することで、どのトランザクションに対してどのバージョンの行データを見せるべきなのか?ということが判断できる訳です。※XID = トランザクションID

また、PostgreSQLは基本的に1リレーションにつき1ファイルを利用してデータを管理します(TOASTテーブルなどの例外もあります)。例えばOracleの場合は1つ以上のデータファイルから構成された表領域に複数のテーブルやインデックスが格納されますが、PostgreSQLの場合はテーブルやインデックスといったDB上のオブジェクト1つにつきOS上の1ファイルが対応付きます。PostgreSQLでTRUNCATEを実行すると、対象テーブル用の物理ファイルを新しく作成し、以後は新しい物理ファイルを参照するようになります。古い物理ファイルはTRUNCATEがを実行したトランザクションがコミットされるまで(実際はコミットされてからもしばらく)残り続けるため、ROLLBACKが可能になるという訳です。

やってみる

実際にTRUNCATEをROLLBACKした際にPostgreSQLの内部データがどのように遷移するか確認してみましょう。

環境構築

まずは検証環境の準備としてAmazon Linux上にPostgresSQLの環境を用意します。後ほどpageinspectを利用するので、PostgreSQL本体に加えて拡張モジュールもインストールしておきます。

$ sudo amazon-linux-extras enable postgresql11
$ sudo yum install postgresql-server postgresql-contrib

PostgreSQLがインストールできたらDBクラスタを作成します。

$ su - postgres
$ initdb
$ pg_ctl start

検証用に適当なテーブルが欲しいので、pgbenchを使ってテーブルを作成します。

$ psql -c "create database pgbench;"
$ pgbench -i pgbench

準備できたらFull Vacuumをかけてゴミを掃除しておきます。

$ vacuumdb --full -d pgbench

実際に挙動を追いかけてみる

実際にトランザクション内でテーブルをtruncateした際に、PostgreSQLのシステムカタログや物理ファイルがどのように更新されるかを追いかけてみます。今回はpgbenchで作成した pgbench_branchesテーブルを利用して動作を見ていきます。

まずはpageinspectが利用できるようにEXTENSIONを作成します。pageinspectはPostgreSQLの拡張モジュールでpageinspectを利用することで物理ファイル内のページから色々な情報を覗きみることができます。

pgbench=# CREATE EXTENSION pageinspect;
CREATE EXTENSION

まずpgbench_branchesテーブルのOIDを確認します。

pgbench=# select oid,relfilenode from pg_class where relname = 'pgbench_branches';
  oid  | relfilenode
-------+-------------
 19763 |       19780
(1 row)

pgbench_branchesテーブルのOIDは19763で、現在は19780という物理ファイルにテーブルのデータを格納していることが分かりました。続いてシステムカタログpg_classの物理ファイルに書き込まれている pgbench_branchesに関するタプルの情報を確認します。heap_page_itemsを利用することで前述のHeapTupleHeaderDataのデータが確認できます。

pgbench=# SELECT * FROM heap_page_items(get_raw_page('pg_class', 0)) WHERE t_oid = 19763;
-[ RECORD 1 ]-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
lp          | 3
lp_off      | 7584
lp_flags    | 1
lp_len      | 213
t_xmin      | 1266
t_xmax      | 0
t_field3    | 2
t_ctid      | (0,3)
t_infomask2 | 33
t_infomask  | 11019
t_hoff      | 32
t_bits      | 1111111111111111111111111111110100000000
t_oid       | 19763
t_data      | \x706762656e63685f6272616e6368657300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000098080000354d0000000000000a00000000000000444d000000000000010000000000803f0000000000000000010070720300000000000000000001640000000000000000f2040000010000005301000000000000001900000001000000010000004800000066696c6c666163746f723d3130300000

t_minが1266、t_xmax が0となっています。トランザクションIDが1266以後のトランザクションから見たpgbench_branchesテーブルのメタデータは上記タプルの情報になることが分かります。

検証用にpsqlのセッションをもう1つ立ち上げて(以後セッションAとします)トランザクションを開始します。

pgbench=# begin;
BEGIN
pgbench=# select txid_current();
 txid_current
--------------
         1344
(1 row)

もう片方のセッションからトランザクションを開始し(以後セッションBとします)pgbench_branches テーブルをTRUNCATEしてみます。

pgbench=# begin;
BEGIN
pgbench=# truncate table pgbench_branches;
TRUNCATE TABLE

再度システムカタログpg_classの物理ファイルの中身を確認してみます。

pgbench=# SELECT * FROM heap_page_items(get_raw_page('pg_class', 0)) WHERE t_oid = 19763;
-[ RECORD 1 ]-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
lp          | 3
lp_off      | 7584
lp_flags    | 1
lp_len      | 213
t_xmin      | 1266
t_xmax      | 1345
t_field3    | 0
t_ctid      | (0,12)
t_infomask2 | 33
t_infomask  | 8971
t_hoff      | 32
t_bits      | 1111111111111111111111111111110100000000
t_oid       | 19763
t_data      | \x706762656e63685f6272616e6368657300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000098080000354d0000000000000a00000000000000444d000000000000010000000000803f0000000000000000010070720300000000000000000001640000000000000000f2040000010000005301000000000000001900000001000000010000004800000066696c6c666163746f723d3130300000
-[ RECORD 2 ]-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
lp          | 12
lp_off      | 560
lp_flags    | 1
lp_len      | 213
t_xmin      | 1345
t_xmax      | 0
t_field3    | 0
t_ctid      | (0,12)
t_infomask2 | 33
t_infomask  | 10251
t_hoff      | 32
t_bits      | 1111111111111111111111111111110100000000
t_oid       | 19763
t_data      | \x706762656e63685f6272616e6368657300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000098080000354d0000000000000a00000000000000e84e0000000000000000000000000000000000000000000001007072030000000000000000000164000000000000000040050000010000005301000000000000001900000001000000010000004800000066696c6c666163746f723d3130300000

今度は2レコード抽出されました。1レコード目は t_min が1266、t_maxが1345なので、トランザクションIDが1266 ~ 1345の範囲内から参照されるタプルです。2レコード目はt_minが1345、t_maxが0なのでトランザクションIDが1345以後のトランザクションから参照されるタプルです。セッションBから、現在のトランザクションIDを確認してみましょう。

pgbench=# select txid_current();
-[ RECORD 1 ]+-----
txid_current | 1345

トランザクションIDは1345なので、セッションBから見たpgbench_branchesテーブルのメタデータはpg_classの物理ファイルに格納されたpgbench_branchesに関するタプルの中で、2つ目のタプルの情報を参照することが分かります。セッションBからpg_classの情報をSELECTしてみます。

pgbench=# select oid,relfilenode from pg_class where relname = 'pgbench_branches';
  oid  | relfilenode
-------+-------------
 19763 |       20200
(1 row)

TRUNCATE実行前からrelfilenodeの値が変わっていることが分かります。セッションAからもpg_classの情報をSELECTしてみます。

pgbench=# select oid,relfilenode from pg_class where relname = 'pgbench_branches';
  oid  | relfilenode
-------+-------------
 19763 |       19780
(1 row)

こちらのセッションからみるとrelfilenodeは19780のままです。

セッションBをROLLBACKしてTRUNCATEを無かったことにします。

pgbench=# rollback;
ROLLBACK

Vacuumを実行して削除フラグの立ったpg_class内のタプルを回収後に再度pg_classの物理ファイルの中身を確認してみます。

pgbench=# vacuum;
VACUUM

SELECT * FROM heap_page_items(get_raw_page('pg_class', 0)) WHERE t_oid = 19763;
pgbench=# SELECT * FROM heap_page_items(get_raw_page('pg_class', 0)) WHERE t_oid = 19763;
-[ RECORD 1 ]-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
lp          | 40
lp_off      | 768
lp_flags    | 1
lp_len      | 213
t_xmin      | 1427
t_xmax      | 0
t_field3    | 2
t_ctid      | (0,40)
t_infomask2 | 33
t_infomask  | 11019
t_hoff      | 32
t_bits      | 1111111111111111111111111111110100000000
t_oid       | 19763
t_data      | \x706762656e63685f6272616e6368657300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000098080000354d0000000000000a000000000000007a50000000000000010000000000803f000000000000000001007072030000000000000000000164000000000000000093050000010000005301000000000000001900000001000000010000004800000066696c6c666163746f723d3130300000

pgbench_branchesテーブルに関する情報が1タプルに減っていることが分かります。再度セッションBからpg_classの情報をSELECTしてみます。

pgbench=# select oid,relfilenode from pg_class where relname = 'pgbench_branches';
  oid  | relfilenode
-------+-------------
 19763 |       19780
(1 row)

relfilenodeが19780に戻りました。これでセッションBからpgbench_branchesテーブルを参照した際も物理ファイル19780を参照することになり、TRUNCATE実行前のpgbench_branchesの中身が見れるようになったことが分かります。

まとめ

実際に物理ファイルの中身がどのように更新されていくか確認することで、PostgreSQLの挙動をより深く理解することができました。また色々と調べてみようと思います。

参考