Claude Media
Claude CodeでEXPLAIN ANALYZEの実行計画を読む — 遅いクエリの絞り込み手順

Claude CodeでEXPLAIN ANALYZEの実行計画を読む — 遅いクエリの絞り込み手順

EXPLAIN ANALYZEはクエリを実際に実行します。BEGINとROLLBACKで包む取得スクリプト、Claude Codeの権限設定、推定行数のずれの読み方、索引追加後の取り直しまでを手順にしました。

遅いクエリの原因を絞るには、PostgreSQLの EXPLAIN (ANALYZE, BUFFERS) の出力をClaude Codeに読ませるのが速い方法です。ただし、この文を実行すると、クエリは実際に動きます。更新系の文なら、テーブルの中身も実際に変わります。

先に決めておくのは、取得の安全装置です。計画の読解はその後でも間に合います。この記事では、取得を決まった入口に閉じ込める構成から始めます。続けて、出力の中で見る場所、推定行数のずれの扱い、索引を足したあとの取り直しを順に説明します。

EXPLAIN ANALYZEの前に決める3つのこと

EXPLAIN だけなら、プランナーが立てた計画を表示するだけで、クエリは動きません。ANALYZE を付けると文が実際に実行され、各ノードの実測時間と実際の行数が加わります。PostgreSQLのマニュアルは、SELECTの結果は捨てられるが、それ以外の副作用は通常どおり起きると明記しています。

Claude Codeに任せる前に、次の3点を決めます。

  • どこで動かすか: 本番ではなく、検証用の環境かレプリカを使う構成が候補になります。実行する以上、負荷がかかるためです
  • 更新系をどう扱うか: INSERT・UPDATE・DELETE・MERGEは BEGIN と ROLLBACK で包みます。マニュアルが示している方法です
  • 誰がDBに触るか: Claude Codeに接続情報を渡して psql を自由に打たせるのか、決まった入口だけを許すのかを選びます

三つ目は、Claude Codeの権限設計の話です。次の節で入口を一本にします。

取得を一本のスクリプトに閉じ込める

SQLファイルを渡すと、BEGIN と ROLLBACK で包んで EXPLAIN (ANALYZE, BUFFERS, SETTINGS) を実行し、結果を plans/ に保存するスクリプトを用意します。Claude Codeには、このスクリプトと保存済みの結果だけを触らせます。

#!/usr/bin/env bash
# scripts/explain.sh — SQLファイルを1つ受け取り、実行計画を plans/ に保存する
set -euo pipefail
 
sql_file="$1"
out="plans/$(basename "${sql_file%.sql}").txt"
 
# ファイル側のCOMMITでトランザクションが確定するのを防ぐ
if grep -qi 'commit' "$sql_file"; then
  echo "COMMITを含むSQLは対象外です" >&2
  exit 1
fi
 
mkdir -p plans
{
  echo 'BEGIN;'
  echo 'EXPLAIN (ANALYZE, BUFFERS, SETTINGS)'
  cat "$sql_file"
  echo ';'
  echo 'ROLLBACK;'
} | psql -X -v ON_ERROR_STOP=1 "$DATABASE_URL" > "$out"

このスクリプトは例です。$DATABASE_URL には、検証用DBかレプリカの接続文字列を入れます。更新系の文は ROLLBACK で元に戻りますが、戻るのはテーブルの変更です。関数が外部のサービスを呼ぶような処理は、トランザクションでは取り消せません。そうした関数を含む文は、スクリプトに通さない運用が無難です。

SETTINGS を付けるのは、プランナーの設定が既定値と違う場合に、その差が出力へ載るからです。Claude Codeに原因を絞らせるとき、設定の差は手がかりになります。

権限の設定: 入口以外を閉じる

Claude Codeの権限ルールは、deny、ask、allow の順に評価され、最初に当たったものが結果になります。広い deny に対して、狭い allow で例外を作ることはできません。この順序を前提に、プロジェクトの .claude/settings.json を組みます。

{
  "permissions": {
    "allow": [
      "Bash(./scripts/explain.sh *)",
      "Read(./queries/**)",
      "Read(./plans/**)"
    ],
    "deny": [
      "Bash(psql *)",
      "Read(./.env)",
      "Edit(/scripts/explain.sh)"
    ]
  }
}

Bash(psql *) を deny に置くと、Claudeが書いた psql ... は止まります。一方で、Bashルールは書かれたコマンドの文字列に対して当たります。/usr/bin/psql のようにパスを付けた形や、sh -c の中の psql は、同じルールでは止まりません。ルールは歯止めの一枚で、境界ではありません。

境界が必要なら、二つの手があります。ひとつはDB側の権限です。接続文字列のロールを、検証用DBの必要な範囲に絞ります。もうひとつはサンドボックスで、ファイルとネットワークをコマンドの文字列に頼らずに制限できます。

Edit(/scripts/explain.sh) の deny は、取得スクリプト自体を書き換えさせないためです。/ で始まるパスは、設定ファイルのあるプロジェクトの基準で解釈されます。Bash(./scripts/explain.sh *) の * には任意の引数が入るので、引数の中身は検査されません。SQLファイルの中身を縛る役は、スクリプト側の commit 検査とDBロールが持ちます。

権限の書き方をほかの言語や道具で見たい場合は、OpenTofuでvalidateとplanだけ許可する権限設計が同じ型で組んでいます。

CLAUDE.mdに書いておく運用ルール

入口を閉じても、Claudeが別の手を試す余地は残ります。方針は CLAUDE.md にも書いておくと、最初の動きが揃います。

## 遅いクエリの調査
- 実行計画は `scripts/explain.sh queries/<名前>.sql` で取得し、`plans/<名前>.txt` を読む
- psql を直接実行しない。DDL(CREATE INDEX など)は提案だけして、実行しない
- 計画を読むときは、推定行数と実際の行数、loops、Buffers を必ず表にして示す
- 原因の仮説は、確かめる手順とセットで出す

CLAUDE.md は動作の傾向を作るもので、強制力はありません。強制は前の節の deny が担います。二つを併用する構成です。

出力を渡す: クエリ、計画、索引の三点セット

Claude Codeに渡す材料は三つです。

  1. クエリ本体(queries/ のSQLファイル)
  2. 実行計画(plans/ のテキスト)
  3. 関係するテーブルの定義と索引の一覧(psql の \d テーブル名 の出力など)

計画だけ渡しても、索引が存在しないのか、あるのに使われていないのかは区別できません。三点がそろうと、「索引が無い」「あるが使えない形でクエリが書かれている」「あるが統計が古い」の切り分けに進めます。

手順

取得から仮説までの流れ

  1. 1

    SQLファイルを作る

    queries/slow-orders.sql にクエリを書きます。末尾のセミコロンは付けても付けなくても構いません。

  2. 2

    スクリプトで計画を取る

    ./scripts/explain.sh queries/slow-orders.sql を実行し、plans/slow-orders.txt を作ります。

  3. 3

    計画と定義を読ませる

    計画ファイルと、テーブル定義の出力を渡し、推定と実測の差が大きいノードから順に挙げさせます。

  4. 4

    仮説を一つずつ確かめる

    仮説ごとに、確かめるSQLを分けて出させます。まず統計の更新、次に索引の候補、という順です。

依頼の文は、見る場所を指定します。たとえば次のとおりです。

plans/slow-orders.txt を読んで、次の順で整理してください。
1. 各ノードの推定行数(rows)と実際の行数(actual rows)を表にする
2. loops が1より大きいノードは、actual time × loops で合計時間を出す
3. LIMIT で打ち切られたノードなど、ずれが「誤差ではない」ものは区別する
4. 仮説は最大3つ。それぞれに、確かめる手順を付ける
CREATE INDEX などのDDLは実行せず、文面の提案だけにしてください。

3番は、次の節の内容に対応します。計画のずれがすべて問題ではないため、読む側に区別の基準を持たせておくのです。

計画の読み方: どこに原因が出るか

PostgreSQLのマニュアルは、実行計画の読み方で最初に見る場所を明確にしています。実測時間の単位はミリ秒で、コストは任意の単位です。数字は一致しません。最も重要なのは、推定行数が実際の行数に近いかどうかです。

推定行数と実際の行数のずれ

次の出力は、読み方の練習用に組み立てた架空の例です。実機で取った出力ではありません。

Nested Loop  (cost=0.43..21034.10 rows=12 width=72)
             (actual time=0.05..912.40 rows=48000.00 loops=1)
  Buffers: shared hit=52000 read=6334
  ->  Seq Scan on orders o  (cost=0.00..18334.00 rows=12 width=40)
                            (actual time=0.02..310.80 rows=48000.00 loops=1)
        Filter: (status = 'pending'::text)
        Rows Removed by Filter: 952000
        Buffers: shared hit=2000 read=6334
  ->  Index Scan using customers_pkey on customers c
        (cost=0.43..8.45 rows=1 width=32)
        (actual time=0.012..0.012 rows=1.00 loops=48000)
        Index Cond: (id = o.customer_id)

見る順番は次のとおりです。

  • Seq Scan on orders の推定は rows=12 で、実際は rows=48000.00 です。四桁の差があります
  • Rows Removed by Filter: 952000 から、約100万行を読んで4万8千行だけ残していると分かります
  • 内側の Index Scan は1回あたり0.012msですが、loops=48000 です。マニュアルの説明どおり、actual time と rows は1回あたりの平均なので、loops を掛けて合計を出します。0.012 × 48000 ≒ 576msで、全体の912msの大半を占めます

推定が12行だったので、プランナーは「外側が少ないから入れ子ループが安い」と判断しています。実際は4万8千行あり、入れ子ループが重くなりました。原因の候補は、orders の統計が古いこと、status の偏りをプランナーが見積もれていないことなどです。どちらかは、この出力だけでは決まりません。

そこで、Claude Codeには断定ではなく、確かめる手順を出させます。たとえば ANALYZE orders; のあとにスクリプトを再実行し、推定行数が動くかを見ます。動けば統計が原因で、動かなければ見積もりの前提が別にあります。

ずれていても問題ではない場合

マニュアルは、推定と実測が合わなくても、何も壊れていないケースを挙げています。

  • LIMITで打ち切られたノード: コストと行数は最後まで実行した想定で表示されますが、実際には必要な行数で止まります。誤差ではなく、表示の仕様です
  • マージ結合: 片方の入力を読み切る前に止まることがあります。外側に重複キーがあると、内側を巻き戻して数え直すため、内側の実際の行数が、表の行数より大きく出ることがあります
  • BitmapAndとBitmapOr: 実装上の制約で、実際の行数が常に0と表示されます
  • 小さなテーブル: 1ページに収まる表は、索引があっても順次スキャンが選ばれるのが普通です

これらを知らないと、Claudeが「推定が外れているので統計を疑うべき」と、正常な出力まで問題扱いする可能性があります。前の節の依頼文に3番を入れたのは、この誤読を減らすためです。

そのほかに見る行

出力の行読み取れること
Buffers: shared hit=… read=…読み取れることhit はキャッシュで足りたブロック、read は読み込んだブロック
Sort Method: …読み取れることメモリ内か、ディスクを使った並べ替えか
Batches: …(Hash)読み取れること1を超えるとハッシュ表がメモリに収まらず、ディスクを使う
Rows Removed by Filter読み取れること読んだが捨てた行数。大きいと、絞り込みに索引が効いていない可能性がある
Planning Time / Execution Time読み取れること計画の作成と実行にかかった時間

BUFFERS はANALYZEと一緒に指定すると自動で有効になります。この記事のスクリプトで明示しているのは、読む側に意図を伝えるためです。

索引を足して取り直す

仮説の中に索引の追加が出たら、実行はあなたが行います。Claude Codeに出させるのは、文面と、取り直すときの比較の観点です。

# 索引を作る前の計画を保存し、名前を変えて残す
cp plans/slow-orders.txt plans/slow-orders.before.txt
 
# 検証用DBで索引を作る(実行は人が行う)
psql -X "$DATABASE_URL" -c "CREATE INDEX orders_status_idx ON orders (status);"
psql -X "$DATABASE_URL" -c "ANALYZE orders;"
 
# 同じSQLファイルで取り直す
./scripts/explain.sh queries/slow-orders.sql

取り直したら、plans/slow-orders.before.txt と plans/slow-orders.txt を並べて渡し、次の観点で比べさせます。

  • Seq Scan が Index Scan や Bitmap Heap Scan に変わったか
  • Execution Time が下がったか。下がり方は一度の測定でなく、複数回の傾向で見る
  • Buffers の read が減ったか。hit と read は、直前にキャッシュが温まっていたかでも変わる
  • 推定行数と実際の行数の差は縮まったか

測定には限界もあります。マニュアルによれば、EXPLAIN ANALYZEの時間はクライアントへの転送時間を含みません。時刻を何度も読むオーバーヘッドが、環境によっては小さくないとも書かれています。ノードごとの時間が要らず行数だけ見たいなら、TIMING OFF を使えます。出力の変換にかかる時間まで測りたいときは、SERIALIZE を指定します。

もう一つ、マニュアルは「結果を大きく違う状況に当てはめない」と注意しています。小さな表で出た計画は、大きな表には当てはまりません。検証用DBのデータ量が本番と桁違いに少ないと、索引が使われない計画になり、結論を誤ります。

落とし穴

索引を足したのに計画が変わらない

小さな表では順次スキャンが選ばれ続けます。表の大きさや統計を確かめる前に、索引が悪いと判断しないでください。ANALYZE を実行して統計を更新したか、検証用DBの行数が実際に近いかを、先に見ます。

MCPサーバーと使い分ける

計画の取得から索引の提案までを、MCPサーバーに任せる方法もあります。Postgres MCP Proの使い方は、索引提案とヘルスチェックを主題にしています。この記事の方法は、MCPを入れずにSQLファイルとスクリプトだけで済ませ、計画の読み方を自分の側に残す方法です。

設計の側から相談したい場合は、pg-aiguideでPostgresスキーマ設計を任せる3つの入れ方が参考になります。マネージドのPostgreSQLに接続する場合は、Cloud SQL for PostgreSQLプラグインの接続設定が先になります。

どの方法でも、EXPLAIN ANALYZE がクエリを実行する点は変わりません。接続先とロールの設計が、安全性の大半を決めます。

まとめ

実行計画の読解をClaude Codeに任せるときの要点は、取得の経路を先に絞ることです。更新系の文を BEGIN と ROLLBACK で包むスクリプトを入口にし、psql の直接実行は deny で止めます。止められない書き方が残るので、DBロールかサンドボックスで境界を作ります。

読解は、推定と実測のずれ、loops を掛けた合計時間、Rows Removed by Filter、Buffers の順に見るよう指定します。ずれのすべてが異常ではありません。LIMITやマージ結合の表示の癖は、先に伝えておきます。

索引を足したら、同じSQLファイルで計画を取り直し、前後を並べて比べます。取り直しまでやって初めて、仮説が当たったかどうかが分かります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. postgresql.org/docs/current/using-explain.html
  2. postgresql.org/docs/current/sql-explain.html
  3. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn