2026年6月3日

2026年6月3日

PostgreSQLのpg_stat_statementsでクエリ統計を取る:重いクエリの特定と改善前後の比較

pg_stat_statements は、PostgreSQL が実行した SQL を形ごとにまとめ、実行回数・合計時間・読み込んだブロック数などを記録する拡張機能です。これを見ると「1回は速いが回数が多くて合計の負荷が大きいクエリ」のような、スロークエリログだけでは気づきにくい問題を見つけられます。この記事では、有効化の手順、見るべき列と集計の SQL、統計をスナップショットとして保存して改善の前後を比べる方法までを説明します。

pg_stat_statementsで分かること

pg_stat_statements は、SQL の定数部分を $1 や $2 に置き換えて「同じ形のクエリ」を1行にまとめます。たとえば WHERE id = 10 と WHERE id = 25 は同じ行に集計されます。この1行ごとに、次のような値が累積されていきます。

  • calls:実行回数
  • total_exec_time、mean_exec_time:実行時間の合計と平均(ミリ秒)
  • rows:返した、または更新した行数の合計
  • shared_blks_hit、shared_blks_read:共有バッファから読めたブロック数と、バッファになくディスク側から読んだブロック数
  • temp_blks_written:メモリに収まらず一時ファイルに書き出したブロック数

列名はバージョンで変わっています。PostgreSQL 13 で total_time が total_exec_time などに分かれ、17 でも I/O 時間の列名が変わりました。この記事は 14 以降を想定しています(2026年10月現在、13 以前はサポートが終了しています)。手元の列は \d pg_stat_statements で確認してください。

値は「拡張機能を有効にしてから、またはリセットしてからの累積」です。ある時間帯だけの傾向を見たいときは、後で説明するスナップショットの比較が必要になります。

有効化の手順

pg_stat_statements は、PostgreSQL の起動時に読み込む必要があるため、設定ファイルの変更と再起動が要ります。本番で行う前に、ステージング環境で手順と影響を確かめてください。

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'

# 必要に応じて(以下はいずれも既定値の例)
pg_stat_statements.max = 5000
pg_stat_statements.track = top

shared_preload_libraries にすでにほかのライブラリが書かれている場合は、カンマ区切りで追加します。上書きして既存のライブラリを外さないように注意してください。設定したら PostgreSQL を再起動し、統計を見たいデータベースで拡張機能を作成します。

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 動いているか確認
SELECT count(*) FROM pg_stat_statements;

統計そのものはサーバー全体で1つですが、参照用のビューは CREATE EXTENSION を実行したデータベースにだけ作られます。各行の dbid で、どのデータベースのクエリかを区別できます。

Amazon RDS などのマネージドサービスでは、設定ファイルの代わりにパラメータグループなどの管理画面で shared_preload_libraries を指定します。サービスやバージョンによっては最初から読み込まれていることもあるので、SHOW shared_preload_libraries; で確認してから作業します。

主な設定の意味は次のとおりです。

  • pg_stat_statements.max:記録するクエリの形の上限。超えると実行回数の少ないものから捨てられる
  • pg_stat_statements.track:top はアプリケーションから直接実行された文だけ、all は関数の中で実行された文も記録する
  • pg_stat_statements.track_planning:実行計画の作成時間も記録する。既定は無効で、有効にすると負荷が増えることがある

重いクエリを見つける集計

まず、合計の実行時間が大きい順に並べます。サーバーの負荷を下げたいときに、最初に見るべき一覧です。

SELECT
    queryid,
    calls,
    round(total_exec_time::numeric, 1)  AS total_ms,
    round(mean_exec_time::numeric, 2)   AS mean_ms,
    rows,
    round(100.0 * total_exec_time / sum(total_exec_time) OVER (), 1) AS pct,
    left(query, 120) AS query
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY total_exec_time DESC
LIMIT 20;

pct の列は、全体の実行時間のうちそのクエリが占める割合です。上位の数件で大半を占めていることが多く、そこから手を付けると効果が大きくなります。

見方のポイントは、合計時間・平均時間・回数を組み合わせて考えることです。

  • 平均が大きく回数が少ない:1回ごとの処理が重い。インデックスの不足や、条件の書き方を EXPLAIN で調べる
  • 平均は小さいが回数が非常に多い:アプリケーション側で同じ問い合わせを繰り返している(ループの中で1件ずつ取得している、など)。まとめて取得する、キャッシュするといった改善を考える
  • rows を calls で割った値が大きい:1回で大量の行を返している。必要な行や列だけを取るようにできないか確認する

I/O の多いクエリを探すときは、バッファから読めた割合を計算します。

SELECT
    queryid,
    calls,
    shared_blks_hit,
    shared_blks_read,
    round(100.0 * shared_blks_hit
          / nullif(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct,
    temp_blks_written,
    left(query, 120) AS query
FROM pg_stat_statements
ORDER BY shared_blks_read DESC
LIMIT 20;

shared_blks_read が多いクエリは、バッファに載っていないデータを大量に読んでいます。temp_blks_written が多いクエリは、並べ替えや集計がメモリに収まっていません。後者は work_mem の不足が原因のこともありますが、サーバー全体の値を上げるとメモリの使用量が増えるため、まずクエリ側で対象の行を減らせないかを確認します。

見つけたクエリを調べる

pg_stat_statements の query には定数が $1 などに置き換わった形が入っているので、そのまま EXPLAIN にかけることはできません。アプリケーションのログや、実際に使われている典型的な値を当てはめてから実行計画を見ます。

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 1234 ORDER BY created_at DESC LIMIT 20;

EXPLAIN ANALYZE は実際にクエリを実行します。UPDATE や DELETE を調べるときは、BEGIN; と ROLLBACK; で囲んで、変更が残らないようにしてください。

実行時のパラメーターごとに遅くなるなど再現が難しい場合は、同梱の拡張機能 auto_explain を使うと、一定時間を超えたクエリの実行計画をログに出せます。こちらもログの量と負荷が増えるため、しきい値を高めにして一時的に使うのが無難です。

スナップショットで改善の前後を比べる

累積値のままでは、インデックスを追加した効果が分かりにくくなります。統計を定期的にテーブルへ保存しておけば、任意の2時点の差分を計算できます。

CREATE TABLE IF NOT EXISTS pss_snapshot (
    taken_at        timestamptz NOT NULL DEFAULT now(),
    userid          oid,
    dbid            oid,
    queryid         bigint,
    calls           bigint,
    total_exec_time double precision,
    rows            bigint,
    shared_blks_read bigint,
    query           text
);

-- cron などから定期的に実行する
INSERT INTO pss_snapshot
    (userid, dbid, queryid, calls, total_exec_time, rows, shared_blks_read, query)
SELECT userid, dbid, queryid, calls, total_exec_time, rows, shared_blks_read, query
FROM pg_stat_statements
WHERE toplevel;

2つの時点の差を取ると、その間に実行された分だけの統計になります。

WITH a AS (
    SELECT * FROM pss_snapshot WHERE taken_at = '2026-10-15 09:00+09'
), b AS (
    SELECT * FROM pss_snapshot WHERE taken_at = '2026-10-15 10:00+09'
)
SELECT
    b.queryid,
    b.calls - coalesce(a.calls, 0) AS calls,
    round((b.total_exec_time - coalesce(a.total_exec_time, 0))::numeric, 1) AS total_ms,
    left(b.query, 100) AS query
FROM b
LEFT JOIN a USING (userid, dbid, queryid)
ORDER BY total_ms DESC
LIMIT 20;

時刻は実際に保存した taken_at の値に置き換えてください。途中で統計がリセットされたり、上限を超えてエントリが捨てられたりすると差分が負の値になることがあります。PostgreSQL 14 以降では pg_stat_statements_info ビューで、最後にリセットした時刻と、捨てられた回数(dealloc)を確認できます。dealloc が増え続けている場合は、pg_stat_statements.max を増やすことを検討します。

統計をまるごとリセットしたいときは SELECT pg_stat_statements_reset(); を実行します。全員分の統計が消えるので、ほかの担当者と共有しているサーバーでは事前に知らせておきます。

権限と運用上の注意

一般のユーザーでは、ほかのユーザーが実行したクエリの文面は表示されません。監視用のユーザーには、組み込みのロール pg_read_all_stats を付与すると、すべてのクエリを参照できます。

GRANT pg_read_all_stats TO monitor_user;

クエリの文面では、条件に使われた値の多くは $1 などに置き換わりますが、文の種類によっては値がそのまま残る場合があり、テーブル名や列名などのシステムの構造も含まれます。監視ツールに送る場合や、スナップショットを外部に出す場合は、その点も含めて扱いを決めてください。

pg_stat_statements の記録による負荷は、多くの環境で小さいとされていますが、記録するクエリの形が非常に多いシステムでは影響が出ることもあります。導入後はサーバーの負荷を確認し、必要に応じて pg_stat_statements.max や track の設定を見直します。

まとめ

  • pg_stat_statements は、SQL を形ごとにまとめて回数・時間・I/O を累積する拡張機能。shared_preload_libraries への追加と再起動、CREATE EXTENSION で有効になる
  • 合計時間の多い順に並べ、平均時間・回数・行数を組み合わせて原因の種類を見分ける
  • 見つけたクエリは実際の値を当てはめて EXPLAIN (ANALYZE, BUFFERS) で調べる
  • 累積値は定期的にスナップショットとして保存し、2時点の差分で改善の効果を確かめる
  • 列名や関数の引数はバージョンで変わるので、手元のバージョンで確認する

お気軽にご相談ください

お見積りへ お問い合わせへ
▲