クエリが遅いのは「なんとなく」ではない。EXPLAIN で計画を開き、統計とコストから遅さの理由を言葉にする回。
同じ結果を返すクエリでも、PostgreSQL が内部でどう処理するかによって速度は桁で変わる。その「どう処理するか」を決めるのがプランナ(計画立案器)であり、決めた結果が実行計画(プラン)である。この回では EXPLAIN (ANALYZE, BUFFERS) の出力を1行ずつ読み、遅さの原因を統計情報とコストの言葉で説明できるようになる。これは本講座の卒業要件「遅いクエリの原因を計画から特定し、改善して before/after を示す」の中核である。
EXPLAIN (ANALYZE, BUFFERS) の各ノードの cost / rows / width / actual time / loops / Buffers を読める。rows)と実測行数(actual ... rows)の乖離を見つけ、統計情報(ANALYZE・ヒストグラム)の観点で原因を説明できる。第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_mem | 1回のソートやハッシュ表の作成に使ってよいメモリ量の上限を決める設定 |
VACUUM | 不要になった古い行の領域を回収する保守処理(第12回) |
次のクエリは「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(内側)→ Aggregate(外側)の順に処理される。上の行ほど後に実行される、と読む。cost=20834.00..20834.01: プランナの見積もりコスト。起動コスト..総コスト。単位は任意(既定では順次1ページ読みが 1.0)。起動コストは「最初の1行が返るまで」、総コストは「最後まで」。ミリ秒ではないので実測とは直接比べない。rows=1: このノードが返すと見積もった行数。width=8: 1行あたりの平均バイト数(見積もり)。actual time=182.334..182.335: 実測時間(ミリ秒)。起動..総。ANALYZE を付けたときだけ出る。rows=1 loops=1: 実測の出力行数と、このノードが実行された回数(loops)。Filter と Rows Removed by Filter: 998630: 99.8万行を読んで条件で捨てた、という意味。関数適用で索引が使えず、全行を走査したことがここに現れている。Buffers: shared hit=64 read=8306: 共有バッファ(= PostgreSQL がディスクのページを載せておく共有メモリ上のキャッシュ)に命中したブロックが64、ディスクから読んだブロックが8306。1ブロック=8KB。read が大きいほど I/O が重い。BUFFERS を見ずに「遅い」を語らない。ここで Seq Scan の見積もり rows=5000 と実測 rows=1370 がずれている。この乖離が診断の入口になる(後述)。
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 だと読みすぎ)で選ばれやすい。
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つ。
customer_id の distinct が5万なら = 12345 は約 1/50000)。>= < BETWEEN の選択率に使う)。中身は次のように覗ける。
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 のプランと実測を並べる。これは卒業要件そのものの練習である。実測ミリ秒は環境依存なので「例:」として読むこと。
やること: 「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 |
| 見積もり rows | 5000(式のため既定選択率に化ける) | 1370(ヒストグラムで正確) |
| 実測 rows | 1370 | 1370 |
| Buffers | shared read=8306(8KB×8306≒65MB 読む) | shared hit=17 |
| 実行時間(例) | 約182ms | 約0.5ms |
要点は、書き換えで見積もり誤差も消えたこと(5000→1370)。列そのものの範囲条件にしたことで、範囲用のヒストグラムが効くようになった。Heap Fetches: 0 は、count(*) に必要な列が索引だけで足り、ヒープを触らず完結したことを示す(Index Only 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(外側が軽い) |
| Buffers | shared read≒8370 | shared 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';
cost=起動..総 / rows / width / actual time / loops / Buffers を、それぞれ何の値か説明できる。actual time・rows が1ループあたりで、実コストは × loops になることを説明できる。rows と actual rows の乖離を見つけ、統計が古い/関数適用/相関のどれかに原因を切り分けられる。order_items に対する「よく使う集計クエリ」を1つ選び、EXPLAIN (ANALYZE, BUFFERS) を取って、各ノードのスキャン方式・結合方式・Buffers を1行ずつ注釈するメモを作る。idx_orders_ordered_at を DROP INDEX してから同じ範囲クエリを計測し、計画が Index Only Scan から Seq Scan に変わること、Buffers の read が跳ね上がることを確認する(確認後は索引を作り直す)。region と status のように相関しそうな2列で AND 条件のクエリを作り、見積もり rows と実測を比べる。ずれていたら CREATE STATISTICS で拡張統計を作り、ANALYZE 後に見積もりが改善するか観察する。計画から「どの索引を足すべきか」まで読めるようになった。索引そのものの内部構造(B-tree・式索引・複合索引の列順・Index Only Scan の可視性マップ)は第7回で扱う。また、どのクエリを最初に虫眼鏡で見るべきか——本番で遅い順に候補を挙げる定点観測は第12回の pg_stat_statements が担う。次回第9回は、計画どおり動くクエリが「同時に」走ったときに何が起きるか(トランザクションと並行制御)へ進む。