目次を表示する

RDB内部構造完全ガイド

ストレージエンジン ── ページ・タプル・TOAST

ストレージエンジン ── ページ・タプル・TOAST

このレイヤーの役割:データがディスク上でどう物理的に格納されているか。RDB の「骨格」。


ストレージエンジン — ページ・タプル・TOAST・HOT

この章で何ができるようになるか:「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 との違い

PostgreSQLMySQL 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 更新の成功率