「クエリが遅い」を直すのに、勘は要りません。PostgreSQL は EXPLAIN で実行計画を、EXPLAIN ANALYZE で実測を見せてくれます。読めるようになれば、「なぜ遅いか」が一目で分かり、打ち手(索引・統計更新・クエリ書き換え・メモリ)が確定します。
この記事は、パフォーマンス総論の §2 で特定した「重いクエリ」を、実際に診断して直すための読み方ガイドです。公式ドキュメントの定義に忠実に、現場で見る警告サインと対処まで落とし込みます。
この記事のルール:
EXPLAINの出力項目の意味・各オプション・PostgreSQL 18 の変更点は、すべて PostgreSQL 18 公式ドキュメント(2026年6月時点) に基づきます。コストは「ディスクページ取得を1.0とした任意単位」で、絶対値ではなくプラン間の相対比較に使うものです。
1. 4つの数字を読む:cost / rows / width
まず素の EXPLAIN(実行しない=見積もりのみ)。
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
Index Scan using idx_orders_cust on orders (cost=0.42..8.45 rows=3 width=72)
Index Cond: (customer_id = 42)
括弧内の4つの数字が基本です(公式 using-explain)。
| 項目 | 意味 |
|---|---|
cost=0.42..8.45 | 開始コスト..総コスト。0.42=最初の行を返せるまで、8.45=全行返し終えるまで |
rows=3 | このノードが出力すると見積もった行数 |
width=72 | 1行あたりの推定平均バイト数 |
ポイント。
- 開始コストは「出力開始までの準備」(ソートノードならソート完了まで)。
- 総コストは「最後まで読み切る前提」。親が
LIMITで途中で打ち切れば、実際はここまで掛かりません。 - コストは累積。親ノードのコストは子ノードを含む。
- 単位は任意(
seq_page_cost = 1.0基準)。絶対値に意味はなく、プラン同士の比較に使います。
PostgreSQL 18 の小ネタ:
EXPLAINのrowsが小数表示になり得ます(従来は整数に丸め)。微小な選択率がより正確に見えます。
2. EXPLAIN ANALYZE:実測を出す(DMLは要注意)
ANALYZE を付けると、PostgreSQL は実際にクエリを実行し、見積もりと実測を並べます。
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE customer_id = 42;
Index Scan using idx_orders_cust on orders
(cost=0.42..8.45 rows=3 width=72) (actual time=0.018..0.021 rows=3 loops=1)
Index Cond: (customer_id = 42)
Buffers: shared hit=4
Planning Time: 0.10 ms
Execution Time: 0.04 ms
actual time=0.018..0.021 rows=3 loops=1 が実測です。(cost=…) (actual=…) を左右で見比べるのが診断の基本動作。
危険:ANALYZE は本当に実行する。公式の警告どおり、
INSERT/UPDATE/DELETE/MERGEにEXPLAIN ANALYZEを付けるとデータが変わります。SELECTの返却は破棄されますが、副作用は起きる。DML を計測するときは必ずトランザクションで囲んでロールバックします。
BEGIN;
EXPLAIN (ANALYZE) UPDATE orders SET status = 'shipped' WHERE id = 42;
ROLLBACK; -- 計画は分かったが、変更は取り消す
3. 最重要:見積もり rows と実測 rows のズレ
公式が「最も重要」と明言するのは——見積もり行数が実測にどれだけ近いか。
Seq Scan on orders
(cost=0.00..18334.00 rows=1 width=72) (actual ... rows=48210 loops=1)
^^^^^^ ^^^^^^^^^^^
見積もり1行 実測は48,210行(桁違い!)
このように桁違いにズレているとき、プランナーは間違った前提で計画を立てています。原因はほぼ統計情報の陳腐化。ANALYZE で統計を取り直すと、多くの場合この種の問題は解決します。
ANALYZE orders; -- 統計を更新してから、もう一度 EXPLAIN ANALYZE
それでもズレるなら、相関する複数列(プランナーは独立と仮定する)が原因のことが多い。CREATE STATISTICS で拡張統計を与えます(総論 §3 参照)。見積もりが正しくなれば、プランナーは自然と良い計画を選びます。「索引を強制する」より「統計を正す」が王道です。
4. loops の罠:実測値は「1ループあたりの平均」
ここを誤読する人が非常に多い。公式の定義:
サブプランノードが複数回実行される場合、
loopsは総実行回数を表し、actual timeとrowsは1回あたりの平均で表示される。総時間を知るには loops を掛ける。
つまり actual time=0.5 rows=2 loops=10000 は「平均0.5ms × 1万回 = 合計5秒」。Nested Loop の内側で起きると、見た目の小さな数字が総時間では爆発します。
Nested Loop (actual time=... rows=10000 loops=1)
-> Seq Scan on a (actual ... rows=10000 loops=1)
-> Index Scan on b (actual time=0.05..0.06 rows=1 loops=10000)
^^^^^^^^^^
内側が1万回実行 → 0.06ms × 10000 ≈ 0.6秒がここに隠れている
外側の行数が多いのに Nested Loop が選ばれているなら、行数の見積もり誤りでプランナーが Hash/Merge 結合を選び損ねている可能性大。やはり統計を疑います。
5. ノードの意味:何が選ばれたら何を意味するか
実行計画は木構造で、葉がスキャン、枝が結合・集約です。主要ノードの意味と「出たら何を考えるか」。
スキャン系
| ノード | 意味 | 出たときの考え |
|---|---|---|
| Seq Scan | 全行を順に読む | 大量行を返すなら正常。少数を返すのに出たら索引不足を疑う |
| Index Scan | 索引で位置特定→ヒープ取得 | 選択的な条件・整列に最適。理想形の一つ |
| Index Only Scan | 索引だけで完結(ヒープ不要) | 最速級。Heap Fetches を確認(索引の記事) |
| Bitmap Index Scan → Bitmap Heap Scan | 位置をビットマップに集めてから物理順でヒープ取得 | 中程度の選択率。Seq と Index の中間で効率的 |
結合系
| ノード | 意味 | 得意な状況 |
|---|---|---|
| Nested Loop | 外側1行ごとに内側を再スキャン | 外側が少数のとき。多いと爆発(§4) |
| Hash Join | 片方からハッシュ表を作り突き合わせ | 大きい非整列の等値結合 |
| Merge Join | 両側を整列してマージ | 両側が既に整列済みの大規模結合 |
-- VERBOSE で出力列・スキーマ修飾名まで、FORMAT json で機械可読に出せる
EXPLAIN (ANALYZE, VERBOSE, FORMAT JSON) SELECT ...;
6. 警告サイン集:これが出たらこう直す
実行計画から「遅い理由」を読み取る、現場のパターン集です。
① Rows Removed by Filter が巨大 → 索引不足
Seq Scan on orders (actual ... rows=120 loops=1)
Filter: (status = 'pending')
Rows Removed by Filter: 1488000 ← 149万行を読んで120行に絞った(無駄が大きい)
149万行を読んでフィルタで捨てている。status に索引(または部分索引)を張れば、読む行を最初から絞れます。
② Sort Method: external merge Disk: … → work_mem 不足
Sort (actual time=820..950 rows=500000 loops=1)
Sort Key: created_at
Sort Method: external merge Disk: 86016kB ← メモリに収まらずディスクへ退避
ソートが work_mem を溢れてディスクに書いた証拠。そのクエリだけ SET LOCAL work_mem を上げるか(総論 §4.2)、ORDER BY を満たす索引を用意してソート自体を消します。Memory: NkB(quicksort)ならメモリ内で完結=健全。
③ Heap Fetches が多い → VACUUM 不足
Index Only Scan using idx_orders_cust_covering on orders
(actual ...) Heap Fetches: 43120 ← index-only のはずがヒープを4万回も見ている
可視性マップが all-visible でないため、index-only scan がヒープを見に行っている。対象表を VACUUM すれば Heap Fetches は下がります(MVCC/VACUUM の記事)。
④ Nested Loop で内側の loops が巨大 → 結合方式の誤選択
§4 のとおり。多くは見積もり誤り。ANALYZE/拡張統計で見積もりを正すのが本筋です。
7. BUFFERS でキャッシュ効率を読む(PG18で既定ON)
BUFFERS は、各ノードが触れたバッファ数を出します。メモリに乗っているか、ディスクを叩いているかが分かります。
| 項目 | 意味 |
|---|---|
shared hit | キャッシュ命中(ディスク読み回避) |
shared read | ディスクから読んだ(コールド) |
shared dirtied | このクエリが変更した未変更ブロック |
shared written | このクエリ実行中に追い出された dirty ブロック |
PostgreSQL 18 の変更:
EXPLAIN ANALYZEはBUFFERSを自動で含むようになりました(公式:「Buffers information is automatically included when ANALYZE is used.」)。PG17 以前は明示が必要でした。抑制したいときだけEXPLAIN (ANALYZE, BUFFERS OFF)。
Index Scan ... Buffers: shared hit=4 ← 全部キャッシュ命中(理想)
Seq Scan ... Buffers: shared read=18334 ← 1.8万ブロックをディスクから読んだ(重い)
同じクエリを2回実行して read が hit に変われば、2回目はキャッシュに乗った証拠。初回(コールド)と2回目(ウォーム)を区別して評価しましょう。PG18 ではこのほか、EXPLAIN ANALYZE がインデックススキャンごとの索引探索回数を報告し、Material/Window Aggregate/CTE ノードのメモリ・ディスク使用量も出るようになりました。
8. 本番のスロークエリを捕まえる:auto_explain
開発中は EXPLAIN ANALYZE を手で叩けますが、本番では「いつ・どのクエリが遅かったか」を後から追う必要があります。そのための拡張が auto_explain——しきい値を超えたクエリの実行計画を自動でログに残します。
# postgresql.conf(shared_preload_libraries 推奨)
shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '500ms' # 500ms 超のクエリの計画をログ(-1=無効, 0=全件)
auto_explain.log_format = 'json' # 構造化ログとして集約しやすい
auto_explain.sample_rate = 1.0 # 高トラフィックでは下げて負荷を抑える
最重要の注意(公式が強く警告):auto_explain.log_analyze = on にすると EXPLAIN ANALYZE 相当になり計画だけでなく実測も取れますが、「ログ対象でない短いクエリも含め全クエリでノード単位の計時が走り、性能に極めて悪影響を与え得る」。本番で常時ONは避け、必要なときだけ・log_timing = off や sample_rate < 1 と併用して負荷を抑えます。
| パラメータ | 既定 | 役割 |
|---|---|---|
log_min_duration | -1(無効) | この時間(ms)を超えたら計画を記録。0 で全件 |
log_analyze | off | 実測も取る。高負荷注意 |
log_timing | on | ノード単位の計時(log_analyze 前提)。offで負荷軽減 |
log_buffers | off | BUFFERS を含める(log_analyze 前提) |
sample_rate | 1 | 記録するクエリの割合。高負荷時に下げる |
9. 診断〜改善のワークフロー(まとめ)
- 特定:
pg_stat_statementsで累計時間の長いクエリを選ぶ(総論 §2)。 - 計測:
EXPLAIN (ANALYZE)を実行(DML はBEGIN; … ROLLBACK;)。 - 見積もりズレを確認:
rows見積もり vs 実測。桁違いならANALYZE→ 拡張統計。 - 重いノードを特定:
actual time(×loops)が大きいノード、Rows Removed by Filter、external merge Disk、Heap Fetches。 - 打ち手を確定:
- 大量フィルタ → 索引(索引の記事)
- 外部ソート →
work_memor 整列索引 - Heap Fetches → VACUUM
- 結合誤選択 → 統計の修正
- 再計測:同じ
EXPLAIN ANALYZEで改善を数値で確認(コールド/ウォームに注意)。 - 本番監視:
auto_explainで再発を検知。
やってはいけない:
SET enable_seqscan = off等でプランナーを力技で曲げるのは、原因を隠す対症療法。デバッグ時の切り分けには使えますが、本番常用は禁物です。正しい統計と索引で、プランナーに正しい選択をさせるのが本筋。
10. まとめ
EXPLAINは見積もり、EXPLAIN ANALYZEは実測。両者を左右で見比べる。- 見積もりと実測のズレが最重要シグナル。桁違いなら統計の問題(
ANALYZE/拡張統計)。 actualは loops あたりの平均。総時間は loops 倍——Nested Loop の罠に注意。- 警告サイン(
Rows Removed by Filter/external merge Disk/Heap Fetches)ごとに打ち手は決まっている。 - PG18 は
EXPLAIN ANALYZEが BUFFERS 既定ON。本番はauto_explainで計画を記録(log_analyze常時ONは高負荷)。
実行計画が読めれば、チューニングは「勘」から「診断」になります。次は、計画を左右する物理レイヤー——MVCC とトランザクション、VACUUM へ。