Claude Codeでpg_stat_statementsの遅いクエリを集計させる手順
pg_stat_statementsの上位クエリを、読み取り専用ロールを持つサブエージェントに取らせます。総時間順と平均時間順の2本のリストで、改善対象の性質を切り分ける流れです。
PostgreSQLの遅いクエリを探すとき、pg_stat_statements を total_exec_time の降順で眺めて終わりにすると、見落としが出ます。累計で時間を食っているクエリと、1回あたりが重いクエリは別物だからです。
この記事では、その2つの見方を、Claude Codeのサブエージェントに別々のリストとして作らせる手順を扱います。サブエージェントに渡すのは、統計を読むだけのロールと、書き込みを止めるhookです。結果の行がメイン会話のコンテキストを埋めず、候補の要約だけが戻ります。
総時間順と平均時間順は、別の問いに答える
pg_stat_statements は、サーバーが実行したSQLの計画と実行の統計を集めるモジュールです。ビューの1行は、データベースID・ユーザーID・クエリID・トップレベルかどうか、の組み合わせごとに1つ作られます。
並べ替えに使う列は3つです。
| 列 | 中身 | 単位 |
|---|---|---|
calls | 中身文が実行された回数 | 単位回 |
total_exec_time | 中身実行に使った時間の合計 | 単位ミリ秒 |
mean_exec_time | 中身実行時間の平均 | 単位ミリ秒 |
total_exec_time は、calls と mean_exec_time の掛け算に相当する量です。つまり総時間順の上位には、1回は軽いが大量に呼ばれるクエリが入ります。平均時間順の上位には、回数は少ないが1回が重いクエリが入ります。直す手段も変わるので、リストを分けて作る意味があります。
PostgreSQL文書のサンプル出力で、2つの順序がどれだけずれるかを見られます。そこに載る5件の total_exec_time を calls で割ると、次のようになります。
| クエリ(文書のサンプル) | calls | total_exec_time(ms) | 割って得た平均(ms) |
|---|---|---|---|
UPDATE pgbench_branches ... | calls3000 | total_exec_time(ms)25565.855387 | 割って得た平均(ms)約8.52 |
UPDATE pgbench_tellers ... | calls3000 | total_exec_time(ms)20756.669379 | 割って得た平均(ms)約6.92 |
copy pgbench_accounts from stdin | calls1 | total_exec_time(ms)291.865911 | 割って得た平均(ms)291.87 |
UPDATE pgbench_accounts ... | calls3000 | total_exec_time(ms)271.232977 | 割って得た平均(ms)約0.09 |
alter table pgbench_accounts add primary key (aid) | calls1 | total_exec_time(ms)160.588563 | 割って得た平均(ms)160.59 |
総時間順では UPDATE pgbench_branches が先頭です。平均時間順に並べ直すと、先頭は1回しか実行されていない copy で、alter table が続き、先頭グループだった UPDATE 2件は3位と4位に下がります。calls が1の行は平均が偶然の1回分なので、平均時間順のリストには calls の下限を付けて、1回きりの実行を除きます。
読み取り専用ロールを用意する
サブエージェントが接続するロールは、統計が読めて何も変更できない権限にします。統計の読み取りには条件があります。
他のユーザーが実行したクエリの文面と queryid は、スーパーユーザーか pg_read_all_stats ロールの権限を持つロールにしか見えません。それ以外のロールには統計の数値は見えるものの、どのクエリの数値なのか分かりません。上位クエリを調べる目的では、pg_read_all_stats が要ります。
-- 管理者権限で一度だけ実行する(ロール名とパスワードは例)
CREATE ROLE stats_reader LOGIN PASSWORD 'change-me';
GRANT pg_read_all_stats TO stats_reader;拡張自体が有効になっているかも先に見ます。このモジュールは postgresql.conf の shared_preload_libraries に登録し、サーバーを再起動して読み込みます。追加の共有メモリが要るためで、再起動なしには足せません。そのうえで、対象のデータベースで CREATE EXTENSION pg_stat_statements を実行すると、ビューが使えるようになります。
# postgresql.conf(文書にある設定例)
shared_preload_libraries = 'pg_stat_statements'
compute_query_id = on
pg_stat_statements.max = 10000
pg_stat_statements.track = allcompute_query_id は auto か on なら、クエリ識別子の計算が有効になります。pg_stat_statements.track の既定は top で、クライアントが直接発行した文だけを数えます。関数の中から呼ばれる文も見たいときは all にしますが、設定を変えられるのはスーパーユーザーだけです。この調整はサブエージェントの仕事ではなく、人が決めて本番に反映するものです。
接続文字列は、PG_STATS_URL のような環境変数に入れて渡します。定義ファイルやプロンプトにパスワードを書き込まない構成にするためです。
サブエージェントの定義ファイル
Claude Codeのサブエージェントは、.claude/agents/ に置くMarkdownファイルで、YAMLのfrontmatterとシステムプロンプトでできています。必須なのは name と description だけです。tools を省くと全ツールを継承するので、使うツールを Bash に絞ります。
---
name: pg-stats-analyst
description: pg_stat_statementsの上位クエリを読み取り専用で集計し、改善候補を並べる。Postgresの遅いクエリ調査で使う。
tools: Bash
model: sonnet
maxTurns: 12
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/validate-readonly-query.sh"
---
あなたはPostgreSQLの統計を読む分析担当です。接続は環境変数 PG_STATS_URL で
psql から行い、SELECT 文だけを実行します。データやスキーマは変更しません。
手順:
1. pg_stat_statements_info の stats_reset と dealloc を取り、集計期間を書く
2. 総時間順(total_exec_time の降順)の上位10件を取る
3. 平均時間順(mean_exec_time の降順、calls が20以上)の上位10件を取る
4. 各リストの行ごとに、calls・total・mean・rows を並べ、
「回数を減らす候補」「1回を軽くする候補」のどちらかに分類する
5. 2つのリストに共通して現れるクエリを先頭に書く
クエリの文面は queryid と先頭200文字だけ返す。結果の行そのものを
全部貼らず、候補の表と根拠の数値だけを返す。model・maxTurns・hooks は、公式のfrontmatter一覧にあるフィールドです。maxTurns に達するとサブエージェントは途中結果を部分的な出力として返します。クエリ調査が長引いても、無制限には走りません。calls >= 20 のような下限は、先ほどの calls が1の行を除くための値で、環境に合わせて変えます。
hookの検証スクリプトは、公式の db-reader の例が土台です。書き込み系の語を含むBashコマンドを、終了コード2で止めます。定義ファイルのhooksの書き方、スクリプトの全文、権限の付け忘れで検査がすり抜ける条件は、サブエージェントをhookで読み取り専用のDB分析役にするで詳しく扱っています。
公式のスクリプトだけでは止まらない呼び出し
pg_stat_statements を扱うときだけ、押さえておきたい穴があります。統計を消す pg_stat_statements_reset は、SELECT 文から呼び出す関数です。文書のサンプルでも SELECT pg_stat_statements_reset(); と書かれています。
公式のスクリプトが止める語は INSERT UPDATE DELETE DROP CREATE ALTER TRUNCATE REPLACE MERGE の9語です。SELECT pg_stat_statements_reset() にはどれも含まれないため、語のリストだけでは通ります。
落とし穴
統計のリセットはSELECTで呼べる
pg_stat_statements_reset を引数なしで呼ぶと、蓄積した統計がすべて捨てられます。userid dbid queryid を指定した場合は、一致する分だけが対象です。リセットされると集計の起点が変わり、過去の累計との比較ができなくなります。調査のたびに「前回の総時間」を持ち越して比べる運用では、気づかないうちに比較が壊れます。
対策は、スクリプトの語リストに関数名を足すことです。
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
[ -z "$COMMAND" ] && exit 0
# 公式の9語に、統計を消す関数名を加える
if echo "$COMMAND" | grep -iE \
'\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|REPLACE|MERGE|pg_stat_statements_reset)\b' \
> /dev/null; then
echo "Blocked: write or reset operations are not allowed." >&2
exit 2
fi
exit 0語単位の一致なので、ILIKE '%update%' のようにリテラルの中に禁止語が入ったコマンドも止まります。分析の絞り込みで文面を検索したくなったときは、サブエージェントの返答に「ブロックされた」と出るため、条件を変えて再実行させます。止めすぎる側に倒れる設計なので、見逃しよりは扱いやすい失敗です。hookは最後の歯止めなので、DB側のロール権限と二重にする前提で使います。
2本のリストを取るクエリ
サブエージェントに任せる前に、人の手で同じ結果が出るかを確かめておくと、あとで出力を信用しやすくなります。
総時間順は、累計で最も時間を使ったクエリです。
SELECT queryid,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows,
left(query, 200) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;平均時間順は、calls に下限を付けて1回あたりが重いものを拾います。ばらつきも見たいので、max_exec_time と stddev_exec_time を足します。
SELECT queryid,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(max_exec_time::numeric, 1) AS max_ms,
round(stddev_exec_time::numeric, 1) AS stddev_ms,
left(query, 200) AS query
FROM pg_stat_statements
WHERE calls >= 20
ORDER BY mean_exec_time DESC
LIMIT 10;集計期間は別のクエリで取ります。pg_stat_statements_info は1行だけのビューで、全統計を最後にリセットした時刻の stats_reset と、保持しきれず捨てた回数の dealloc を持ちます。
SELECT stats_reset, dealloc FROM pg_stat_statements_info;dealloc が増えていると、最も実行回数の少ない文の統計から捨てられています。つまり平均時間順で見たい「回数は少ないが重い」クエリほど、追跡から落ちている可能性があります。pg_stat_statements.max(既定5000、サーバー起動時のみ変更可)を超える種類のクエリが動く環境では、この値を毎回リストの冒頭に出させます。
Claude Codeに依頼する
定義ファイルを置いたら、メイン会話から呼び出します。サブエージェントの名前を指定すると、Claude Codeが委任します。確実に走らせたいときは @ でサブエージェントを選びます。
pg-stats-analystサブエージェントで、pg_stat_statementsの総時間順と平均時間順の
上位10件を取り、改善候補を回数の削減と1回の軽量化に分けて表にしてサブエージェントは別のコンテキストで動くので、20行分のSQLテキストや統計値はメイン会話に残りません。公式ドキュメントも、ログの処理やテストの実行のように出力が膨らむ作業を、サブエージェントに隔離する使い方を挙げています。戻ってくるのは要約だけです。
返答の形は、プロンプトで決めた2つのリストと分類の表になります。次は形式の例です(数値は文書のサンプルの再掲で、実機の出力ではありません)。
| 分類 | queryid | calls | total_ms | mean_ms | 候補 |
|---|---|---|---|---|---|
| 回数を減らす | queryid(取得値) | calls3000 | total_ms25565.9 | mean_ms8.52 | 候補同一文の反復実行。まとめ実行の余地を見る |
| 1回を軽くする | queryid(取得値) | calls1 | total_ms291.9 | mean_ms291.87 | 候補単発の重い処理。計画を見る対象 |
実際のサブエージェントの返答は、環境のクエリと統計に左右されます。表の形が崩れたり、分類が曖昧だったりしたら、プロンプトの手順4に分類の基準(たとえば「callsが1000以上でmeanが10ms未満なら回数側」)を数値で書き足します。
数値を読むときに外せない注意点
候補を人に渡す前に、サブエージェントの出力のうち次の点を点検します。いずれも文書に書かれている仕様です。
- 定数は
$1に置き換わる: 定数だけが違うクエリは同じ行にまとまり、文面には最初に来たクエリのテキストが残ります。IN句の要素数だけが違う場合も1行にまとまります。 queryidの安定性には限度がある: 内部のオブジェクト識別子から計算されるので、論理レプリケーションの複製間では累計の突き合わせに使えません。同じ文面でもsearch_pathが違えば別の行になります。- 計画時間は既定で取られない:
pg_stat_statements.track_planningの既定はoffで、plansと*_plan_timeは0のままです。オンにすると、同じ構造の文を多数の接続が同時に実行する環境では、性能への影響が出るおそれがあると書かれています。 - 最小・最大は別の起点を持つ:
min_exec_timeとmax_exec_timeは、minmax_onlyを指定したリセットの後は、その後の実行が来るまで0です。minmax_stats_sinceで起点を確かめます。 callsとplansは一致しない: 計画と実行は、それぞれの終了時に成功した分だけ更新されます。
総時間順の上位が、pg_stat_statements に問い合わせる監視クエリ自身で埋まることもあります。文書のサンプルでも、リセット直後の上位は統計を読むクエリでした。結果から監視用のクエリを除くよう、プロンプトに一行足しておきます。
候補を実際の改善につなげる
サブエージェントの仕事は候補を並べるところまでで、変更は別の手順にします。たとえば総時間順の上位で calls が大きく平均が小さい行は、1回を速くしても全体の時間は大きくは減りません。呼び出しの回数そのものを、アプリケーション側のコードで減らす余地を見ます。平均時間順の上位は、該当の文を EXPLAIN で見て、インデックスやクエリの書き方を検討する対象です。
EXPLAIN の実行や改善案の提示まで任せたい場合は、データベース側の診断機能を持つMCPサーバーやプラグインの出番です。Postgres MCP Proの使い方では索引の提案まで行う構成を、CockroachDBプラグインではクエリ診断をClaude Codeに任せる流れを扱っています。この記事のように読み取り専用のSELECTだけで済む調査は、サブエージェントにBashだけを渡す構成のほうが権限を絞りやすい作りです。
サブエージェントの作り方や、フォアグラウンドとバックグラウンドの違いはClaude Code Subagents完全ガイドにまとまっています。hookが失敗したときに操作を止める設定は、hooksのonFailureで失敗時に操作を止める設定が扱います。
まとめ
遅いクエリ探しの核心は、1本のリストにせず2本に分けることです。総時間順は「全体でどこに時間が消えているか」、平均時間順は「1回が重いのはどれか」に答えます。文書のサンプルを割り算するだけでも、先頭の入れ替わりが分かりました。
サブエージェントには、pg_read_all_stats を持つ読み取りロールと、リセット関数を加えたhookを渡し、集計期間と dealloc を毎回冒頭に出させる形にしておくと、数値の読み違いが減ります。変更の判断と実行は、候補を受け取った人が行います。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。