同じSQLが本番だけ十秒かかる。索引を張ったのに全走査のままだ。開発機では一瞬で返るのに、アプリ経由だと詰まる。この三つはどれも実行計画を出せば原因の所在が分かる種類の症状で、勘で索引を足しても直りません。プランナが何を根拠にその計画を選んだのかを読めるかどうかが分かれ目になります。
この記事は、PostgreSQLのEXPLAINが返す計画をどう読み、どこで判断を切り替えるかを扱う解説です。4つの数値の意味、走査方式と結合方式が選ばれる条件、コストパラメータの既定値、統計情報が劣化したときの見積もり乖離、そして遅いクエリを絞り込む手順までを実行できるSQL付きで並べます。データベース全般の位置づけはデータベースとは?種類・DBMS・RDBとNoSQLの選び方に譲り、ここはPostgreSQL固有の診断だけを扱います。数値の前提は18系(2026年8月時点で18.6が最新マイナー)としました。
まとめ:実行計画を読むときに先に決める5点
結論を5点で先に置きます。第一に、実行計画は木構造で、内側のノードから外側へ向かって実行されます。字下げの深い行が先に動き、上の行は下の結果を受け取る側です。この向きを取り違えると、時間のかかっているノードを毎回誤診します。
第二に、見るべきは推定と実測の差です。rowsはプランナの見積もり、actual rowsは実際に返った件数で、この二つが一桁以上ずれているノードが原因の入口になります。コストの絶対値は任意単位で、秒には換算できません。
第三に、Seq ScanとIndex Scanの分かれ目は表の大きさではなく選択率とコストパラメータです。既定のrandom_page_costは4.0で、索引経由のランダム読みの大半がキャッシュに載っている前提で低めに置かれた値になります。
第四に、18でEXPLAIN ANALYZEの既定出力が増えました。バッファ情報が自動で付き、インデックス走査ノードごとの索引探索回数が出て、行数が小数で表示され、無効化されたノードが明示されます。従来記事にある「BUFFERSを付けて実行しろ」という助言は、18では前提が変わっています。
第五に、psqlで速いのにアプリだけ遅いなら、計画そのものより計画のキャッシュを疑います。パラメータ付きのプリペアド文は最初の5回をカスタムプランで実行し、その平均推定コストとジェネリックプランのコストを比べて切り替える規則です。以下、順に根拠を見ていきます。
EXPLAINとEXPLAIN ANALYZEの出力構造とコスト行が示す4つの数値
EXPLAINは計画を見せるだけ、EXPLAIN ANALYZEは実際に走らせて実測値を併記します。後者は更新系でも本当に実行されるため、検証時はトランザクションで囲んで戻すのが安全です。出力の各行はノードで、字下げと矢印が親子関係を表し、最上位の行の総コストと総時間には下にぶら下がる全ノードの分が含まれます。この包含関係を知らないと、上位ノードの数字だけを見て「結合が遅い」と誤診しがちです。
cost・rows・width・actual timeの4項目が表す推定値と実測値
各ノードには推定4項目と実測3項目が並びます。推定側のcostは二つの数字を持ち、前半が最初の1行を返すまでの起動コスト、後半が全行を返し終える総コストを指します。rowsは返すと見込んだ行数、widthは1行あたりの平均バイト数です。
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'shipped';
Seq Scan on orders (cost=0.00..2334.00 rows=1042 width=52)
(actual time=0.021..18.412 rows=98231 loops=1)
Filter: (status = 'shipped'::text)
Rows Removed by Filter: 1769
この例では見積もり1,042行に対して実測98,231行と約94倍の開きがあり、統計が実データを表せていないと判断できます。起動コストが0.00なのは全走査が最初の1行をすぐ返せるためで、索引付きの整列を伴う計画ではここに大きな値が乗ります。
loopsの回数でactual timeを割り増して読むときの落とし穴
結合の内側に入ったノードにはloopsが付きます。ここに出るactual timeは1回あたりの平均値で、そのノードの総所要時間ではありません。平均0.03ミリ秒でも、loopsが12万なら実際には3.6秒を消費しています。
見た目の小さい数字が真犯人という事故は、ほぼこの読み違いから起きます。行数についても同じで、表示されるrowsは1回あたりの平均行数です。総処理行数を知りたければ回数を掛けてください。
Seq ScanとIndex Scanが切り替わる境目とコストパラメータの既定値
索引があるのに全走査になるのは、たいていバグではなく計算結果です。プランナはページ読みとCPU処理を単価で見積もり、合計の安い方を採ります。
全走査を選ぶ判断がコストの計算式と選択率の見積もりで決まる理由
全走査のコストは、おおよそ「ページ数×seq_page_cost+行数×cpu_tuple_cost+行数×cpu_operator_cost」で求まります。1万ページ・100万行の表を条件1つで絞るなら、10000+10000+2500で約22,500です。索引経由はランダム読みの単価が4倍なので、取り出す行が全体の数%を超えたあたりで逆転します。
| パラメータ | 既定値 | 意味 |
|---|---|---|
| seq_page_cost | 1.0 | 連続ページ1枚の読み取り |
| random_page_cost | 4.0 | ランダムページ1枚の読み取り |
| cpu_tuple_cost | 0.01 | 1行を処理するCPU負荷 |
| cpu_index_tuple_cost | 0.005 | 索引1エントリの処理 |
| cpu_operator_cost | 0.0025 | 演算子1回の評価 |
| effective_cache_size | 4GB | 索引に使えるキャッシュ量 |
公式文書はrandom_page_costの4.0について、索引読みなどランダムアクセスの大半はキャッシュにあると仮定しているため低めの既定値にした、と説明しています。データベース全体がRAMに載るならseq_page_costと同値にするのが理屈に合います。effective_cache_sizeの既定4GBは実メモリと無関係な固定値なので、搭載量の5〜7割程度へ引き上げないと索引が不当に不利なままです。
Index Only ScanとBitmap Heap Scanが出る条件の違い
Index Only Scanは、必要な列がすべて索引に含まれていて、かつ可視性マップで該当ページが「全行可視」と印されている場合にだけ選ばれます。更新の多い表でこれが出ないときは索引の作りではなく、可視性マップの更新が追いついていない側の問題です。回収処理の周期はPostgreSQLのVACUUM運用|autovacuumのしきい値設計とXID周回で扱った設定が効いてきます。
Bitmap Heap Scanは、索引から該当ページの位置を先に集めてビットマップを作り、物理順に並べ替えてから表を読む方式です。取り出す行が多くランダム読みが増えすぎる中間帯で選ばれます。Recheck Condの下にHeap Blocks: lossyの行が出ていたら、ビットマップが行単位からページ単位へ粗くなった合図で、work_memが足りていません。転置索引そのものの挙動はPostgreSQLにおけるGINインデックスとは何かに整理があります。
Nested Loop・Hash Join・Merge Joinが選ばれる条件
結合方式は三つしかなく、選ばれる条件も単純です。厄介なのは、見積もりが外れたときに最も壊れやすい方式が選ばれてしまう点にあります。
内側の反復回数で決まるNested Loopの適性とN+1問題との接点
Nested Loopは外側の1行ごとに内側を引き直す方式で、外側が数十行なら圧倒的に速く、外側が数万行になると同じ形のまま破綻します。見積もり1,000行が実測30万行だったとき、プランナは安いはずだと判断してこの方式を採り続け、内側の索引探索が30万回走ります。
アプリケーション側で同じ構図が起きるのが、一覧を取得してから明細を1件ずつ引くN+1です。データベース内のNested Loopは索引が効いていれば許容範囲に収まりますが、通信を挟むN+1は往復遅延がそのまま積み上がるため桁が違います。検出と対処はN+1問題とは?仕組みと検出方法・ORM別の対策にまとめました。
work_memを超えたHash Joinの溢れとMerge Joinが選ばれる条件
Hash Joinは小さい側でハッシュ表を作り、大きい側を流して突き合わせます。等値条件かつ片側がwork_memに収まる場合の第一候補です。収まらないとバッチ分割が起き、EXPLAIN ANALYZEの出力には分割数と一時ファイルの使用量が現れます。この2行が出た時点で、索引を足すより先に作業メモリの見直しが打ち手になります。
Merge Joinは両側が結合キー順に整列している場合に効く方式で、索引の順序をそのまま使えるなら整列コストなしで通ります。整列が要るなら先にSortノードが挟まり、起動コストが跳ね上がる形です。なお結合表が多いクエリではfrom_collapse_limitとjoin_collapse_limitの既定8を超えた時点で結合順序の探索が打ち切られ、geqo_thresholdの既定12を超えると遺伝的アルゴリズムによる近似探索へ切り替わります。
統計情報の精度とANALYZEの実行契機・見積もり行数が乖離する原因
見積もりの精度は統計がすべてです。プランナは実データを見ずにpg_statisticの要約だけで判断するので、要約が古ければ計画も古い前提のまま決まります。
default_statistics_targetの既定100とpg_statsの読み方
統計の粒度を決めるのがdefault_statistics_targetで、既定は100です。この値は最頻値リストの上限件数とヒストグラムの分割数を兼ね、公式文書は「解析対象の列のうち最大の統計目標が、統計作成のために標本抽出される表の行数を決める」と説明しています。値を上げれば精度は上がり、その分だけ解析の時間と保存領域が比例して増えます。
中身はpg_statsビューで読めます。pg_statisticを直接見ず、可読性が高く一般ユーザでも参照できるこちらを使うのが公式の案内です。偏りの激しい列だけ精度を上げたいならALTER TABLEのSET STATISTICSで列単位に指定でき、全体を引き上げるより副作用が小さく済みます。n_distinctが実際の異なり数と大きく離れている列は、まずここを疑ってください。異なり数の考え方はカーディナリティとは?意味・高低の見分け方とインデックス設計に整理があります。
相関する列で過小見積もりが起きる仕組みと拡張統計を定義する手順
列単位の統計しか持たない以上、プランナは複数条件を独立事象として掛け算します。市区町村と郵便番号のように従属関係がある2列に条件を付けると、実際には絞り込みが進んでいないのに両方で絞れたと計算し、見積もりが実測を大きく下回ります。過小見積もりはNested Loopの誤選択に直結する典型パターンです。
CREATE STATISTICS stts (dependencies) ON city, zip FROM zipcodes;
ANALYZE zipcodes;
拡張統計は関数従属だけでなく、複数列の異なり数や最頻値の組み合わせも保持できます。定義しただけでは反映されず、直後にANALYZEを走らせる必要がある点は見落としやすいところです。作成対象は、条件が同時に付くことが多く、かつ業務上の従属関係がある列の組に絞ってください。
パーティション親と継承親が自動ANALYZEの対象外になる落とし穴
自動解析はautovacuumデーモンが担いますが、公式文書は明確な例外を挙げています。パーティション表そのものは処理されず、子だけが更新される継承の親も処理されません。したがって階層の統計を保つには、定期的な手動ANALYZEが要ります。
日次バッチで区画を追加していく設計だと、子の統計は自動で更新されるのに親の統計だけが初期状態のまま取り残されます。区画をまたぐ集計クエリの見積もりが常に外れているなら、まず親に対してANALYZEを流してみてください。
18で変わったEXPLAINの出力と計画の形を変える三つの改良点
18の変更は表示だけでなく計画の選択そのものにも及びます。過去の記事で覚えた読み方が、そのままでは通らなくなった箇所があります。
EXPLAIN ANALYZEの既定出力が18で増えた四つの表示項目
18のリリースノートは、EXPLAINまわりの変更を複数挙げています。実務で効くのは次の4点です。
- EXPLAIN ANALYZEにバッファ情報が自動で含まれるようになった
- インデックス走査ノードごとに索引の探索回数が報告されるようになった
- 行数が小数で出力され、1未満の見積もりが潰れなくなった
- 無効化されたノードがEXPLAIN ANALYZEの出力上で明示されるようになった
1点目により、共有バッファのヒット数と実読み込み数が指定なしで見えます。実読みが多いノードはキャッシュに乗り切っていない証拠で、索引の追加より先に見るべきはメモリ配分です。2点目の探索回数は、配列条件やスキップ走査で索引を何度引き直したかを示す値で、loopsとは別の観点から反復の多さを暴きます。
スキップ走査・自己結合除去・OR句の配列化が計画に及ぼす影響
18のオプティマイザ側では三つの改良が入りました。第一に複合索引のスキップ走査が可能になり、先頭列に条件がない、あるいは等値でない場合でも後続列の条件で索引を使えるようになっています。複合索引は先頭列から使うという長年の経験則は、18では単純に当てはまりません。
第二に不要な自己結合が自動で除去されます。ビューを重ねた結果として同一表が二度出るクエリで、計画から結合そのものが消えます。挙動を切り分けたいときはenable_self_join_eliminationを切って比較してください。第三にOR句が配列条件へ変換され、索引処理が速くなりました。これまでOR句をUNION ALLへ手で書き換えていた対処は、18では不要になる場面があります。MySQLとの挙動差を確認したい場合はPostgreSQLとMySQLの違いを徹底比較|性能・データ型・全文検索・移行を参照してください。
遅いクエリを実行計画から改善する五段階の手順と打ち手の優先順位
手順を固定しておくと、当てずっぽうの索引追加が減ります。対象の特定、計画の取得、乖離の確認、打ち手の選択、再測定の5段階です。
pg_stat_statementsとauto_explainで直す対象を絞る手順
最初にやるのは、直す価値のあるクエリを1本に絞ることです。総実行時間で並べれば、1回3秒のクエリより1回30ミリ秒で10万回走るクエリのほうが効く、といった判断ができます。
SELECT queryid, calls, total_exec_time,
mean_exec_time, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
個別の計画を本番で捕まえるのはauto_explainの役目です。既定ではauto_explain.log_min_durationが-1で無効なので、閾値をミリ秒で設定して有効化します。log_analyzeは既定off、log_timingは既定onで、公式文書は前者について「全ての文でノード単位の計時が発生し、性能に極めて悪い影響を与えうる」と警告しています。本番で実測値まで取りたいならlog_timingをoffにして代償を減らし、sample_rateで対象を間引くのが現実的な線でしょう。MySQLでの同等の仕組みはスロークエリログとは|MySQLの設定・見方・解析ツールと改善手順にまとめてあります。
GENERIC_PLANと5回ルールで説明できるアプリだけ遅い現象
psqlでは速いのにアプリ経由だと遅い。この現象の多くは、パラメータ付きのプリペアド文がジェネリックプランへ切り替わったことで説明が付きます。公式文書によればplan_cache_modeがautoのとき、最初の5回はカスタムプランで実行して平均推定コストを算出し、その後ジェネリックプランのコストと比べて採否を決める規則です。
手元で再現するにはEXPLAIN (GENERIC_PLAN)でパラメータ記号を含むSQLの計画を取り、実値を埋めた計画と見比べます。両者が違うなら、偏りの強い列がパラメータになっている可能性が高いところです。対処としてはplan_cache_modeをセッション単位でforce_custom_planに倒すか、該当列の統計目標を上げるかの二択になります。
実行計画の診断を内製で回すか保守運用へ載せるかを分ける三条件
ここは判断を言い切ります。実行計画の診断を内製で回せるかどうかは、担当者の知識量ではなく環境と権限が揃っているかで決まります。
内製で回せるかどうかは再現環境と統計の同期が取れているかで決まる
内製化の条件は三つです。本番同等の行数を持つ検証環境があること、その統計が本番と同期していること、そして計画を継続的に取得する仕組みが入っていること。この三つが揃っていれば、索引追加やパラメータ調整は社内で回せます。逆に、行数が本番の百分の一の環境で計画を比べても意味がありません。
採用しない場面も明示しておきます。検証環境が本番の1割未満の規模しかなく、pg_stat_statementsもauto_explainも入っていない状態で内製診断に踏み切るのは見送るべきです。この条件下では計画の比較そのものが成立せず、当たった索引と外れた索引の区別が付きません。
外部へ委ねる判断は権限設計と本番データの取り扱い条件で決まる
診断には本番の統計と実行計画が要ります。表の定義と行数分布が見えれば足りる場合が多く、実データの中身まで渡す必要はありません。pg_read_all_statsのような権限を切り出して統計参照だけを許可し、実データへのアクセスは分離するのが現実的な線引きになります。
設計と実装を担った側が保守も持つと、索引の意図や正規化の判断理由が残っているぶん切り分けが速く進む点は無視できません。一創では受託開発した業務システムについて、性能劣化の診断からパラメータ調整、内製化の伴走までを保守運用・内製化支援として引き受けています。現行の計画が読めない状態のまま索引を足し続けている場合は、まず計測の土台から見直す相談をおすすめします。
よくある質問
実行計画の読み方でつまずきやすい点を5つ挙げます。
EXPLAIN ANALYZEは本番で実行しても安全ですか?
参照系なら問題ありませんが、更新系はクエリが実際に実行されます。INSERTやUPDATEを含む文で試すなら、トランザクションを開始してから実行し、最後にロールバックしてください。18ではバッファ情報が既定で付くぶん出力が長くなるため、ログへ流す設定なら記録量の増加を見込んでおいてください。
索引を作ったのにSeq Scanのままなのはなぜですか?
取り出す行が表全体の数%を超えていれば、全走査のほうが安いという計算結果です。まずEXPLAINで見積もり行数と実測行数を比べ、乖離が大きければANALYZEを流して統計を更新します。それでも変わらないなら、random_page_costとeffective_cache_sizeが実機の構成に合っていない可能性があります。検証目的ならenable_seqscanを一時的に切って索引側のコストを見比べてください。
costの数値は秒に換算できますか?
できません。公式文書はコストを、任意の単位だが慣例的にはディスクページの取得を意味する、と定義しており、時間の単位ではないためです。実測時間はEXPLAIN ANALYZEのactual timeに出ます。コストは計画同士の相対比較に使う値と割り切り、絶対値を性能指標として扱わないでください。
実行計画をJSONで保存して比較する方法はありますか?
EXPLAIN (ANALYZE, FORMAT JSON)で構造化された出力が得られます。ノードごとの推定値と実測値がキーで取れるため、変更前後の計画を機械的に突き合わせる用途に向いています。取得時のサーバ設定を残したい場合はSETTINGSオプションを併せて指定してください。
統計を更新してもプランナの判断が変わりません。次に何を見ますか?
単一列の統計では表せない条件が残っている可能性があります。複数列に条件が付いているなら拡張統計を定義し、パーティション表なら親へ手動でANALYZEを流してください。それでも動かないなら、結合表の数が探索の打ち切り境界を超えていないかを確認してください。
関連記事
- カーディナリティとは?意味・高低の見分け方とインデックス設計を実装目線で解説(見積もり精度を左右する異なり数)
- PostgreSQLのVACUUM運用|autovacuumのしきい値設計とXID周回・肥大の切り分け(可視性マップを保つ周期処理)
- N+1問題とは?仕組みと検出方法・ORM別の対策(アプリ側の反復実行の検出)
- スロークエリログとは|MySQLの設定・見方・解析ツールと改善手順(MySQLでの捕まえ方との比較)
- PostgreSQLとMySQLの違いを徹底比較|性能・データ型・全文検索・移行と使い分け(プランナの挙動差の前提)