プログラミング
ページレベルでのVACUUM
Vacuum at the Page Level (boringsql.com)
要約
PostgreSQLのVACUUMコマンドがテーブルのページレベルでどのように動作するかを詳細に解説した記事です。DELETE操作によって生成された不要なデータ(デッドタプル)がページ内でどのように扱われ、VACUUMの各フェーズ(ヒープスキャン、インデックスクリーンアップ、ヒープクリーンアップ)を経て最終的にディスクスペースがどのように解放されるかを、具体的なコマンドとページのスナップショットを用いて説明しています。
全文翻訳
ページレベルでのVACUUM
目次
セットアップ
削除前のスナップショット
デッドタプルを作成する
VACUUMがテーブルを処理する方法
フェーズ1:ヒープスキャン - プルーニング、フリーズ、デッドTIDの収集
フェーズ2:インデックスクリーンアップ
フェーズ3:ヒープクリーンアップ - ラインポインタの解放
プルーニング後:LP_DEAD、そしてスペースが既に回復
完全なVACUUM後:LP_UNUSED
ラインポインタのライフサイクル、正確に
フリースペースマップ
ビジビリティマップ
フリーズ
VACUUM FULL vs 通常のVACUUM
すべてをまとめる
PostgresのHOTアップデートでは、ページプルーニングによるHOTチェーンのクリーンアップについて説明しました。これはPostgreSQLが通常の読み取り中にデッドタプルのスペースを回収するエレガントなショートカットです。すべてバックグラウンドプロセスを待つことなく行われます。しかし、プルーニングはあくまでショートカットです。それは単一のページ内で、HOTアップデートされたタプルにのみ機能します。それ以外のすべて(インデックス列に触れるコールドアップデート、通常のDELETE、インデックスエントリのクリーンアップ、フリースペースマップの登録、ビジビリティマップのメンテナンス)にはVACUUMが必要です。
この記事では、VACUUMが操作的に何をするかを繰り返しません。DELETEは難しい記事では、オートバキュームのチューニング、ワーカーの割り当て、デッドタプルクリーンアップの運用面をカバーしています。ここでは、VACUUMがバイトごとにどのように動作するかを見ていきます。各フェーズの前後にページのスナップショットを取り、ページヘッダー、ラインポインタ、タプルヘッダー、フリースペースマップ、ビジビリティマップで正確に何が変更されるかを追跡します。いつものツールを使用します:`pageinspect`、`pg_visibility`、`pg_freespacemap`。
セットアップ
比較を意味のあるものにするために十分な行を持つテーブルと、完全なVACUUMサイクルを示すためのインデックスが必要です。
```sql
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE EXTENSION IF NOT EXISTS pg_visibility;
CREATE EXTENSION IF NOT EXISTS pg_freespacemap;
CREATE TABLE vacuum_demo (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
category text NOT NULL,
payload text
);
INSERT INTO vacuum_demo (category, payload)
SELECT 'cat_' || (i % 5), repeat('x', 100)
FROM generate_series(1, 50) AS i;
```
100バイトのペイロードを持つ50行です。プライマリキーはインデックスを提供します。これは重要です:VACUUMの動作はインデックスが存在する場合に変更されます。まず、クリーンなベースラインから開始するために、VACUUMを一度実行します。
```sql
VACUUM vacuum_demo;
```
削除前のスナップショット
ページ0のベースライン状態を記録します。まずページヘッダーです。
```sql
SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('vacuum_demo', 0));
```
```
lower | upper | special | pagesize
-------+-------+---------+----------
224 | 1392 | 8192 | 8192
(1 row)
```
`pd_lower`は224にあります。これは24バイトのページヘッダーに、4バイトごとの50個のラインポインタ(24 + 200 = 224)を加えたものです。`pd_upper`は1392にあります。つまり、タプルはバイト1392から8191までを占有しています。フリースペースは1392 - 224 = 1168バイトです。あまり余裕がありません。100バイトのペイロードは積み重なります。
次にラインポインタとタプルヘッダーです。
```sql
SELECT lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('vacuum_demo', 0)) LIMIT 10;
```
```
lp | lp_flags | lp_off | lp_len | t_xmin | t_xmax | t_ctid
----+----------+--------+--------+--------+--------+--------
1 | 1 | 8056 | 135 | 746 | 0 | (0,1)
2 | 1 | 7920 | 135 | 746 | 0 | (0,2)
3 | 1 | 7784 | 135 | 746 | 0 | (0,3)
4 | 1 | 7648 | 135 | 746 | 0 | (0,4)
5 | 1 | 7512 | 135 | 746 | 0 | (0,5)
6 | 1 | 7376 | 135 | 746 | 0 | (0,6)
7 | 1 | 7240 | 135 | 746 | 0 | (0,7)
8 | 1 | 7104 | 135 | 746 | 0 | (0,8)
9 | 1 | 6968 | 135 | 746 | 0 | (0,9)
10 | 1 | 6832 | 135 | 746 | 0 | (0,10)
(10 rows)
```
`lp_len`は135で、タプルの実際のバイト長ですが、各タプルはページ上でMAXALIGNされた136バイトのスロットを占有します。`lp_off`の値が136ずつ減少していることに注意してください。このアラインされたストライドが、以下のフリースペース計算で使用されます。すべてのラインポインタは`LP_NORMAL`(`lp_flags` = 1)です。すべてのタプルには`t_xmax` = 0です。挿入されて以来、これらの行に触れた人はいません。すべての`t_ctid`はそれ自体を指しています。これは完全にクリーンなページです。
デッドタプルを作成する
ここで、いくつかの行をデッドにします。
```sql
DELETE FROM vacuum_demo WHERE id % 3 = 0;
```
```
DELETE 16
```
これにより、約3行に1行が削除されます。ID 3、6、9、12などが削除されます。16行がデッドになりました。
VACUUMが実行される前のページを見てみましょう。
```sql
SELECT lp, lp_flags, lp_off, lp_len, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('vacuum_demo', 0)) LIMIT 10;
```
```
lp | lp_flags | lp_off | lp_len | t_xmin | t_xmax | t_ctid
----+----------+--------+--------+--------+--------+--------
1 | 1 | 8056 | 135 | 746 | 0 | (0,1)
2 | 1 | 7920 | 135 | 746 | 0 | (0,2)
3 | 1 | 7784 | 135 | 746 | 747 | (0,3)
4 | 1 | 7648 | 135 | 746 | 0 | (0,4)
5 | 1 | 7512 | 135 | 746 | 0 | (0,5)
6 | 1 | 7376 | 135 | 746 | 747 | (0,6)
7 | 1 | 7240 | 135 | 746 | 0 | (0,7)
8 | 1 | 7104 | 135 | 746 | 0 | (0,8)
9 | 1 | 6968 | 135 | 746 | 747 | (0,9)
10 | 1 | 6832 | 135 | 746 | 0 | (0,10)
(10 rows)
```
行3、6、9を見てください。それらの`t_xmax`は、DELETEステートメントのトランザクションIDである747になっています。しかし、それ以外のすべては変更されていません。`lp_flags`はまだ1(`LP_NORMAL`)です。`lp_off`と`lp_len`は同じです。タプルはまだ物理的にページ上にあり、スペースを消費しています。ページヘッダーも変更されていません。
```sql
SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('vacuum_demo', 0));
```
```
lower | upper | special | pagesize
-------+-------+---------+----------
224 | 1392 | 8192 | 8192
(1 row)
```
`pd_lower`と`pd_upper`は、DELETE前のものと同じです。PostgreSQLは行をデッド(`t_xmax`にタイムスタンプを付けることによって)としてマークしましたが、1バイトのスペースも回収していません。デッドタプルはブロートであり、VACUUMが到着するまでその状態のままです。
VACUUMがテーブルを処理する方法
VACUUMを実行してページの変化を見る前に、それがどのように実行されるかについての1つのことは、スナップショットがすべてを示す理由を説明します。VACUUMは3つのパスで作業を行い、それらの間の分割は、削除されたタプルのストレージが一時的に消え、そのラインポインタが別の時点で消えるという理由全体です。そのギャップに注目してください。それは、この記事の残りの部分が明らかにするものです。
フェーズ1:ヒープスキャン - プルーニング、フリーズ、デッドTIDの収集
VACUUMは各ヒープページを順番にスキャンし、この最初のパスは単なる確認以上のことを行います。各ページについて、ページプルーニング(通常の読み取り中に機会的に発生するのと同じ`heap_page_prune_and_freeze`メカニズム)を実行します。プルーニングは、バイトが実際に戻ってくる場所です。デッドタプルのストレージを削除し、ページをデフラグメントし、`pd_upper`を進めます。また、十分古いタプルを機会的にフリーズします。PostgreSQL 17より前は、VACUUMは`maintenance_work_mem`から割り当てられたフラット配列にデッドTIDを格納していました。配列がいっぱいになると、VACUUMは一時停止し、収集されたバッチのインデックスとヒープのクリーンアップを実行してからスキャンを再開する必要がありました。PostgreSQL 17以降、VACUUMはラジックスツリーベースのTIDストアを使用しており、メモリ効率がはるかに高いため、`maintenance_work_mem`がボトルネックになる可能性は低くなっています。しかし、この記事の残りの部分が依存する微妙な点はここにあります。プルーニングは、インデックスがまだTIDで参照しているため、削除されたタプルのラインポインタを単純に`LP_UNUSED`とマークすることはできません。したがって、インデックスを持つテーブルの場合、デッドタプルのラインポインタは`LP_DEAD`に設定されます。そのストレージはなくなりますが、インデックスエントリが削除されるまで、TIDの場所を保持する4バイトのスロットはそのまま残ります。それらの`LP_DEAD`TIDは、次のフェーズのためにVACUUMがデッドTIDストアに収集するものです。
フェーズ2:インデックスクリーンアップ
デッドTIDのリストを手に入れたら、VACUUMはテーブルの各インデックスをスキャンします。各インデックスについて、すべてのインデックスエントリをウォークし、デッドTIDを指しているエントリを削除します。これがコストのかかる部分です。VACUUMは、少数のエントリしか削除する必要がない場合でも、すべてのインデックスページを読み取る必要があります。これがインデックスブロートが発生する理由でもあります。長時間実行されているトランザクションがビジビリティホライゾンを遅らせているか、テーブルに多くのインデックスがあり、デッドTIDリストがメモリを超えているためにVACUUMがこのフェーズを完了できない場合、デッドタプルを指すインデックスエントリが蓄積します。インデックスブロートの影響については、「VACUUM Is a Lie」で詳しく説明しました。
フェーズ3:ヒープクリーンアップ - ラインポインタの解放
インデックスがクリーンになった後、どのインデックスエントリもそれらのデッドTIDを参照しなくなるため、予約されたスロットは最終的に解放できます。VACUUMは、デッドタプルがあった各ページを再訪し、フェーズ1でできなかったことを行います。各ページを再訪し、デッドタプルがあった各ページを再訪し、フェーズ1でできなかったことを行います。デッドタプルのラインポインタを`LP_UNUSED`に反転させます。これにより、ページヘッダーの`pd_lower`が更新され、ページ内のフリースペースが増加します。