「PostgreSQL が遅い」と相談されたとき、最初から設定値やインデックスを変更するのは危険です。まず必要なのは、どの SQL が、どの条件で、どれくらい遅くなっているのかを切り分けることです。
この記事では、PostgreSQL のクエリ性能問題を調べるときに、最初に確認したいポイントを順番に整理します。
参考にした公式ドキュメントは PostgreSQL 18 です。基本的な考え方は古いバージョンでも大きく変わりませんが、利用できる統計項目や表示内容はバージョンによって異なる場合があります。
ステップ1: まず「どのクエリが遅いか」を特定する
闇雲に最適化しても意味がありません。最初にやるべきことは、調査対象のクエリを絞り込むことです。
pg_stat_statements を使う
pg_stat_statements 拡張が有効になっていれば、実行された SQL の回数・合計時間・平均時間を集計できます。
SELECT
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
substr(query, 1, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
「1 回あたりは速いが何千回も呼ばれているクエリ」と「1 回が極端に遅いクエリ」の両方が見つかります。前者はアプリ側の N+1 問題、後者はインデックス不足や統計情報の陳腐化が原因になっていることがあります。
なお、pg_stat_statements は shared_preload_libraries への追加が必要です。未導入の環境で新しく有効化する場合は、設定変更後に PostgreSQL の再起動が必要になります。
拡張が入っていない場合は postgresql.conf でスロークエリログを有効にします。
log_min_duration_statement = 1000 # 1秒以上かかったクエリをログに残す
log_min_duration_statement のように再読み込みで反映できる設定であれば、設定後に SELECT pg_reload_conf(); を実行します。
ステップ2: EXPLAIN ANALYZE で実行計画を読む
調査対象のクエリが特定できたら、EXPLAIN ANALYZE で実行計画を確認します。
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 12345;
BUFFERS オプションを付けると、共有バッファで読めたブロック数やディスクから読まれたブロック数を確認できます。
EXPLAIN ANALYZE は実際に SQL を実行します。UPDATE や DELETE など更新系の SQL を確認する場合は、検証環境で実行するか、トランザクション内で実行して ROLLBACK するなど、データを変更しない手順を取ってください。
Seq Scan が出ていたら妥当性を確認する
Seq Scan on orders (cost=0.00..45231.00 rows=1 width=300)
(actual time=0.032..892.341 rows=1 loops=1)
テーブル全体を読んでいます。ただし、Seq Scan は常に悪いわけではありません。小さなテーブルや、大量の行を返すクエリでは、インデックスを使うより順次スキャンのほうが妥当なこともあります。
一方で、対象行が少ないはずなのに大きなテーブルを全件読んでいる場合は、インデックス不足や条件式の書き方を疑います。
推定行数と実績行数の乖離に注目する
(cost=... rows=1000 ...) ← PostgreSQL の推定
(actual time=... rows=50000 ...) ← 実際の行数
推定と実績が大きくずれている場合、統計情報が古い、またはデータ分布を十分に表現できていない可能性があります。
よくある原因と対処
原因1: インデックスが貼られていない
WHERE 句・JOIN 条件・ORDER BY に使われるカラムに適切なインデックスがないと、不要に多くの行を読むことがあります。
-- CONCURRENTLY なら通常の書き込みを止めにくい
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);
CREATE INDEX CONCURRENTLY は運用中の追加に向いていますが、通常の CREATE INDEX より時間と I/O がかかります。また、トランザクションブロック内では実行できません。
インデックスを増やしすぎると INSERT / UPDATE が遅くなります。本当に必要なものだけ追加しましょう。
原因2: 統計情報が古い
PostgreSQL はテーブルの統計情報(行数分布など)をもとに実行計画を立てます。統計情報が古いと、見当外れな実行計画を選んでしまいます。
ANALYZE orders; -- 対象テーブルの統計情報を更新
autovacuum が正常に動いていれば定期的に自動更新されますが、大量データを一括投入した直後は手動実行が確実です。
原因3: テーブルの肥大化(bloat)
PostgreSQL は MVCC の仕組み上、DELETE / UPDATE 後の古い行バージョンをすぐには再利用可能にしません。長期間 VACUUM が追いついていないと、テーブルやインデックスが肥大化し、読み取り量が増えることがあります。
-- デッドタプルが多いテーブルを確認
SELECT
relname,
pg_size_pretty(pg_total_relation_size(oid)) AS total_size,
n_dead_tup
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
VACUUM ANALYZE orders;
原因4: N+1 クエリ
アプリ側の問題ですが、データベース視点でも発見できます。pg_stat_statements で同じクエリが数千〜数万回実行されていたら N+1 の疑いがあります。対処はアプリ側で JOIN にまとめるか、バッチ取得に変えることになります。
チェックリスト
-
pg_stat_statementsでスロークエリを特定した -
EXPLAIN ANALYZEで Seq Scan が妥当か確認した -
ANALYZEで統計情報を更新した - デッドタプルが多いテーブルを
VACUUMした - 必要なインデックスを
CREATE INDEX CONCURRENTLYで追加した
おわりに
データベースの性能問題は、「なんとなく遅い」を「どの SQL が、なぜ遅いのか」に分解するところから始まります。いきなり設定値を変えるのではなく、遅いクエリの特定、実行計画の確認、統計情報や VACUUM 状況の確認という順番で見ていくと、原因に近づきやすくなります。
原因特定から対処まで、まるごとご相談いただくことも可能です。障害調査・原因分析サービスもご覧ください。また MySQL 版の手順は「MySQL のクエリが遅い原因を突き止める手順」で紹介しています。