目次を表示する

RDB内部構造完全ガイド

パーサーとプランナー ── SQL が実行計画になるまで

パーサーとプランナー ── SQL が実行計画になるまで

このレイヤーの役割:SQL テキストを構文解析し、最適な実行計画を選択する。RDB の「頭脳」。


パーサーとプランナー — SQL→構文木→実行計画

この章で何ができるようになるか:SQL が「テキスト → 構文木 → 実行計画」と変換される過程を理解し、プランナーが「なぜこの実行計画を選んだか」を推測できるようになる。


フェーズ1:パーサー(Parser)

SQL テキストを**構文木(Parse Tree)**に変換する。プログラミング言語のコンパイラのフロントエンドと同じ。

SELECT name FROM users WHERE age > 30;
構文木:
  SelectStmt
  ├── targetList: [ColumnRef "name"]
  ├── fromClause: [RangeVar "users"]
  └── whereClause:
      └── A_Expr (>)
          ├── left: ColumnRef "age"
          └── right: Const 30

パーサーが検出するエラー:構文エラーのみ。テーブルやカラムの存在チェックはまだ行わない。

-- パーサーが弾く(構文エラー)
SELCT name FORM users;

-- パーサーは通す(構文は正しい)が、アナライザーで弾かれる
SELECT name FROM nonexistent_table;

フェーズ2:アナライザー(Analyzer)

構文木の意味を解析する。テーブルの存在確認、カラムの型チェック、暗黙の型変換を行う。

SELECT name FROM users WHERE age > '30';
-- '30' は TEXT 型だが、age が INTEGER なら暗黙的に INTEGER にキャスト
-- → アナライザーが型変換ノードを挿入

フェーズ3:リライター(Rewriter)

ビューの展開やルールの適用を行う。

-- ビュー定義
CREATE VIEW active_users AS
  SELECT * FROM users WHERE status = 'active';

-- ユーザーのクエリ
SELECT name FROM active_users WHERE age > 30;

-- リライト後(ビューが展開される)
SELECT name FROM users WHERE status = 'active' AND age > 30;

フェーズ4:プランナー / オプティマイザ(Planner)

最も重要なフェーズ。クエリツリーから**実行計画(Plan Tree)**を生成する。複数の計画候補を生成し、コスト推定でベストなものを選ぶ。

コストモデル

PostgreSQL のプランナーは各操作にコストを割り当てる。

主要なコスト定数(postgresql.conf):
  seq_page_cost = 1.0       # シーケンシャルページ読み込み(基準)
  random_page_cost = 4.0    # ランダムページ読み込み(シーケンシャルの4倍)
  cpu_tuple_cost = 0.01     # 1タプルの処理コスト
  cpu_index_tuple_cost = 0.005  # インデックスタプルの処理コスト
  cpu_operator_cost = 0.0025    # 演算子の適用コスト

SSD の場合: random_page_cost を 1.1〜1.5 に下げるのが推奨
(SSD はランダムアクセスが HDD ほど遅くない)

統計情報:プランナーの判断材料

プランナーはテーブルの統計情報を使ってコストを推定する。

-- 統計情報の確認
SELECT
  relname,
  reltuples AS estimated_rows,
  relpages AS pages_on_disk
FROM pg_class
WHERE relname = 'users';

-- カラムの統計情報
SELECT
  attname,
  n_distinct,     -- ユニーク値の推定数
  most_common_vals,  -- 最頻出値
  most_common_freqs, -- 最頻出値の出現頻度
  correlation     -- 物理的な並び順との相関(1.0 に近いほど良い)
FROM pg_stats
WHERE tablename = 'users' AND attname = 'status';

ANALYZE コマンドが統計情報を更新する。統計情報が古いとプランナーが誤った計画を選ぶ。

ANALYZE users;  -- users テーブルの統計情報を更新

実行計画の選択例

SELECT * FROM users WHERE age = 25;

プランナーが検討する候補:

候補1: Seq Scan(全件スキャン)
  コスト = ページ数 × seq_page_cost + 行数 × cpu_tuple_cost
  例: 10000ページ × 1.0 + 1000000行 × 0.01 = 20000

候補2: Index Scan(age にインデックスがある場合)
  コスト = インデックスページ読み込み + データページランダム読み込み + CPU
  例: log(1000000) × random_page_cost + 100行 × random_page_cost + ...
  = 24 + 400 + ... ≈ 500

→ 候補2の方がコストが低い → Index Scan を選択

選択率(Selectivity)がプランナーの判断を左右する

-- 選択率が低い(結果が少ない)→ インデックスが有利
SELECT * FROM users WHERE id = 12345;
-- 選択率 ≈ 1/1000000 = 0.0001%

-- 選択率が高い(結果が多い)→ Seq Scan が有利
SELECT * FROM users WHERE status = 'active';
-- もし 80% が active なら、インデックスで 80万行をランダムアクセスするより
-- Seq Scan で全件読んだ方が速い

経験則:テーブルの 5〜10% 以上の行を返すクエリでは、Seq Scan の方が速いことが多い。


JOIN の実行戦略

SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.country = 'JP';

Nested Loop Join

for each row in users (外側):
    for each row in orders (内側):
        if users.id == orders.user_id:
            output(row)

計算量: O(N × M)
向いている: 外側テーブルが小さい場合(内側はインデックスで高速化可能)

Hash Join

1. 小さい方のテーブル(users)でハッシュテーブルを構築
   hash_table[user_id] = user_row

2. 大きい方のテーブル(orders)を走査
   for each order:
       user = hash_table[order.user_id]
       output(user, order)

計算量: O(N + M)
向いている: 両テーブルが大きく、等価条件の JOIN
メモリ: ハッシュテーブルが work_mem に収まる必要がある

Merge Join

1. 両テーブルを JOIN キーでソート
2. 2つのポインタを同時に進めながらマッチング

計算量: O(N log N + M log M)(ソート込み)
向いている: 両テーブルが大きく、既にソート済み(インデックスの順序)

JOIN 戦略の選択基準

戦略向いているケース
Nested Loop外側が小さい + 内側にインデックス
Hash Join両テーブルが大きい + 等価条件
Merge Join両テーブルがソート済み + 大規模

PostgreSQL でプランナーの動作を確認する

-- 実行計画の表示
EXPLAIN SELECT * FROM users WHERE age = 25;

-- 結果例:
-- Index Scan using idx_users_age on users  (cost=0.43..8.45 rows=1 width=64)
--   Index Cond: (age = 25)

-- cost=0.43..8.45:
--   0.43 = 最初の行を返すまでのコスト(startup cost)
--   8.45 = 全行を返すまでのコスト(total cost)
-- rows=1: 推定される返却行数
-- width=64: 1行あたりの推定バイト数
-- JOIN の実行計画
EXPLAIN SELECT u.name, o.total
FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.country = 'JP';

-- 結果例:
-- Hash Join  (cost=125.00..1234.56 rows=500 width=40)
--   Hash Cond: (o.user_id = u.id)
--   ->  Seq Scan on orders o  (cost=0.00..800.00 rows=50000 width=12)
--   ->  Hash  (cost=100.00..100.00 rows=1000 width=32)
--         ->  Seq Scan on users u  (cost=0.00..100.00 rows=1000 width=32)
--               Filter: (country = 'JP'::text)

プランナーが「間違える」ケース

1. 統計情報が古い
   → ANALYZE を定期実行(autovacuum が自動で行うが、大量 INSERT 直後は手動で)

2. 相関のあるカラムの組み合わせ
   → WHERE city = 'Tokyo' AND country = 'JP' の行数を過小評価
   → 独立と仮定して 0.01 × 0.01 = 0.0001 と推定するが、
     実際は Tokyo なら必ず JP(相関が 1.0)
   → PostgreSQL 10+ の拡張統計で対応可能

3. パラメータ化クエリの汎用計画
   → プリペアドステートメントで generic plan が選ばれると、
     パラメータの値に関わらず同じ計画が使われる
-- 拡張統計の作成(相関カラム用)
CREATE STATISTICS stats_city_country (dependencies)
ON city, country FROM users;

ANALYZE users;

まとめ

フェーズ入力出力主な処理
パーサーSQL テキスト構文木構文チェック
アナライザー構文木クエリツリー型チェック、テーブル検証
リライタークエリツリー書き換え済みツリービュー展開、ルール適用
プランナークエリツリー実行計画コスト推定、最適計画選択

プランナーの「コスト推定」の品質が RDB のパフォーマンスを大きく左右する。統計情報を新鮮に保つことが、最も基本的なチューニングだ。