Claude Media
Claude Codeでpg_stat_statementsの遅いクエリを集計させる手順

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 で割ると、次のようになります。

クエリ(文書のサンプル)callstotal_exec_time(ms)割って得た平均(ms)
UPDATE pgbench_branches ...calls3000total_exec_time(ms)25565.855387割って得た平均(ms)約8.52
UPDATE pgbench_tellers ...calls3000total_exec_time(ms)20756.669379割って得た平均(ms)約6.92
copy pgbench_accounts from stdincalls1total_exec_time(ms)291.865911割って得た平均(ms)291.87
UPDATE pgbench_accounts ...calls3000total_exec_time(ms)271.232977割って得た平均(ms)約0.09
alter table pgbench_accounts add primary key (aid)calls1total_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 = all

compute_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つのリストと分類の表になります。次は形式の例です(数値は文書のサンプルの再掲で、実機の出力ではありません)。

分類queryidcallstotal_msmean_ms候補
回数を減らすqueryid(取得値)calls3000total_ms25565.9mean_ms8.52候補同一文の反復実行。まとめ実行の余地を見る
1回を軽くするqueryid(取得値)calls1total_ms291.9mean_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 を毎回冒頭に出させる形にしておくと、数値の読み違いが減ります。変更の判断と実行は、候補を受け取った人が行います。

参照した一次ソース

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

  1. code.claude.com/docs/en/sub-agents
  2. postgresql.org/docs/current/pgstatstatements.html
この記事を共有:XはてブLinkedIn