ストレージエンジン ── ページ・タプル・TOAST
このレイヤーの役割:データがディスク上でどう物理的に格納されているか。RDB の「骨格」。

この章で何ができるようになるか:「8KB のページ」「タプルヘッダ」「TOAST」といったストレージ概念を理解し、テーブルの物理的な肥大化の原因と対策を説明できるようになる。
ページ(Page / Block)
PostgreSQL のストレージの最小単位は 8KB のページ(MySQL InnoDB は 16KB)。
1ページ (8192 bytes) の構造:
+---------------------------+
| Page Header (24 bytes) | ← ページのメタ情報
+---------------------------+
| Item Pointers (Line Ptrs) | ← 各タプルへのオフセット配列
| [LP1][LP2][LP3]... | (上から下へ伸びる)
+---------------------------+
| |
| Free Space | ← 空き領域
| |
+---------------------------+
| Tuple 3 | ← 実際のデータ
| Tuple 2 | (下から上へ伸びる)
| Tuple 1 |
+---------------------------+
| Special Space | ← インデックスページ用の特殊領域
+---------------------------+
-- ページサイズの確認
SHOW block_size; -- 8192(デフォルト)
-- テーブルのページ数と行数の確認
SELECT
pg_relation_size('users') AS total_bytes,
pg_relation_size('users') / 8192 AS pages,
reltuples::bigint AS estimated_rows
FROM pg_class WHERE relname = 'users';
Item Pointer(Line Pointer)
各タプルへの間接参照。TID(Tuple Identifier)= (ページ番号, オフセット番号) でタプルを特定する。
なぜ間接参照が必要か:
VACUUM でタプルの物理位置が変わっても、
Item Pointer を更新するだけでインデックスのエントリを変更しなくて済む
(HOT: Heap Only Tuple 最適化で活用される)
タプル(Tuple / Row)
各行のデータ。ヘッダ + 実際のカラムデータで構成される。
タプルの構造:
+-----------------------------------+
| Tuple Header (23 bytes + padding) |
| t_xmin: このタプルを挿入した TX ID |
| t_xmax: このタプルを削除した TX ID |
| t_cid: TX 内のコマンド ID |
| t_ctid: 現在の TID(自分自身 or 更新先)|
| t_infomask: 状態ビットフラグ |
+-----------------------------------+
| NULL Bitmap | ← どのカラムが NULL か
+-----------------------------------+
| Column 1 data |
| Column 2 data |
| Column 3 data |
| ... |
+-----------------------------------+
23バイトのヘッダ:どんなに小さい行でも、ヘッダだけで最低23バイト(パディング含めて24バイト)のオーバーヘッドがある。
-- 1行あたりの実際のサイズを確認
SELECT
pg_column_size(users.*) AS row_bytes,
pg_column_size(users.name) AS name_bytes,
pg_column_size(users.email) AS email_bytes
FROM users LIMIT 5;
カラムのアライメント
PostgreSQL はカラムを型のアライメントに従って配置する:
int2 (SMALLINT): 2バイト境界
int4 (INTEGER): 4バイト境界
int8 (BIGINT): 8バイト境界
text / varchar: 1バイト境界(可変長)
カラム順序でパディングが発生する:
❌ 悪い例: (bool, bigint, bool, bigint)
→ [bool 1B][padding 7B][bigint 8B][bool 1B][padding 7B][bigint 8B] = 32B
✅ 良い例: (bigint, bigint, bool, bool)
→ [bigint 8B][bigint 8B][bool 1B][bool 1B][padding 6B] = 24B
→ 8バイト節約 / 行
実務への影響:数億行のテーブルでは、カラム順序の最適化だけで数GB のストレージを節約できる。
TOAST(The Oversized-Attribute Storage Technique)
8KB のページに収まらない大きなデータの扱い。
1行のデータが約2KB(ページの1/4)を超えると TOAST が発動:
TOAST の戦略:
1. PLAIN: 圧縮も外部格納もしない(固定長の小さい型)
2. EXTENDED: まず圧縮を試みる → それでも大きければ外部テーブルに格納(デフォルト)
3. EXTERNAL: 圧縮せず外部テーブルに格納(既に圧縮済みのデータに有効)
4. MAIN: まず圧縮を試みるが、外部格納はなるべく避ける
-- カラムの TOAST 戦略を確認
SELECT attname, attstorage
FROM pg_attribute
WHERE attrelid = 'articles'::regclass AND attnum > 0;
-- attstorage: p=PLAIN, x=EXTENDED, e=EXTERNAL, m=MAIN
-- TOAST テーブルの確認
SELECT relname, pg_size_pretty(pg_relation_size(oid))
FROM pg_class
WHERE relname LIKE 'pg_toast_%' AND relkind = 't'
ORDER BY pg_relation_size(oid) DESC
LIMIT 5;
テーブルスペースとファイルレイアウト
$PGDATA/
base/
16384/ ← データベース OID
16385 ← テーブルファイル(リレーションの OID)
16385.1 ← 1GB を超えると分割される
16385_fsm ← Free Space Map(空き領域マップ)
16385_vm ← Visibility Map(可視性マップ)
Free Space Map(FSM)
各ページの空き領域を追跡。INSERT 時に「どのページに空きがあるか」を高速に見つけるために使う。
Visibility Map(VM)
各ページの全タプルが「全トランザクションから可視」かを追跡。
用途:
1. Index Only Scan: VM でページが「全可視」なら、テーブルにアクセスせず
インデックスだけで結果を返せる
2. VACUUM: 「全可視」ページは VACUUM のスキャン対象から除外できる
→ VACUUM の高速化
fillfactor:ページの空き率を制御
-- 更新が多いテーブル: fillfactor を下げて HOT 更新の余地を残す
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
status TEXT
) WITH (fillfactor = 70);
-- 各ページの 70% だけ使用し、30% を更新用に空けておく
-- 更新がほぼないテーブル: fillfactor を上げて空間効率を最大化
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
event TEXT
) WITH (fillfactor = 100);
HOT(Heap Only Tuple)更新
インデックス付きカラムが変更されない UPDATE で、同じページ内に新しいタプルを書き込む最適化。インデックスの更新が不要になる。
通常の UPDATE:
1. 旧タプルに削除マークを付ける
2. 新しいタプルをヒープに追加
3. 全てのインデックスを更新(新 TID を登録)← コスト大
HOT UPDATE(条件: インデックス付きカラムが変更されない + 同じページに空きがある):
1. 旧タプルの t_ctid を新タプルに向ける
2. 新タプルを同じページに追加
3. インデックスの更新は不要 ← コスト大幅削減
-- HOT 更新の統計確認
SELECT
n_tup_upd AS total_updates,
n_tup_hot_upd AS hot_updates,
ROUND(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 1) AS hot_ratio_pct
FROM pg_stat_user_tables
WHERE relname = 'orders';
-- hot_ratio_pct が高いほど効率的
MySQL InnoDB との違い
| PostgreSQL | MySQL InnoDB | |
|---|---|---|
| ページサイズ | 8KB(デフォルト) | 16KB |
| 行の格納 | ヒープ(挿入順・順序なし) | クラスタードインデックス(PK 順) |
| MVCC 方式 | タプルに xmin/xmax を保持 | Undo ログで旧バージョンを復元 |
| 大きなデータ | TOAST(自動圧縮+外部格納) | ROW_FORMAT=DYNAMIC(オーバーフローページ) |
| セカンダリインデックス | TID を直接参照 | PK を参照(PK → データの二段構え) |
まとめ
| 概念 | 役割 | パフォーマンスへの影響 |
|---|---|---|
| ページ(8KB) | ストレージの最小I/O単位 | ランダム vs シーケンシャルI/O |
| タプルヘッダ(23B) | MVCC 情報・NULLビットマップ | 小さい行ほどオーバーヘッド比率が高い |
| カラムアライメント | 型のバイト境界に合わせる | カラム順序で数GB 節約可能 |
| TOAST | 大きな値の圧縮・外部格納 | JSONB・TEXT カラムの効率 |
| FSM / VM | 空きページ・可視ページの追跡 | INSERT 性能・Index Only Scan・VACUUM |
| fillfactor | ページの空き率 | HOT 更新の成功率 |