メインコンテンツへスキップ
友田 陽大
PostgreSQL本体・性能設計
PostgreSQL
パフォーマンス
アーキテクチャ設計

PostgreSQL EXPLAIN ANALYZE の読み方とスロークエリ改善(実行計画の見方・ノード別の意味・auto_explain・v18対応)

PostgreSQL の遅いクエリを EXPLAIN ANALYZE で診断し、確実に速くするための実務ガイド。cost/rows/width の読み方、見積もりと実測のズレ(統計の陳腐化)、Seq Scan・Bitmap・Nested Loop など各ノードの意味、外部ソートや Heap Fetches などの警告サイン、本番で遅いクエリの計画をログに残す auto_explain、PostgreSQL 18 の BUFFERS 既定ON までを公式ドキュメントに忠実に解説します。

公開日
読了時間
11分
著者
友田 陽大
シェア

「クエリが遅い」を直すのに、勘は要りません。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=721行あたりの推定平均バイト数

ポイント。

  • 開始コストは「出力開始までの準備」(ソートノードならソート完了まで)。
  • 総コストは「最後まで読み切る前提」。親が LIMIT で途中で打ち切れば、実際はここまで掛かりません。
  • コストは累積。親ノードのコストは子ノードを含む。
  • 単位は任意(seq_page_cost = 1.0 基準)。絶対値に意味はなく、プラン同士の比較に使います。

PostgreSQL 18 の小ネタEXPLAINrows小数表示になり得ます(従来は整数に丸め)。微小な選択率がより正確に見えます。


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/MERGEEXPLAIN 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 timerows1回あたりの平均で表示される。総時間を知るには 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 ANALYZEBUFFERS を自動で含むようになりました(公式:「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回実行して readhit に変われば、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 = offsample_rate < 1 と併用して負荷を抑えます。

パラメータ既定役割
log_min_duration-1(無効)この時間(ms)を超えたら計画を記録。0 で全件
log_analyzeoff実測も取る。高負荷注意
log_timingonノード単位の計時(log_analyze 前提)。offで負荷軽減
log_buffersoffBUFFERS を含める(log_analyze 前提)
sample_rate1記録するクエリの割合。高負荷時に下げる

9. 診断〜改善のワークフロー(まとめ)

  1. 特定pg_stat_statements で累計時間の長いクエリを選ぶ(総論 §2)。
  2. 計測EXPLAIN (ANALYZE) を実行(DML は BEGIN; … ROLLBACK;)。
  3. 見積もりズレを確認rows 見積もり vs 実測。桁違いなら ANALYZE → 拡張統計
  4. 重いノードを特定actual time(× loops)が大きいノード、Rows Removed by Filterexternal merge DiskHeap Fetches
  5. 打ち手を確定
    • 大量フィルタ → 索引索引の記事
    • 外部ソート → work_mem or 整列索引
    • Heap Fetches → VACUUM
    • 結合誤選択 → 統計の修正
  6. 再計測:同じ EXPLAIN ANALYZE で改善を数値で確認(コールド/ウォームに注意)。
  7. 本番監視auto_explain で再発を検知。

やってはいけないSET enable_seqscan = off 等でプランナーを力技で曲げるのは、原因を隠す対症療法。デバッグ時の切り分けには使えますが、本番常用は禁物です。正しい統計と索引で、プランナーに正しい選択をさせるのが本筋。


10. まとめ

  • EXPLAIN は見積もり、EXPLAIN ANALYZE は実測。両者を左右で見比べる
  • 見積もりと実測のズレが最重要シグナル。桁違いなら統計の問題(ANALYZE/拡張統計)。
  • actualloops あたりの平均。総時間は loops 倍——Nested Loop の罠に注意。
  • 警告サイン(Rows Removed by Filter / external merge Disk / Heap Fetches)ごとに打ち手は決まっている。
  • PG18 は EXPLAIN ANALYZE が BUFFERS 既定ON。本番は auto_explain で計画を記録(log_analyze 常時ONは高負荷)。

実行計画が読めれば、チューニングは「勘」から「診断」になります。次は、計画を左右する物理レイヤー——MVCC とトランザクション、VACUUM へ。


参考(PostgreSQL 18 公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

木材流通DXのB2B SaaS — PostgreSQL を中核にマルチテナント・多段商流を支えたデータ基盤

ケーススタディを見る