第8回|実行計画を読む(虫眼鏡)

クエリが遅いのは「なんとなく」ではない。EXPLAIN で計画を開き、統計とコストから遅さの理由を言葉にする回。

この回のねらい

同じ結果を返すクエリでも、PostgreSQL が内部でどう処理するかによって速度は桁で変わる。その「どう処理するか」を決めるのがプランナ(計画立案器)であり、決めた結果が実行計画(プラン)である。この回では EXPLAIN (ANALYZE, BUFFERS) の出力を1行ずつ読み、遅さの原因を統計情報とコストの言葉で説明できるようになる。これは本講座の卒業要件「遅いクエリの原因を計画から特定し、改善して before/after を示す」の中核である。

到達目標

前提と準備

第1回で作った PostShop スキーマ(orders 約100万行、order_items 約300万行ほか)が投入済みであることを前提とする。挙動は PostgreSQL 16 系を前提に記述する。

計画は統計情報に基づいて立てられるため、まず統計を最新化しておく。

-- 全テーブルの統計を収集(プランナの見積もりの土台)
VACUUM ANALYZE;

EXPLAIN の主なオプションは次の3つを押さえておけばよい。

オプション意味注意
EXPLAIN(素)計画と見積もりだけを表示。クエリは実行しない速いが実測は出ない
EXPLAIN (ANALYZE)実際に実行して実測(time・rows・loops)も出す本当に実行される
EXPLAIN (ANALYZE, BUFFERS)上に加えブロック単位の I/O(Buffers)を出す遅さの診断はこれを基本にする

ANALYZE を付けるとクエリは実際に実行される。SELECT は無害だが、UPDATE / DELETE / INSERT を計測するときは副作用が残る。トランザクションで囲んで巻き戻すこと。

BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'paid' WHERE id = 1;
ROLLBACK;   -- 計測はできるが変更は捨てる

I/O の実時間も見たいときは、セッションで次を有効にすると Buffers に I/O Timings が加わる。

SET track_io_timing = on;

この回で新しく出てくる用語

計画を読むときに出てくる、スキャン方式・結合方式以外の語をここで押さえる。方式名そのものは本編の表で扱う。回をまたいで使う語は用語集にもまとめている。

用語一行での説明
プランナ受け取ったSQLに対し、複数の実行方法をコストで比較して1つ選ぶ内部コンポーネント
実行計画プランナが選んだ「どう処理するか」の木構造。EXPLAIN が表示するもの
選択率条件を満たす行が全体に占める割合。読む行数の見積もりの基礎になる
統計情報列の値の分布をサンプリングして蓄えたデータ。見積もりの根拠になる
ヒストグラム値をほぼ等頻度に区切った境界の列。範囲条件の選択率を見積もるのに使う
拡張統計複数列の相関をプランナに教えるために、明示的に作成する統計情報
共有バッファディスクのページを載せておく、PostgreSQLの共有メモリ上のキャッシュ
work_mem1回のソートやハッシュ表の作成に使ってよいメモリ量の上限を決める設定
VACUUM不要になった古い行の領域を回収する保守処理(第12回)

本編

EXPLAIN の基本: 何が表示されるか

次のクエリは「2025-06-01 の注文件数」を数える。ただし ordered_at に date_trunc を適用しているのが後で効いてくる。

EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM orders
WHERE date_trunc('day', ordered_at) = DATE '2025-06-01';

出力イメージ(実測値は環境依存。例:)。

 Aggregate  (cost=20834.00..20834.01 rows=1 width=8) (actual time=182.334..182.335 rows=1 loops=1)
   Buffers: shared hit=64 read=8306
   ->  Seq Scan on orders  (cost=0.00..20821.00 rows=5000 width=0) (actual time=0.045..181.002 rows=1370 loops=1)
         Filter: (date_trunc('day'::text, ordered_at) = '2025-06-01 00:00:00+09'::timestamp with time zone)
         Rows Removed by Filter: 998630
         Buffers: shared hit=64 read=8306
 Planning Time: 0.120 ms
 Execution Time: 182.360 ms

各要素の読み方は次のとおり。

ここで Seq Scan の見積もり rows=5000 と実測 rows=1370 がずれている。この乖離が診断の入口になる(後述)。

スキャン方式: Seq / Index / Index Only / Bitmap

1つのテーブルから行を取り出す方法は主に4つある。どれが選ばれるかは「何行取り出すか」と「索引があるか」でほぼ決まる。この「条件を満たす行が全体に占める割合」を選択率と呼び、以降しばしば使う。選択率が低い(=少数行しか返らない)ほど索引が有利になる。

スキャン方式いつ選ばれるか特徴
Seq Scan索引がない/大部分の行を読む/小さいテーブル全ページを順に読む。少数行の抽出には不利だが、多数行の読み出しや小テーブルにはむしろ有利
Index Scan選択率が低い(少数行)/索引順で取り出したい索引をたどってヒープ(本体)をランダムアクセス。1行ごとにヒープ参照が発生
Index Only Scan必要な列が全て索引に含まれ、可視性マップで可視性を確認できるヒープに触れずに完結。Heap Fetches: 0 が理想。詳細は第7回
Bitmap Heap/Index Scan中程度の選択率/複数索引の AND・OR を合成したい索引でヒープページのビットマップを作り、ヒープをページ順にまとめ読み。ランダム I/O を減らせる

重要なのは、Seq Scan は「遅い方式」ではないという点である。テーブルの大部分を返すクエリでは、索引をたどってヒープをランダムアクセスするより、全ページを順に読む Seq Scan の方が速い。索引が「使われない」のはプランナがサボっているのではなく、その方が安いと見積もったからであることが多い。

Bitmap は Index Scan と Seq Scan の中間で、次のように2段構えで現れる。

 Bitmap Heap Scan on orders  (cost=...) (actual ...)
   Recheck Cond: (customer_id = 12345)
   ->  Bitmap Index Scan on idx_orders_customer_id  (cost=...) (actual ...)
         Index Cond: (customer_id = 12345)

内側の Bitmap Index Scan で「どのページに該当行があるか」のビットマップを作り、外側の Bitmap Heap Scan でそのページ群をまとめて読む。中程度の件数(Index Scan だとランダム I/O が増えすぎ、Seq Scan だと読みすぎ)で選ばれやすい。

結合方式: Nested Loop / Hash / Merge

2つのテーブルを結合する方法も主に3つある。

結合方式いつ選ばれるか特徴
Nested Loop外側が少数行で、内側に索引がある外側の各行ごとに内側を検索。loops が大きくなると急激に悪化
Hash Join等値結合で、小さい側がメモリに収まる(1回のソートやハッシュ表に使ってよい上限が work_mem)小さい側でハッシュ表を作り、大きい側を1回走査。大量結合に強い。等値条件のみ
Merge Join両側が結合キー順に整列済み/整列が安い両側をキー順に並走。ソート済み入力や広い範囲の結合に向く

Nested Loop では loops の掛け算を必ず意識する。EXPLAIN の actual time と rows は1ループあたりの平均で表示されるため、内側ノードの実コストは「表示値 × loops」になる。

 ->  Index Scan using order_items_pkey on order_items oi
       (cost=0.43..8.45 rows=3 width=18) (actual time=0.005..0.007 rows=3 loops=21)

この内側ノードは loops=21、1ループ0.007ms・3行なので、実際には合計で 21 × 0.007 ≈ 0.15ms・21 × 3 = 63行 処理している。外側が20行程度なら Nested Loop で十分速いが、外側が10万行になれば「10万回の内側検索」になり、Hash Join に負ける。loops が大きい Nested Loop は要注意、と読めるようになる。

コストと行数見積もり

プランナは各計画の総コストを見積もり、最も安いものを選ぶ。コストは主に「読むページ数 × ページコスト」+「処理する行数 × CPU コスト」で組み立てられる。ページを1枚順に読むコストを 1.0 とし、ランダム読みは random_page_cost(既定4.0)で重み付けする。だから同じ件数でも、順読みできる Seq/Bitmap は安く、ランダムアクセスの Index Scan は高く見積もられる。

width(1行の平均バイト数)も無視できない。返す列が多い・長いほど width が増え、ソートやハッシュに必要なメモリが増える。SELECT * を避けるだけでコストが下がることがある。

コストはあくまで相対比較のための数字であり、ミリ秒ではない。速さの絶対値は actual time と Buffers で見る。

統計情報とヒストグラム

プランナが rows を見積もる根拠が統計情報である。ANALYZE が列ごとにサンプリングし、pg_statistic(人間向けビューは pg_stats)に格納する。主な中身は次の3つ。

中身は次のように覗ける。

SELECT attname, n_distinct, null_frac,
       array_length(histogram_bounds::text::text[], 1) AS hist_buckets
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('ordered_at', 'customer_id', 'status');

出力イメージ。

  attname    | n_distinct | null_frac | hist_buckets
-------------+------------+-----------+--------------
 ordered_at  |     -1     |     0     |     101
 customer_id |   50000    |     0     |     101
 status      |     6      |     0     |
(3 rows)

ordered_at の n_distinct = -1 は「ほぼ全行が異なる値」を表す。histogram_bounds は既定で100バケット(default_statistics_target = 100)に区切られ、ordered_at >= '2025-06-01' AND < '2025-06-02' のような範囲条件はこのヒストグラムから正確に見積もれる。一方 status は値が6種類しかないので most_common_vals 側で扱われ、ヒストグラムは持たない。

見積もり誤差の原因と診断

rows(見積もり)と actual ... rows(実測)の乖離こそ、遅いクエリ診断の一番の手がかりである。乖離が大きいと、プランナは間違った前提で方式を選ぶ(少数行だと思って Nested Loop を選んだら実は大量行だった、など)。主な原因は次のとおり。

原因症状対処
統計が古い大量投入・大量更新の直後にズレるANALYZE テーブル名;(自動バキュームが追いつくまで手動で)
サンプルが粗い値の分布が偏った列で範囲見積もりが甘いALTER TABLE ... ALTER COLUMN ... SET STATISTICS 1000; 後に ANALYZE
列に関数を適用WHERE date_trunc('day', ordered_at) = ... が既定選択率(約0.5%)に化ける範囲条件へ書き換え、または式索引を作る
列間の相関を見誤るregion と status のように相関する複数条件で件数を掛け算しすぎる/少なく見る拡張統計(列どうしの相関をプランナに教えるために明示的に作る統計)を CREATE STATISTICS ... (dependencies) ON ... で作る

最初の例で Seq Scan が rows=5000(=100万×約0.5%)と見積もったのは、date_trunc('day', ordered_at) という式に対して分布統計が無く、等値の既定選択率にフォールバックしたためである。列そのものに条件を書けばヒストグラムが効き、見積もりは実測に近づく。これを次のハンズオンで確かめる。

ハンズオン

意図的に遅いクエリを渡し、EXPLAIN (ANALYZE, BUFFERS) で原因を特定し、書き換えや索引で改善し、before/after のプランと実測を並べる。これは卒業要件そのものの練習である。実測ミリ秒は環境依存なので「例:」として読むこと。

課題1: 関数適用で索引が効かない WHERE を直す

やること: 「2025-06-01 の注文件数」を、まず date_trunc を使った本編のクエリで計測し、索引が使われない理由を述べたうえで、索引が効く形に書き換えて before/after を比較する。

想定解答

before は本編の計画そのもので、Seq Scan + Rows Removed by Filter: 998630 が原因である。ordered_at に索引があっても、date_trunc('day', ordered_at) という式には使えない(索引は列 ordered_at の値で並んでいて、date_trunc を適用した値では並んでいない)。

改善は2手ある。(a) 条件を「列そのものの範囲」に書き換える。(b) 範囲条件を索引で引けるよう索引を作る。

-- (b) ordered_at に索引を作る(第7回参照)
CREATE INDEX idx_orders_ordered_at ON orders (ordered_at);

-- (a) 同じ意味を「関数を使わない範囲条件」で書く
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM orders
WHERE ordered_at >= DATE '2025-06-01'
  AND ordered_at <  DATE '2025-06-02';

after の出力イメージ(例:)。

 Aggregate  (cost=48.55..48.56 rows=1 width=8) (actual time=0.512..0.513 rows=1 loops=1)
   Buffers: shared hit=17
   ->  Index Only Scan using idx_orders_ordered_at on orders  (cost=0.42..45.12 rows=1370 width=0) (actual time=0.028..0.402 rows=1370 loops=1)
         Index Cond: ((ordered_at >= '2025-06-01 00:00:00+09'::timestamptz) AND (ordered_at < '2025-06-02 00:00:00+09'::timestamptz))
         Heap Fetches: 0
         Buffers: shared hit=17
 Planning Time: 0.210 ms
 Execution Time: 0.535 ms

before / after の比較。

指標before(date_trunc+Seq Scan)after(範囲条件+Index Only Scan)
スキャン方式Seq Scan(全行走査)Index Only Scan
見積もり rows5000(式のため既定選択率に化ける)1370(ヒストグラムで正確)
実測 rows13701370
Buffersshared read=8306(8KB×8306≒65MB 読む)shared hit=17
実行時間(例)約182ms約0.5ms

要点は、書き換えで見積もり誤差も消えたこと(5000→1370)。列そのものの範囲条件にしたことで、範囲用のヒストグラムが効くようになった。Heap Fetches: 0 は、count(*) に必要な列が索引だけで足り、ヒープを触らず完結したことを示す(Index Only Scan の狙いどおり)。

課題2: JOIN 集計の Seq Scan を索引で潰す

やること: 「特定顧客(customer_id = 12345)の注文ごとの合計金額」を出す。まず索引なしで計測し、遅い箇所を特定して索引を追加し、before/after を並べる。

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, sum(oi.qty * oi.unit_price) AS amount
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.customer_id = 12345
GROUP BY o.id;

想定解答

before の出力イメージ(例:)。order_items 側は主キー (order_id, product_id) の先頭列が order_id なので索引で引けている。問題は orders 側に customer_id の索引がなく、100万行を Seq Scan していることにある。

 HashAggregate  (cost=20896.50..20897.10 rows=60 width=40) (actual time=201.418..201.470 rows=21 loops=1)
   Group Key: o.id
   Buffers: shared hit=8 read=8370
   ->  Nested Loop  (cost=0.43..20896.20 rows=60 width=18) (actual time=15.204..201.180 rows=64 loops=1)
         Buffers: shared hit=8 read=8370
         ->  Seq Scan on orders o  (cost=0.00..20821.00 rows=20 width=8) (actual time=15.010..200.640 rows=21 loops=1)
               Filter: (customer_id = 12345)
               Rows Removed by Filter: 999979
               Buffers: shared read=8306
         ->  Index Scan using order_items_pkey on order_items oi  (cost=0.43..3.72 rows=3 width=18) (actual time=0.020..0.024 rows=3 loops=21)
               Index Cond: (order_id = o.id)
               Buffers: shared hit=8 read=64
 Planning Time: 0.204 ms
 Execution Time: 201.520 ms

読み解き。外側 Seq Scan on orders が Rows Removed by Filter: 999979=99.9万行を捨てており、read=8306 ブロックの I/O をここで消費している。内側 Index Scan ... loops=21 は1ループ0.024msで健全(合計でも約0.5ms)。つまり遅さの主因は外側の Seq Scan である。

customer_id に索引を足す。

CREATE INDEX idx_orders_customer_id ON orders (customer_id);

after の出力イメージ(例:)。

 HashAggregate  (cost=249.35..249.95 rows=60 width=40) (actual time=0.298..0.334 rows=21 loops=1)
   Group Key: o.id
   Buffers: shared hit=95
   ->  Nested Loop  (cost=0.86..248.75 rows=60 width=18) (actual time=0.041..0.235 rows=64 loops=1)
         Buffers: shared hit=95
         ->  Index Scan using idx_orders_customer_id on orders o  (cost=0.43..79.20 rows=20 width=8) (actual time=0.028..0.060 rows=21 loops=1)
               Index Cond: (customer_id = 12345)
               Buffers: shared hit=23
         ->  Index Scan using order_items_pkey on order_items oi  (cost=0.43..3.72 rows=3 width=18) (actual time=0.005..0.007 rows=3 loops=21)
               Index Cond: (order_id = o.id)
               Buffers: shared hit=72
 Planning Time: 0.312 ms
 Execution Time: 0.362 ms

before / after の比較。

指標before(索引なし)after(idx_orders_customer_id)
orders 側の方式Seq Scan(99.9万行を捨てる)Index Scan(21行を直接取得)
結合方式Nested Loop(外側が重い)Nested Loop(外側が軽い)
Buffersshared read≒8370shared hit=95
実行時間(例)約201ms約0.4ms

結合の形(Nested Loop)は前後で同じで、変わったのは外側 orders の取り出し方だけ。遅さの主因は結合ではなく片側のスキャンだった、と計画から言い切れる。

逆に索引が不要な場面も確認する。 同じ結合でも「2025年通年の地域別売上」のように大部分の注文を読む集計では、話が逆になる。

EXPLAIN (ANALYZE, BUFFERS)
SELECT c.region, sum(oi.qty * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN customers  c  ON c.id = o.customer_id
WHERE o.ordered_at >= DATE '2025-01-01' AND o.ordered_at < DATE '2026-01-01'
GROUP BY c.region;

このクエリは order_items のほぼ全行(約300万)が集計対象になる。計画は次のようになりやすい。

 HashAggregate
   ->  Hash Join   (Hash Cond: oi.order_id = o.id)
         ->  Seq Scan on order_items oi
         ->  Hash
               ->  Hash Join (Hash Cond: o.customer_id = c.id)
                     ->  Seq Scan on orders o     (Filter: 通年の範囲)
                     ->  Hash -> Seq Scan on customers c

ここで order_items を Nested Loop + 索引で300万回引いたら、かえって遅い。大量行をまとめて処理するなら Seq Scan + Hash Join が正解であり、プランナが索引を使わないのは正しい判断である。「索引が使われない=悪」ではないことを、この2つのクエリの対比で体得してほしい。

よくあるつまずきと対処

症状原因対処
「なぜか索引が使われない」列に関数・演算を適用している(date_trunc(col), col + 1, lower(col))/取得件数が多くプランナが Seq Scan を選んだ列そのものへの条件に書き換える/式索引を作る/件数が多い場合は Seq Scan が正しいと認める
速い遅いを time だけで判断Buffers を見ずに I/O 量を語っているBUFFERS を付け、shared read(ディスク読み)と hit(キャッシュ命中)を見る
見積もりと実測の乖離を無視rows(estimated)と actual rows を並べて見ていない乖離が大きいノードを疑い、ANALYZE・SET STATISTICS・拡張統計で統計を直す
開発機(少量データ)では速い少量では Seq Scan でも一瞬なので Nested Loop が破綻しない本番同等の規模で ANALYZE 付き計測をする。件数が10倍になれば方式選択が変わる
EXPLAIN だけ見て安心素の EXPLAIN は見積もりのみ。見積もりが外れていたら無意味診断は必ず EXPLAIN (ANALYZE, BUFFERS) で実測と突き合わせる

エラーではないが典型的な誤り。次の2つは同じ意味なのに、上は索引が効かず下は効く。

-- 索引が効かない: 列に関数を適用している
WHERE date_trunc('day', ordered_at) = DATE '2025-06-01';

-- 索引が効く: 列そのものへの範囲条件
WHERE ordered_at >= DATE '2025-06-01' AND ordered_at < DATE '2025-06-02';

到達度チェックリスト

宿題(次回までの自習)

  1. order_items に対する「よく使う集計クエリ」を1つ選び、EXPLAIN (ANALYZE, BUFFERS) を取って、各ノードのスキャン方式・結合方式・Buffers を1行ずつ注釈するメモを作る。
  2. 課題1で作った idx_orders_ordered_at を DROP INDEX してから同じ範囲クエリを計測し、計画が Index Only Scan から Seq Scan に変わること、Buffers の read が跳ね上がることを確認する(確認後は索引を作り直す)。
  3. region と status のように相関しそうな2列で AND 条件のクエリを作り、見積もり rows と実測を比べる。ずれていたら CREATE STATISTICS で拡張統計を作り、ANALYZE 後に見積もりが改善するか観察する。

次回への接続

計画から「どの索引を足すべきか」まで読めるようになった。索引そのものの内部構造(B-tree・式索引・複合索引の列順・Index Only Scan の可視性マップ)は第7回で扱う。また、どのクエリを最初に虫眼鏡で見るべきか——本番で遅い順に候補を挙げる定点観測は第12回の pg_stat_statements が担う。次回第9回は、計画どおり動くクエリが「同時に」走ったときに何が起きるか(トランザクションと並行制御)へ進む。

参考