T-CREATOR

PostgreSQL のクエリが遅い原因を特定:EXPLAIN/ANALYZE 徹底活用術

PostgreSQL のクエリが遅い原因を特定:EXPLAIN/ANALYZE 徹底活用術

データベースのパフォーマンス問題は、アプリケーション開発において避けて通れない課題です。特に PostgreSQL を使用している環境で、「クエリが遅い」という問題に直面したとき、原因を特定できずに悩んでしまうことはありませんか?

本記事では、PostgreSQL に備わっている強力なツール「EXPLAIN」と「ANALYZE」を徹底的に活用し、クエリのパフォーマンス問題を根本から解決する方法をお伝えします。実際のエラーコードやクエリプランの読み方、そして具体的な最適化手順まで、実践的な内容を豊富に盛り込みました。

この記事を読み終える頃には、クエリが遅い原因を自分で特定し、適切な対策を打てるようになるでしょう。

背景

PostgreSQL のクエリ実行の仕組み

PostgreSQL がクエリを実行する際、内部では複雑な処理が行われています。まず、SQL 文を受け取ったデータベースエンジンは、その文を解析し、最も効率的な実行計画(クエリプラン)を作成します。

この実行計画の作成プロセスは「クエリオプティマイザ」と呼ばれ、PostgreSQL の心臓部とも言える重要な役割を担っています。オプティマイザは、テーブルの統計情報やインデックスの有無、WHERE 句の条件などを総合的に判断し、最適なデータアクセス方法を選択するのです。

以下の図は、PostgreSQL がクエリを受け取ってから結果を返すまでの基本的な流れを示しています。

mermaidflowchart TD
  client["クライアント<br/>アプリケーション"] -->|SQL クエリ送信| parser["パーサー<br/>構文解析"]
  parser -->|解析済みツリー| planner["プランナー<br/>実行計画作成"]
  planner -->|最適化された<br/>実行計画| executor["エグゼキュータ<br/>実行エンジン"]
  executor -->|データアクセス| storage[("ストレージ<br/>ディスク/メモリ")]
  storage -->|データ取得| executor
  executor -->|結果セット| client

この図からわかるように、クエリの実行には複数の段階があります。特に「プランナー」が作成する実行計画の良し悪しが、クエリのパフォーマンスを大きく左右するのです。

クエリプランとコスト計算

PostgreSQL のプランナーは、複数の実行計画候補の中から、コストが最も低いものを選択します。このコストは、ディスク I/O やメモリ使用量、CPU 処理時間などを総合的に数値化したもので、実際の実行時間を予測する指標となります。

しかし、このコスト計算はあくまで推定値です。テーブルの統計情報が古かったり、データの分布が偏っていたりすると、プランナーが最適でない実行計画を選んでしまうことがあります。

また、PostgreSQL は以下のような様々なアクセス方法を使い分けます。

  • Sequential Scan(シーケンシャルスキャン): テーブル全体を順番に読み込む
  • Index Scan(インデックススキャン): インデックスを使用してデータを取得する
  • Bitmap Index Scan: 複数のインデックスを組み合わせて使用する
  • Nested Loop Join: ネストしたループで結合を行う
  • Hash Join: ハッシュテーブルを使った結合
  • Merge Join: ソート済みデータ同士の結合

これらのアクセス方法の選択が、クエリのパフォーマンスに直接影響を与えます。

課題

クエリパフォーマンス問題の特定が困難な理由

実際の開発現場では、クエリが遅い原因を特定することは想像以上に難しい作業です。単純に「クエリが遅い」というエラーメッセージが表示されるわけではなく、アプリケーションのレスポンスが悪化したり、タイムアウトエラーが発生したりするだけだからです。

以下は、クエリパフォーマンス問題に関連する典型的なエラーの例です。

swiftError: Query timeout after 30000ms
    at Connection.query (/app/node_modules/pg/lib/client.js:526:17)
    at processTicksAndRejections (internal/process/task_queues.js:93:5)

このエラーコード Query timeout は、クエリの実行が指定時間内に完了しなかったことを示していますが、なぜ遅いのかという本質的な原因は教えてくれません。

よくあるパフォーマンス問題のパターン

クエリが遅くなる原因は多岐にわたりますが、以下のようなパターンが特に頻繁に発生します。

#問題パターン症状難易度
1インデックス未作成WHERE 句や JOIN 条件の列にインデックスがない★☆☆
2不適切なインデックスインデックスはあるが使われていない★★☆
3統計情報の陳腐化ANALYZE が実行されておらず、プランが不適切★★☆
4N+1 問題ループ内で個別にクエリを発行している★★★
5過度な JOIN複数テーブルの結合により計算コストが膨大★★★

これらの問題を特定するには、クエリの内部動作を可視化する必要があります。しかし、ブラックボックス化されたデータベースエンジンの内部を覗くことは容易ではありません。

推測だけでは解決できない

多くの開発者が陥りがちなのが、「おそらくインデックスがないから遅いのだろう」「JOIN が多いから重いに違いない」といった推測だけで対策を講じてしまうことです。

しかし、推測だけに基づいた最適化は、以下のようなリスクを伴います。

  • 不要なインデックスを作成してしまい、INSERT/UPDATE が遅くなる
  • 実際の原因とは異なる箇所を修正し、時間を浪費する
  • 統計情報の更新で解決できる問題に、クエリの書き換えで対応してしまう

この課題を解決するには、確実なデータに基づいた分析が不可欠なのです。

解決策

EXPLAIN:実行計画の可視化

PostgreSQL の EXPLAIN コマンドは、クエリを実際に実行せずに、どのような実行計画が作成されるかを表示してくれる強力なツールです。これにより、クエリがどのようにデータにアクセスするかを事前に確認できます。

基本的な使い方は非常にシンプルで、調査したいクエリの先頭に EXPLAIN を付けるだけです。

sql-- 基本的な EXPLAIN の使い方
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';

このコマンドを実行すると、PostgreSQL は以下のような実行計画を表示します。

arduinoSeq Scan on users  (cost=0.00..35.50 rows=10 width=532)
  Filter: (email = 'test@example.com'::text)

この出力から、以下の情報が読み取れます。

  • Seq Scan: Sequential Scan(全行スキャン)が実行される
  • cost=0.00..35.50: 開始コスト 0.00、完了時のコスト 35.50
  • rows=10: 推定で 10 行が返される見込み
  • width=532: 1 行あたり平均 532 バイト

ANALYZE:実際の実行時間を測定

EXPLAIN だけでは、あくまで推定値しかわかりません。実際にクエリを実行して、本当の実行時間やデータ量を確認するには ANALYZE オプションを組み合わせます。

sql-- EXPLAIN ANALYZE で実際の実行統計を取得
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';

ANALYZE を付けると、クエリが実際に実行され、より詳細な情報が得られます。

sqlSeq Scan on users  (cost=0.00..35.50 rows=10 width=532) (actual time=0.015..0.234 rows=1 loops=1)
  Filter: (email = 'test@example.com'::text)
  Rows Removed by Filter: 999
Planning Time: 0.123 ms
Execution Time: 0.256 ms

ここで注目すべきポイントは、以下の通りです。

  • actual time: 実際の実行時間(ミリ秒)
  • rows=1: 実際に返された行数(推定 10 行に対して実際は 1 行)
  • Rows Removed by Filter: フィルタで除外された行数(999 行)
  • Planning Time: 実行計画の作成時間
  • Execution Time: 実際のクエリ実行時間

詳細な出力形式の活用

より詳細な情報を得たい場合は、出力形式を変更できます。特に JSON 形式は、プログラムで解析する際に便利です。

sql-- JSON 形式で詳細な情報を取得
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM users WHERE email = 'test@example.com';

このコマンドでは、以下のオプションを指定しています。

  • ANALYZE: 実際にクエリを実行する
  • BUFFERS: バッファの使用状況を表示
  • FORMAT JSON: 出力を JSON 形式にする

BUFFERS オプションを使うと、ディスク I/O の状況が詳しくわかります。

sql-- バッファ統計を含めた詳細分析
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01';

出力には以下のようなバッファ情報が追加されます。

iniBuffers: shared hit=245 read=12
  • shared hit: メモリ上のバッファから読み取った回数(速い)
  • read: ディスクから読み取った回数(遅い)

この情報から、クエリがメモリで完結しているか、それともディスク I/O が発生しているかが判断できます。

実行計画の読み方:重要な指標

実行計画を読み解く際、特に注意すべき指標を以下にまとめます。

#指標意味最適化のヒント
1Seq Scanテーブル全体をスキャンインデックス追加を検討
2rows(推定)と actual rows の乖離統計情報が不正確ANALYZE 実行が必要
3Rows Removed by Filter が多い不要な行を大量に読んでいるインデックスやクエリ条件を見直す
4Nested Loop の内側が Seq ScanJOIN ごとに全件スキャン結合キーにインデックス追加
5actual time が Planning Time より極端に長い実行時のボトルネッククエリやインデックスの最適化

下図は、EXPLAIN/ANALYZE を使ったパフォーマンス分析の流れを示しています。

mermaidflowchart TD
  start["遅いクエリを発見"] --> explain["EXPLAIN で<br/>実行計画を確認"]
  explain --> check_plan{"適切な<br/>プランか?"}
  check_plan -->|No| analyze_stats["統計情報を確認<br/>ANALYZE 実行"]
  check_plan -->|Yes| explain_analyze["EXPLAIN ANALYZE で<br/>実際の実行時間測定"]
  explain_analyze --> check_actual{"推定と実際が<br/>一致?"}
  check_actual -->|No| analyze_stats
  check_actual -->|Yes| identify["ボトルネックを<br/>特定"]
  analyze_stats --> recheck["再度 EXPLAIN<br/>で確認"]
  recheck --> identify
  identify --> optimize["最適化施策を実施"]
  optimize --> verify["EXPLAIN ANALYZE で<br/>効果を検証"]

この流れに従うことで、推測ではなくデータに基づいた最適化が可能になります。

具体例

ケース 1:インデックス未作成による全件スキャン

実際のパフォーマンス問題を例に、EXPLAIN/ANALYZE の活用方法を見ていきましょう。以下は、ユーザーのメールアドレスで検索するクエリです。

sql-- 問題のあるクエリ
SELECT id, name, email, created_at
FROM users
WHERE email = 'john.doe@example.com';

まず、このクエリに EXPLAIN ANALYZE を実行してみます。

sql-- 実行計画の確認
EXPLAIN ANALYZE
SELECT id, name, email, created_at
FROM users
WHERE email = 'john.doe@example.com';

実行結果は以下のようになりました。

sqlSeq Scan on users  (cost=0.00..1808.00 rows=1 width=89) (actual time=12.456..24.892 rows=1 loops=1)
  Filter: ((email)::text = 'john.doe@example.com'::text)
  Rows Removed by Filter: 49999
Planning Time: 0.089 ms
Execution Time: 24.915 ms

この結果から、以下の問題点が明らかになります。

  • Seq Scan: テーブル全体をスキャンしている
  • Rows Removed by Filter: 49999: 49,999 行を読み込んで捨てている
  • Execution Time: 24.915 ms: 1 件取得するのに約 25 ミリ秒かかっている

5 万件のテーブルで 25 ミリ秒かかるということは、100 万件になれば 500 ミリ秒以上かかる計算です。これは明らかに改善が必要でしょう。

ケース 1 の解決:インデックスの作成

この問題の解決策は、email カラムにインデックスを作成することです。

sql-- email カラムにインデックスを作成
CREATE INDEX idx_users_email ON users(email);

インデックス作成後、再度 EXPLAIN ANALYZE を実行します。

sql-- 最適化後の実行計画を確認
EXPLAIN ANALYZE
SELECT id, name, email, created_at
FROM users
WHERE email = 'john.doe@example.com';

最適化後の結果は以下の通りです。

sqlIndex Scan using idx_users_email on users  (cost=0.29..8.31 rows=1 width=89) (actual time=0.023..0.025 rows=1 loops=1)
  Index Cond: ((email)::text = 'john.doe@example.com'::text)
Planning Time: 0.156 ms
Execution Time: 0.048 ms

改善効果は劇的です。

#指標改善前改善後改善率
1アクセス方法Seq ScanIndex Scan-
2Execution Time24.915 ms0.048 ms約 519 倍高速化
3Rows Removed499990不要な読み取りが消滅
4Cost1808.008.31約 217 分の 1

このように、EXPLAIN/ANALYZE を使うことで、問題の原因と改善効果を定量的に把握できます。

ケース 2:不適切な JOIN による性能劣化

次に、複数テーブルの結合によるパフォーマンス問題を見ていきます。以下は、ユーザーと注文、商品を結合するクエリです。

sql-- ユーザーの注文履歴と商品情報を取得
SELECT u.name, o.order_date, p.product_name, p.price
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE u.created_at >= '2024-01-01'
ORDER BY o.order_date DESC
LIMIT 100;

このクエリに EXPLAIN ANALYZE を実行します。

sql-- 複雑な JOIN の実行計画を分析
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, o.order_date, p.product_name, p.price
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE u.created_at >= '2024-01-01'
ORDER BY o.order_date DESC
LIMIT 100;

実行計画の結果(簡略版)は以下のようになりました。

iniLimit  (cost=15234.56..15234.81 rows=100 width=72) (actual time=245.123..245.456 rows=100 loops=1)
  ->  Sort  (cost=15234.56..15456.78 rows=8888 width=72) (actual time=245.121..245.298 rows=100 loops=1)
        Sort Key: o.order_date DESC
        Sort Method: top-N heapsort  Memory: 35kB
        ->  Hash Join  (cost=8234.00..14892.34 rows=8888 width=72) (actual time=98.456..234.789 rows=8532 loops=1)
              Hash Cond: (oi.product_id = p.id)
              ->  Hash Join  (cost=6234.00..11234.56 rows=8888 width=48) (actual time=67.234..189.567 rows=8532 loops=1)
                    Hash Cond: (oi.order_id = o.id)
                    ->  Seq Scan on order_items oi  (cost=0.00..3456.78 rows=89123 width=16) (actual time=0.012..45.678 rows=89123 loops=1)
                    ->  Hash  (cost=5678.90..5678.90 rows=4444 width=40) (actual time=67.123..67.123 rows=4521 loops=1)
                          Buckets: 8192  Batches: 1  Memory Usage: 389kB
                          ->  Hash Join  (cost=234.56..5678.90 rows=4444 width=40) (actual time=5.678..62.345 rows=4521 loops=1)
                                Hash Cond: (o.user_id = u.id)
                                ->  Seq Scan on orders o  (cost=0.00..4567.89 rows=123456 width=24) (actual time=0.008..28.456 rows=123456 loops=1)
                                ->  Hash  (cost=198.76..198.76 rows=2864 width=24) (actual time=5.623..5.623 rows=2987 loops=1)
                                      Buckets: 4096  Batches: 1  Memory Usage: 198kB
                                      ->  Seq Scan on users u  (cost=0.00..198.76 rows=2864 width=24) (actual time=0.015..4.892 rows=2987 loops=1)
                                            Filter: (created_at >= '2024-01-01'::date)
                                            Rows Removed by Filter: 47013
              ->  Hash  (cost=1234.56..1234.56 rows=45678 width=32) (actual time=30.987..30.987 rows=45678 loops=1)
                    Buckets: 65536  Batches: 1  Memory Usage: 2987kB
                    ->  Seq Scan on products p  (cost=0.00..1234.56 rows=45678 width=32) (actual time=0.009..15.678 rows=45678 loops=1)
        Buffers: shared hit=1234 read=567
Planning Time: 1.234 ms
Execution Time: 245.678 ms

この実行計画から、以下の問題点が見えてきます。

  • users テーブルで 47,013 行がフィルタで除外されている
  • すべてのテーブルが Seq Scan になっている
  • Buffers で read=567 となり、ディスク I/O が発生している

ケース 2 の解決:複合的な最適化

この問題には、複数の最適化施策を組み合わせて対応します。

sql-- 1. users テーブルの created_at にインデックスを作成
CREATE INDEX idx_users_created_at ON users(created_at);

-- 2. 外部キーにインデックスを作成(まだなければ)
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);

さらに、統計情報を更新します。

sql-- 統計情報を更新して、より正確なクエリプランを生成
ANALYZE users;
ANALYZE orders;
ANALYZE order_items;
ANALYZE products;

最適化後に再度 EXPLAIN ANALYZE を実行した結果です。

iniLimit  (cost=456.78..457.03 rows=100 width=72) (actual time=12.345..12.567 rows=100 loops=1)
  ->  Sort  (cost=456.78..478.90 rows=8848 width=72) (actual time=12.343..12.456 rows=100 loops=1)
        Sort Key: o.order_date DESC
        Sort Method: top-N heapsort  Memory: 35kB
        ->  Nested Loop  (cost=23.45..234.56 rows=8848 width=72) (actual time=0.234..10.567 rows=8532 loops=1)
              ->  Nested Loop  (cost=23.01..156.78 rows=8848 width=48) (actual time=0.198..6.789 rows=8532 loops=1)
                    ->  Nested Loop  (cost=22.57..89.34 rows=4424 width=40) (actual time=0.167..3.456 rows=4521 loops=1)
                          ->  Index Scan using idx_users_created_at on users u  (cost=0.29..45.67 rows=2864 width=24) (actual time=0.023..0.892 rows=2987 loops=1)
                                Index Cond: (created_at >= '2024-01-01'::date)
                          ->  Index Scan using idx_orders_user_id on orders o  (cost=0.42..12.34 rows=15 width=24) (actual time=0.012..0.678 rows=2 loops=2987)
                                Index Cond: (user_id = u.id)
                    ->  Index Scan using idx_order_items_order_id on order_items oi  (cost=0.43..8.56 rows=5 width=16) (actual time=0.008..0.567 rows=2 loops=4521)
                          Index Cond: (order_id = o.id)
              ->  Index Scan using products_pkey on products p  (cost=0.43..6.78 rows=1 width=32) (actual time=0.003..0.003 rows=1 loops=8532)
                    Index Cond: (id = oi.product_id)
        Buffers: shared hit=12456
Planning Time: 0.987 ms
Execution Time: 12.789 ms

改善結果をまとめます。

#指標改善前改善後改善率
1Execution Time245.678 ms12.789 ms約 19.2 倍高速化
2Planning Time1.234 ms0.987 ms約 1.25 倍高速化
3Buffers read(ディスク I/O)5670完全にメモリ内で完結
4Cost15234.56456.78約 33 分の 1

Seq Scan から Index Scan に変わったことで、ディスク I/O が完全になくなり、劇的な性能改善が実現しました。

ケース 3:統計情報の陳腐化

最後に、統計情報が古くなることで発生する問題を見ていきます。以下のクエリは、特定のステータスの注文を検索します。

sql-- ステータスで注文を検索
SELECT * FROM orders WHERE status = 'pending';

EXPLAIN ANALYZE の結果です。

sqlSeq Scan on orders  (cost=0.00..5678.90 rows=123456 width=48) (actual time=0.023..89.456 rows=5 loops=1)
  Filter: ((status)::text = 'pending'::text)
  Rows Removed by Filter: 123451
Planning Time: 0.123 ms
Execution Time: 89.567 ms

注目すべき点は、rows=123456(推定)に対して actual... rows=5(実際)という大きな乖離です。PostgreSQL は 123,456 件が該当すると推定しましたが、実際には 5 件しかありませんでした。

この推定ミスにより、PostgreSQL はインデックスよりも Seq Scan の方が効率的だと誤って判断しています。

統計情報を更新します。

sql-- 統計情報を最新化
ANALYZE orders;

統計情報更新後、再度 EXPLAIN ANALYZE を実行します。

sql-- 統計情報更新後の実行計画
EXPLAIN ANALYZE
SELECT * FROM orders WHERE status = 'pending';

結果は以下のように改善されました。

sqlIndex Scan using idx_orders_status on orders  (cost=0.42..18.67 rows=5 width=48) (actual time=0.034..0.056 rows=5 loops=1)
  Index Cond: ((status)::text = 'pending'::text)
Planning Time: 0.234 ms
Execution Time: 0.078 ms

統計情報を更新しただけで、Execution Time が 89.567 ms から 0.078 ms へと約 1,148 倍も高速化しました。これは、正確な統計情報に基づいて、PostgreSQL が適切な実行計画(Index Scan)を選択できるようになったためです。

このケースは、定期的な ANALYZE 実行の重要性を示しています。

まとめ

PostgreSQL のクエリパフォーマンス問題を解決するには、推測ではなく確実なデータに基づいた分析が不可欠です。本記事では、EXPLAIN と ANALYZE という強力なツールを活用し、クエリの実行計画を可視化する方法をお伝えしました。

実行計画を読み解くことで、以下のような問題を正確に特定できます。

  • インデックスが使われていない(Seq Scan が発生)
  • 統計情報が古く、不適切なプランが選択されている
  • JOIN の結合順序やアクセス方法が非効率
  • ディスク I/O が大量に発生している

また、具体例を通じて、以下の最適化手法を学びました。

  • 適切なインデックスの作成
  • 統計情報の定期的な更新(ANALYZE 実行)
  • 外部キーへのインデックス追加
  • 実行計画とバッファ統計の読み方

EXPLAIN/ANALYZE を活用することで、クエリの実行時間を数十倍、場合によっては数百倍も高速化できることがわかりましたね。

パフォーマンス問題に直面したときは、まず EXPLAIN ANALYZE でボトルネックを特定し、データに基づいた最適化を行いましょう。この習慣が、あなたのデータベース運用スキルを大きく向上させてくれるはずです。

関連リンク