PostgreSQL プランナー深掘りガイド
プランナー(クエリオプティマイザ)は、SQLを「どのように実行するか」を決定するPostgreSQLの頭脳です。このドキュメントでは、プランナーの内部動作を詳しく解説します。
目次
- プランナーの役割と全体像
- 統計情報の仕組み
- コスト計算モデル
- スキャン方式の選択
- 結合アルゴリズムの選択
- 結合順序の最適化
- 実行計画の読み方
- プランナーの制御とチューニング
- プランナーが苦手なケース
- 実践的なトラブルシューティング
1. プランナーの役割と全体像
1.1 プランナーとは
プラン ナーは、クエリ木(Query Tree)を受け取り、最適な実行計画(Plan Tree)を生成するコンポーネントです。
┌──────────────────────────────────────────────────────────────┐
│ プランナーの位置づけ │
├──────────────────────────────────────────────────────────────┤
│ │
│ SQL文 │
│ │ │
│ ▼ │
│ Parser (構文解析) │
│ │ │
│ ▼ │
│ Analyzer (意味解析) │
│ │ │
│ ▼ │
│ Rewriter (書き換え) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Planner │ │
│ │ │ │
│ │ 入力: Query Tree (何を取得するか) │ │
│ │ 出力: Plan Tree (どうやって取得するか) │ │
│ │ │ │
│ │ 「同じ結果を得る複数の方法」から最適なものを選ぶ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Executor (実行) │
│ │
└──────────────────────────────────────────────────────────────┘
1.2 プランナーの処理フロー
┌──────────────────────────────────────────────────────────────┐
│ プランナー内部の処理フロー │
├──────────────────────────────────────────────────────────────┤
│ │
│ Query Tree │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ 1. 前処理 (Preprocessing) │ │
│ │ - サブクエリの平坦化 │ │
│ │ - 定数の畳み込み │ │
│ │ - WHERE句の正規化 │ │
│ └────────────────────┬───────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ 2. スキャンパスの生成 │ │
│ │ - 各テーブルのアクセス方法を列挙 │ │
│ │ - Sequential Scan, Index Scan等 │ │
│ └────────────────────┬───────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ 3. 結合パスの生成 │ │
│ │ - テーブルの結合方法を列挙 │ │
│ │ - Nested Loop, Hash Join等 │ │
│ └────────────────────┬───────────────────┘ │
│ ▼ │
│ ┌─────────────────────────── ─────────────┐ │
│ │ 4. 最適パスの選択 │ │
│ │ - コストが最小のパスを選択 │ │
│ └────────────────────┬───────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ 5. Plan Tree の生成 │ │
│ │ - 選択したパスを実行計画に変換 │ │
│ └────────────────────┬───────────────────┘ │
│ ▼ │
│ Plan Tree │
│ │
└──────────────────────────────────────────────────────────────┘
1.3 「パス」と「プラン」
プランナー内部では、まず複数の「パス (Path)」を生成し、最終的に最適なパスを「プラン (Plan)」に変換します。
【パス (Path)】
- 実行方法の候補
- コスト見積もりを持つ
- 複数のパスが並行して検討される
【プラン (Plan)】
- 最終的に採用された実行計画
- Executorが実際に実行する
2. 統計情報の仕組み
プランナーが適切な判断をするためには、テーブルの統計情報が不可欠です。
2.1 統計情報とは
┌──────────────────────────────────────────────────────────────┐
│ 統計情報の種類 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【テーブルレベル】pg_class │
│ ├─ reltuples: 推定行数 │
│ ├─ relpages: ページ数(8KBブロック数) │
│ └─ relallvisible: 全可視ページ数 │
│ │
│ 【カラムレベル】pg_statistic / pg_stats │
│ ├─ null_frac: NULL値の割合 │
│ ├─ n_distinct: ユニーク値の数(または割合) │
│ ├─ most_common_vals: 最頻値リスト │
│ ├─ most_common_freqs: 最頻値の出現頻度 │
│ ├─ histogram_bounds: ヒストグラム境界値 │
│ └─ correlation: 物理順序との相関 │
│ │
└──────────────────────────────────────────────────────────────┘
2.2 統計情報の確認方法
-- テーブルの基本統計
SELECT
relname,
reltuples::bigint AS estimated_rows,
relpages AS pages
FROM pg_class
WHERE relname = 'users';
-- カラムの詳細統計
SELECT
attname,
null_frac,
n_distinct,
most_common_vals,
most_common_freqs,
histogram_bounds
FROM pg_stats
WHERE tablename = 'users' AND attname = 'status';
2.3 n_distinct の解釈
┌──────────────────────────────────────────────────────────────┐
│ n_distinct の意味 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 正の値: ユニーク値の絶対数 │
│ 例: n_distinct = 5 → 5種類の値がある │
│ │
│ 負の値: 行数に対する割合(絶対値) │
│ 例: n_distinct = -0.5 → ユニーク値は行数の50% │
│ 10000行なら約5000種類 │
│ │
│ -1: すべての値がユニーク(主キーなど) │
│ │
└──────────────────────────────────────────────────────────────┘
2.4 ヒストグラム
ヒストグラムは値の分布を表し、範囲検索の選択性を推定するのに使われます。
┌──────────────────────────────────────────────────────────────┐
│ ヒストグラムの例 │
├──────────────────────────────────────────────────────────────┤
│ │
│ histogram_bounds = {0, 20, 40, 60, 80, 100} │
│ │
│ これは値を5つの等頻度バケットに分割: │
│ [0-20), [20-40), [40-60), [60-80), [80-100] │
│ 各バケットには約20%のデータが含まれる │
│ │
│ WHERE age BETWEEN 30 AND 50 の選択性推定: │
│ - [20-40) の一部 + [40-60) の一部 │
│ - 約 (10/20 × 20%) + (10/20 × 20%) = 20% │
│ │
│ 頻度 │
│ ▲ │
│ │ ████ ████ ████ ████ ████ │
│ │ ████ ████ ████ ████ ████ │
│ └──────────────────────────────────▶ 値 │
│ 0 20 40 60 80 100 │
│ │
└──────────────────────────────────────────────────────────────┘
2.5 相関 (correlation)
物理的な行の並び順と、カラム値の論理的な順序の相関を示します。
┌──────────────────────────────────────────────────────────────┐
│ correlation の意味 │
├──────────────────────────────────────────────────────────────┤
│ │
│ correlation = 1.0: 完全に順序通り │
│ 物理順: 行1, 行2, 行3, 行4, 行5 │
│ 値: 10, 20, 30, 40, 50 │
│ → Index Scan が効率的(連続読み取り) │
│ │
│ correlation = 0.0: ランダム │
│ 物理順: 行1, 行2, 行3, 行4, 行5 │
│ 値: 30, 10, 50, 20, 40 │
│ → Index Scan はランダムI/Oが多発 │
│ │
│ correlation = -1.0: 完全に逆順 │
│ 物理順: 行1, 行2, 行3, 行4, 行5 │
│ 値: 50, 40, 30, 20, 10 │
│ │
│ 【Index Scan のコストに影響】 │
│ correlation が低い → ランダムI/O → コスト高 │
│ correlation が高い → シーケンシャルI/O → コスト低 │
│ │
└──────────────────────────────────────────────────────────────┘
2.6 統計情報の更新
-- 特定テーブルの統計情報を更新
ANALYZE users;
-- 全テーブルの統計情報を更新
ANALYZE;
-- 統計情報の精度を上げる(サンプル数を増やす)
ALTER TABLE users ALTER COLUMN status SET STATISTICS 1000;
ANALYZE users;
-- デフォルトは 100、最大 10000
2.7 拡張統計情報
PostgreSQL 10以降では、複数カラム間の相関を記録できます。
-- 複数カラムの相関統計を作成
CREATE STATISTICS stats_city_zip ON city, zip_code FROM addresses;
ANALYZE addresses;
-- 確認
SELECT * FROM pg_statistic_ext WHERE stxname = 'stats_city_zip';
┌──────────────────────────────────────────────────────────────┐
│ 拡張統計が必要なケース │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【問題】カラム間に相関があるのにプランナーが認識できない │
│ │
│ 例: addresses テーブル │
│ city = '東京都' AND zip_code LIKE '1%' │
│ │
│ 通常の統計: │
│ city = '東京都' の選択性: 10% │
│ zip_code LIKE '1%' の選択性: 10% │
│ 推定選択性: 10% × 10% = 1% ← 独立と仮定 │
│ │
│ 実際: │
│ 東京都の郵便番号は1で始まる → 実際は10% │
│ │
│ 拡張統計を使うと正確に推定できる │
│ │
└──────────────────────────────────────────────────────────────┘
3. コスト計算モデル
3.1 コストとは
プランナーの「コスト」は、実行にかかるリソースの相対的な見積もりです。
┌──────────────────────────────────────────────────────────────┐
│ コストの構成要素 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 総コスト = I/Oコスト + CPUコスト │
│ │
│ 【I/Oコスト】 │
│ ├─ シーケンシャル読み取り (seq_page_cost = 1.0) │
│ └─ ランダム読み取り (random_page_cost = 4.0) │
│ │
│ 【CPUコスト】 │
│ ├─ 行の処理 (cpu_tuple_cost = 0.01) │
│ ├─ インデックスエントリの処理 (cpu_index_tuple_cost = 0.005)│
│ └─ 演算子/関数の実行 (cpu_operator_cost = 0.0025) │
│ │
│ ※ 値はデフォルト。SSDならrandom_page_costを下げる等の調整可 │
│ │
└──────────────────────────────────────────────────────────────┘
3.2 EXPLAINで見るコスト
EXPLAIN SELECT * FROM users WHERE id = 100;
Index Scan using users_pkey on users (cost=0.29..8.30 rows=1 width=100)
~~~~~ ~~~~~
│ │
startup cost total cost
┌──────────────────────────────────────────────────────────────┐
│ コストの読み方 │
├──────────────────────────────────────────────────────────────┤
│ │
│ cost=0.29..8.30 │
│ ~~~~ ~~~~ │
│ │ └─ total cost: 全行を取得するまでの総コスト │
│ │ │
│ └─ startup cost: 最初の行を返すまでのコスト │
│ (ソートや集約では大きくなる) │
│ │
│ rows=1 : 推定結果行数 │
│ width=100 : 1行あたりの推定バイト数 │
│ │
└──────────────────────────────────────────────────────────────┘
3.3 Sequential Scan のコスト計算
┌──────────────────────────────────────────────────────────────┐
│ Sequential Scan のコスト計算式 │
├──────────────────────────────────────────────────────────────┤
│ │
│ total_cost = seq_page_cost × ページ数 │
│ + cpu_tuple_cost × 行数 │
│ + cpu_operator_cost × 行数 × WHERE条件数 │
│ │
│ 【例】users テーブル: 10000行, 500ページ │
│ WHERE status = 'active' │
│ │
│ I/Oコスト = 1.0 × 500 = 500 │
│ CPUコスト = 0.01 × 10000 + 0.0025 × 10000 = 125 │
│ 総コスト = 625 │
│ │
└─────────────────────────────────────────────────────────── ───┘
3.4 Index Scan のコスト計算
┌──────────────────────────────────────────────────────────────┐
│ Index Scan のコスト計算式 │
├──────────────────────────────────────────────────────────────┤
│ │
│ total_cost = インデックス探索コスト │
│ + random_page_cost × 読み取りページ数 │
│ + cpu_index_tuple_cost × インデックス行数 │
│ + cpu_tuple_cost × テーブル行数 │
│ │
│ 【重要】correlation が考慮される │
│ │
│ correlation高い → ページ読み取りがシーケンシャルに近い │
│ → コストが下がる │
│ │
│ correlation低い → ランダムI/Oが多い │
│ → コストが上がる │
│ │
│ 【例】users.id にインデックス、id = 100 を検索 │
│ 推定1行、インデックス深さ3、correlation = 1.0 │
│ │
│ インデックス探索 = random_page_cost × 3 = 12 │
│ テーブル読み取り = random_page_cost × 1 = 4 │
│ CPU処理 = 約 0.03 │
│ 総コスト ≈ 16 │
│ │
└──────────────────────────────────────────────────────────────┘
3.5 コスト比較の例
-- 10000行のusersテーブル、statusカラムに5種類の値
-- 1. 選択性が高い場合 (1行のみ)
EXPLAIN SELECT * FROM users WHERE id = 100;
-- → Index Scan (cost ≈ 8)
-- 2. 選択性が中程度 (2000行 = 20%)
EXPLAIN SELECT * FROM users WHERE status = 'active';
-- → Index Scan または Bitmap Scan (cost比較で決定)
-- 3. 選択性が低い (8000行 = 80%)
EXPLAIN SELECT * FROM users WHERE status != 'deleted';
-- → Sequential Scan (cost ≈ 625)
-- インデックスを使うより全件スキャンの方が効率的
3.6 コストパラメータのチューニング
-- SSDを使用している場合(ランダム読み取りが速い)
SET random_page_cost = 1.1; -- デフォルト 4.0
-- メモリが潤沢でデータがキャッシュされている場合
SET effective_cache_size = '8GB'; -- OSキャッシュも含めた推定
-- 確認
SHOW random_page_cost;
SHOW seq_page_cost;
4. スキャン方式の選択
4.1 スキャン方式の一覧
┌──────────────────────────────────────────────────────────────┐
│ スキャン方式の種類 │
├────────────────────────────────────────────────── ────────────┤
│ │
│ ┌─────────────────┐ │
│ │ Sequential Scan │ テーブル全体を先頭から順に読む │
│ └─────────────────┘ │
│ ↓ 選択性が低い場合に有利 │
│ │
│ ┌─────────────────┐ │
│ │ Index Scan │ インデックス→テーブルの順でアクセス │
│ └─────────────────┘ │
│ ↓ 選択性が高い場合に有利 │
│ │
│ ┌─────────────────┐ │
│ │Index Only Scan │ インデックスだけで完結(テーブル不要) │
│ └─────────────────┘ │
│ ↓ SELECT対象がインデックスに含まれる場合 │
│ │
│ ┌─────────────────┐ │
│ │ Bitmap Scan │ 複数条件をビットマップで効率化 │
│ └─────────────────┘ │
│ ↓ 中程度の選択性、複数インデックス │
│ │
│ ┌─────────────────┐ │
│ │ TID Scan │ 直接行のTIDを指定してアクセス │
│ └─────────────────┘ │
│ ↓ WHERE ctid = '(0,1)' など特殊ケース │
│ │
└──────────────────────────────────────────────────────────────┘
4.2 Sequential Scan
┌──────────────────────────────────────────────────────────────┐
│ Sequential Scan │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【動作】 │
│ テーブルの全ページを先頭から順番に読み、各行をチェック │
│ │
│ Page 0 → Page 1 → Page 2 → ... → Page N │
│ ↓ ↓ ↓ ↓ │
│ 各行をWHERE条件でフィルタリング │
│ │
│ 【選ばれる条件】 │
│ - 取得行が全体の大部分を占める │
│ - 適切なインデックスがない │
│ - テーブルが小さい │
│ - correlation が低くIndex Scanが非効率 │
│ │
│ 【パラレル実行】 │
│ 大きなテーブルでは複数ワーカーで並列実行可能 │
│ → Parallel Sequential Scan │
│ │
└───────────────────────────────────────────────────── ─────────┘
4.3 Index Scan
┌──────────────────────────────────────────────────────────────┐
│ Index Scan │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【動作】 │
│ │
│ Step 1: インデックスを探索して条件に合うエントリを見つける │
│ │
│ B-tree Index │
│ │ │
│ ▼ │
│ [Root] → [Internal] → [Leaf] │
│ │ │
│ TID (0, 5) ← 行の物理位置 │
│ │
│ Step 2: TIDを使ってテーブルから行を取得 │
│ │
│ Table │
│ Page 0: [row1][row2][row3]... │
│ ↑ │
│ ここを読む │
│ │
│ 【選ばれる条件】 │
│ - 選択性が高い(少数の行を取得) │
│ - ORDER BYがインデックス順と一致 │
│ - LIMITと組み合わせ │
│ │
└──────────────────────────────────────────────────────── ──────┘
4.4 Index Only Scan
┌──────────────────────────────────────────────────────────────┐
│ Index Only Scan │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【動作】 │
│ インデックスだけで必要なデータが揃う場合、テーブルを読まない │
│ │
│ CREATE INDEX idx_users_email ON users(email); │
│ SELECT email FROM users WHERE email LIKE 'a%'; │
│ │
│ Index (email) │
│ ┌──────────────────────┐ │
│ │ alice@example.com │ ← これだけで回答可能 │
│ │ anna@example.com │ │
│ │ ... │ │
│ └──────────────────────┘ │
│ ↓ │
│ テーブルアクセス不要! │
│ │
│ 【条件】 │
│ 1. SELECTするカラムが全てインデックスに含まれる │
│ 2. Visibility Map で行が「全可視」と分かっている │
│ (VACUUMが適切に実行されている必要あり) │
│ │
│ 【カバリングインデックス】 │
│ CREATE INDEX idx_covering ON users(email) INCLUDE (name); │
│ → SELECT email, name FROM users WHERE email = '...' │
│ もIndex Only Scanにできる │
│ │
└──────────────────────────────────────────────────────────────┘
4.5 Bitmap Scan
┌──────────────────────────────────────────────────────────────┐
│ Bitmap Scan │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【動作】3段階のプロセス │
│ │
│ ① Bitmap Index Scan: インデックスから該当ページをビットマップ化│
│ │
│ Index Bitmap │
│ ┌────────┐ ┌───────────────────┐ │
│ │id = 5 │──→ │ Page 0: 1 │ │
│ │id = 12 │──→ │ Page 1: 0 │ │
│ │id = 15 │──→ │ Page 2: 1 │ │
│ │id = 23 │──→ │ Page 3: 1 │ │
│ └────────┘ └───────────────────┘ │
│ │
│ ② BitmapAnd / BitmapOr: 複数条件のビットマップを結合 │
│ │
│ WHERE status='active' AND category='A' │
│ │
│ Bitmap(status) Bitmap(category) Result │
│ [1,0,1,1,0] AND [1,1,0,1,0] = [1,0,0,1,0] │
│ │
│ ③ Bitmap Heap Scan: ビットマップに基づいてテーブルを読む │