目次を表示する

RDB内部構造完全ガイド

実行エンジンとアクセスメソッド ── 計画を実行に移す

実行エンジンとアクセスメソッド ── 計画を実行に移す

このレイヤーの役割:プランナーが選んだ実行計画を実行し、結果をクライアントに返す。テーブルやインデックスへの実際のアクセスを行う。


実行エンジン — Volcanoモデル・スキャン方式・並列クエリ

この章で何ができるようになるか:EXPLAIN の出力に現れる各ノード(Seq Scan, Index Scan, Bitmap Scan, Hash Join 等)が内部で何をしているかを説明できるようになる。


Volcano モデル:実行エンジンのアーキテクチャ

PostgreSQL を含む多くの RDBMS は **Volcano モデル(Iterator モデル)**で実行エンジンを構成している。

原理:各ノードが next() メソッドを持ち、
      上位ノードが下位ノードの next() を呼んで1行ずつ取得する
      → パイプライン処理(全行をバッファしない)
# Volcano モデルの概念実装
class SeqScanNode:
    """テーブルの全行を順番に返す"""
    def __init__(self, table):
        self.table = table
        self.cursor = 0

    def next(self):
        if self.cursor >= len(self.table):
            return None
        row = self.table[self.cursor]
        self.cursor += 1
        return row

class FilterNode:
    """子ノードから行を受け取り、条件に合うものだけ返す"""
    def __init__(self, child, predicate):
        self.child = child
        self.predicate = predicate

    def next(self):
        while True:
            row = self.child.next()
            if row is None:
                return None
            if self.predicate(row):
                return row

class SortNode:
    """子ノードの全行をソートして返す(パイプラインを「壊す」)"""
    def __init__(self, child, key):
        self.child = child
        self.key = key
        self.sorted_rows = None
        self.cursor = 0

    def next(self):
        # 初回呼び出し時に全行を取得してソート(ブロッキング操作)
        if self.sorted_rows is None:
            rows = []
            while (row := self.child.next()) is not None:
                rows.append(row)
            self.sorted_rows = sorted(rows, key=self.key)

        if self.cursor >= len(self.sorted_rows):
            return None
        row = self.sorted_rows[self.cursor]
        self.cursor += 1
        return row

# 実行計画の組み立て
# SELECT * FROM users WHERE age > 30 ORDER BY name;
plan = SortNode(
    child=FilterNode(
        child=SeqScanNode(users_table),
        predicate=lambda row: row['age'] > 30
    ),
    key=lambda row: row['name']
)

# 結果の取得
while (row := plan.next()) is not None:
    print(row)

スキャン方式の詳細

Seq Scan(Sequential Scan)

テーブルの全ページを先頭から順番に読む。

EXPLAIN SELECT * FROM users WHERE status = 'inactive';
-- Seq Scan on users  (cost=0.00..20000.00 rows=100 width=64)
--   Filter: (status = 'inactive'::text)
--   Rows Removed by Filter: 999900
なぜ Seq Scan が選ばれるか:
  1. インデックスがない
  2. 選択率が高い(テーブルの大部分を返す)
  3. テーブルが小さい(全部読んでも速い)
  4. random_page_cost が高い設定(HDD)

Index Scan

B-Tree インデックスを辿り、該当するタプルを直接取得する。

EXPLAIN SELECT * FROM users WHERE id = 12345;
-- Index Scan using users_pkey on users  (cost=0.43..8.45 rows=1 width=64)
--   Index Cond: (id = 12345)
動作:
  1. B-Tree のルートから葉ノードへ辿る(O(log n))
  2. 葉ノードに記録された TID(ページ番号, オフセット)でテーブルを直接参照
  3. 該当するタプルを返す

コスト:
  インデックスページの読み込み: O(log n) × random_page_cost
  テーブルページの読み込み: rows × random_page_cost(最悪ケース)

Index Only Scan

インデックスだけで結果が返せる場合(テーブルへのアクセスが不要)。

-- covering index: クエリが必要とするカラムが全てインデックスに含まれている
CREATE INDEX idx_users_email ON users(email);

EXPLAIN SELECT email FROM users WHERE email = '[email protected]';
-- Index Only Scan using idx_users_email on users  (cost=0.43..4.45 rows=1 width=32)
--   Index Cond: (email = '[email protected]'::text)

Visibility Map:Index Only Scan が機能するには、ページ内の全タプルが「全トランザクションから見える」ことが保証されている必要がある。VACUUM が Visibility Map を更新する。

Bitmap Index Scan + Bitmap Heap Scan

Index Scan と Seq Scan の中間。複数行を返すが Seq Scan ほど多くない場合に選ばれる。

EXPLAIN SELECT * FROM users WHERE age BETWEEN 20 AND 25;
-- Bitmap Heap Scan on users  (cost=50.00..500.00 rows=5000 width=64)
--   Recheck Cond: ((age >= 20) AND (age <= 25))
--   ->  Bitmap Index Scan on idx_users_age  (cost=0.00..48.75 rows=5000 width=0)
--         Index Cond: ((age >= 20) AND (age <= 25))
動作:
  Phase 1: Bitmap Index Scan
    インデックスを走査して、該当する TID を「ビットマップ」に記録
    ビットマップ: [ページ1: ○, ページ2: ×, ページ3: ○, ...]

  Phase 2: Bitmap Heap Scan
    ビットマップに印がついたページだけを順番に読む
    → ランダムアクセスを減らし、シーケンシャルに近い読み方ができる

利点:
  - Index Scan のランダムアクセスを軽減
  - 複数のインデックスを OR / AND で組み合わせられる(BitmapOr / BitmapAnd)
-- 2つのインデックスの AND 結合
EXPLAIN SELECT * FROM users WHERE age > 30 AND city = 'Tokyo';
-- Bitmap Heap Scan on users
--   Recheck Cond: ((age > 30) AND (city = 'Tokyo'))
--   ->  BitmapAnd
--         ->  Bitmap Index Scan on idx_users_age
--         ->  Bitmap Index Scan on idx_users_city

集約(Aggregate)の実行方式

HashAggregate

EXPLAIN SELECT city, COUNT(*) FROM users GROUP BY city;
-- HashAggregate  (cost=200.00..210.00 rows=100 width=40)
--   Group Key: city
--   ->  Seq Scan on users  (cost=0.00..150.00 rows=10000 width=32)
動作:
  ハッシュテーブルを構築: {city → (count)}
  各行を読みながらハッシュテーブルを更新
  → 全行を読み終えたら結果を出力

メモリ: work_mem に収まる必要がある(超えるとディスクへスピル)

GroupAggregate

-- 既にソート済みの入力に対して
-- GroupAggregate  (cost=...)
--   Group Key: city
--   ->  Index Scan using idx_users_city on users

動作:
  入力がソート済みなので、同じキーの行が連続する
  → グループの切れ目を検知しながら集約
  → ハッシュテーブル不要(メモリ効率が良い)

パラレルクエリ

PostgreSQL 9.6 以降、大きなテーブルへのクエリを複数のワーカーで並列実行できる。

EXPLAIN SELECT COUNT(*) FROM large_table WHERE condition;
-- Finalize Aggregate  (cost=...)
--   ->  Gather  (cost=...)
--         Workers Planned: 4
--         ->  Partial Aggregate  (cost=...)
--               ->  Parallel Seq Scan on large_table  (cost=...)
--                     Filter: condition
動作:
  1. Gather ノードが N 個のワーカーを起動
  2. テーブルを N 分割し、各ワーカーが Partial Aggregate を計算
  3. Gather が各ワーカーの結果を集めて Finalize Aggregate
-- パラレル関連の設定
SET max_parallel_workers_per_gather = 4;  -- クエリあたり最大4ワーカー
SET min_parallel_table_scan_size = '8MB'; -- 8MB 以上のテーブルで並列化
SET parallel_tuple_cost = 0.1;            -- 並列化のコストペナルティ

まとめ

スキャン方式向いているケースコスト特性
Seq Scan大部分を返す / テーブルが小さい全ページ読み込み
Index Scan少数行を特定する(高選択性)O(log n) + ランダムI/O
Index Only Scan必要カラムがインデックス内テーブル参照不要
Bitmap Scan中程度の選択性 / 複数条件の組み合わせランダムI/O を軽減
JOIN 方式向いているケース
Nested Loop外側が小さい + 内側にインデックス
Hash Join両テーブルが大きい + 等価条件
Merge Join入力がソート済み