Back

PostgreSQLクエリパフォーマンス:もう我慢できないエンジニアのためのEXPLAIN ANALYZE完全ガイド

APIエンドポイントが3秒かかる。ダッシュボードの読み込みでタイムアウト。「1分で終わるはず」のバックグラウンドジョブが45分動いている。pgAdminを開いてクエリを見つめて、こう思うわけです。「なんで遅いんだ?ただのSELECTなのに。」

PostgreSQLのパフォーマンスデバッグは、すべてのバックエンドエンジニアがいつかぶつかる壁なんです。ほとんどの人は当てずっぽうでインデックスを追加したり、クエリを闇雲に書き直したり、ORMのせいにして終わりにしたりしますよね。

このガイドでは体系的なアプローチを紹介します。EXPLAIN ANALYZEの出力を自在に読む方法、本当に効くインデックス戦略(逆効果のものも)、N+1クエリの見つけ方と潰し方、コネクションプーリングの罠、プロダクションで実際に使われるデバッグワークフローまで、全部まとめました。

クエリが遅い。さて、どうする?

何かを触る前に、まずデータが必要なんです。勘じゃなくてデータ。

最初のステップはいつも同じ。EXPLAIN ANALYZEを実行します。

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT o.id, o.total, c.name, c.email FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.created_at >= '2026-01-01' AND o.status = 'completed' ORDER BY o.created_at DESC LIMIT 50;

ANALYZEフラグは実際にクエリを実行します(破壊的な操作はトランザクションで囲んでください)。BUFFERSはI/Oアクティビティを表示してくれるんですが、遅さの原因を特定するのに一番重要な情報なんです。

結果はこんな感じです:

Limit  (cost=1247.83..1247.96 rows=50 width=86) (actual time=342.118..342.127 rows=50 loops=1)
  Buffers: shared hit=128 read=15234
  ->  Sort  (cost=1247.83..1302.45 rows=21847 width=86) (actual time=342.115..342.120 rows=50 loops=1)
        Sort Key: o.created_at DESC
        Sort Method: top-N heapsort  Memory: 32kB
        ->  Hash Join  (cost=412.50..876.32 rows=21847 width=86) (actual time=45.821..298.456 rows=21847 loops=1)
              Hash Cond: (o.customer_id = c.id)
              ->  Seq Scan on orders o  (cost=0.00..398.75 rows=21847 width=24) (actual time=0.021..187.345 rows=21847 loops=1)
                    Filter: ((created_at >= '2026-01-01') AND (status = 'completed'))
                    Rows Removed by Filter: 478153
                    Buffers: shared hit=0 read=14891
              ->  Hash  (cost=212.50..212.50 rows=10000 width=62) (actual time=45.678..45.678 rows=10000 loops=1)
                    Buffers: shared hit=128 read=343
                    ->  Seq Scan on customers c  (cost=0.00..212.50 rows=10000 width=62) (actual time=0.012..22.345 rows=10000 loops=1)

複雑に見えますよね。一つずつ読み解いていきます。

EXPLAIN ANALYZEの読み方:本当に重要な5つのポイント

1. Actual Time(Costじゃなくて)

costは無視してください。プランナーの推定値であって現実じゃない。見るべきはactual timeです:

actual time=0.021..187.345 rows=21847

最初の数字(0.021)はスタートアップ時間。2番目(187.345)が合計時間(ミリ秒)。このSeq Scanが187ms、クエリ全体の55%を食ってるんですよね。

2. Rows Removed by Filter

インデックスが足りない証拠なんです:

Filter: ((created_at >= '2026-01-01') AND (status = 'completed'))
Rows Removed by Filter: 478153

50万行をスキャンして2.2万行を見つけた。95.6%は無駄なんですよね。Rows Removed by Filterが返却行数の10倍以上なら、ほぼ確実にインデックスが要ります。

3. Buffers: shared hit vs read

Buffers: shared hit=0 read=14891

shared hitはPostgreSQLのバッファキャッシュ(RAM)にあったページ。readはディスクから読んだページ。readhitよりはるかに大きい場合、ワーキングセットがメモリに収まっていないサインなんです。

4. 大きなテーブルのSeq Scan

50万行のテーブルでSequential Scan、これはもうほぼアウトです。PostgreSQLパフォーマンスキラー第1位。

5. Estimated vs Actual Rows

推定行数と実際の行数が10倍以上ずれていたら、プランナーが間違った戦略を選んでいる証拠。大抵は統計情報が古いだけなんですよね:

ANALYZE orders;

本当に効くインデックス戦略

多くの開発者のインデックス戦略って「遅い → WHEREのカラムにインデックス → お祈り」ですよね。壊れたパイプにバンドエイドを貼ってるようなもの。

ルール1:複合インデックスが単一カラムに勝つ

-- イマイチ:単一カラム CREATE INDEX idx_orders_status ON orders(status); -- 'completed'が多すぎてPostgreSQLがseq scanする可能性あり -- 良い:WHERE句にマッチする複合インデックス CREATE INDEX idx_orders_status_created ON orders(status, created_at);

複合インデックスのカラム順序が重要なんです。 等号条件(status = 'completed')を先に、範囲条件(created_at >= '2026-01-01')を後に。

ルール2:カバリングインデックスでテーブルルックアップを排除

インデックススキャン後も、PostgreSQLは実際のカラム値を取りにテーブル(ヒープ)へアクセスしに行くんですよね。この「ヒープフェッチ」が地味に高コスト。

カバリングインデックスなら、必要なカラムを全部含められます:

CREATE INDEX idx_orders_covering ON orders(status, created_at DESC) INCLUDE (id, total, customer_id);

こうするとPostgreSQLがインデックスだけでクエリを解決してくれる。ヒープアクセス不要。

結果:342ms → 0.23ms。1,487倍高速化。 Heap Fetches: 0、ここがポイント。

ルール3:偏ったデータにはパーシャルインデックス

注文の90%がcompletedだけど、頻繁にクエリするのは最近のpendingなら:

CREATE INDEX idx_orders_pending ON orders(created_at DESC) WHERE status = 'pending';

インデックスサイズが10分の1になって、キャッシュにすっぽり収まる。

ルール4:インデックスを貼らないべき時

インデックスはタダじゃないんですよね。各インデックスがINSERTUPDATEDELETEを遅くするし、ディスクとメモリを食うし、VACUUMの負荷も増える。

使われていないインデックスの見つけ方:

SELECT schemaname, tablename, indexname, idx_scan AS times_used, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;

実際のプロダクションDBで、未使用インデックス40個以上が30GB消費して書き込み性能を20%落としていたケースを見たことがあります。

N+1問題:千のクエリによる死

N+1はPostgreSQLの問題じゃなくて、アプリケーション層の問題なんですよね。でもWebアプリで一番よく見るパフォーマンス問題でもある。

こういう風に見えます

// 典型的なN+1: ordersに1クエリ + customersにN回 const orders = await db.query('SELECT * FROM orders LIMIT 100'); for (const order of orders) { // これが100個の個別クエリを発行する const customer = await db.query( 'SELECT * FROM customers WHERE id = $1', [order.customer_id] ); order.customer = customer; } // 合計101クエリ、ネットワークラウンドトリップだけで340ms

各クエリは1-3msしかかからないのに、ネットワークのラウンドトリップが積もり積もるんです。100回 × 3ms = 300msの純粋な無駄。

対策1: JOIN

SELECT o.*, c.name, c.email FROM orders o JOIN customers c ON c.id = o.customer_id LIMIT 100; -- 1クエリ、~2ms

対策2: バッチローディング

const orders = await db.query('SELECT * FROM orders LIMIT 100'); const customerIds = [...new Set(orders.map(o => o.customer_id))]; const customers = await db.query( 'SELECT * FROM customers WHERE id = ANY($1)', [customerIds] ); const customerMap = new Map(customers.map(c => [c.id, c])); orders.forEach(o => o.customer = customerMap.get(o.customer_id)); // 2クエリ、~4ms

対策3: DataLoaderパターン

import DataLoader from 'dataloader'; const customerLoader = new DataLoader(async (ids: number[]) => { const customers = await db.query( 'SELECT * FROM customers WHERE id = ANY($1)', [ids] ); const map = new Map(customers.map(c => [c.id, c])); return ids.map(id => map.get(id) ?? null); }); const [cust1, cust2, cust3] = await Promise.all([ customerLoader.load(1), customerLoader.load(2), customerLoader.load(3), ]); // 1クエリ: SELECT * FROM customers WHERE id = ANY([1, 2, 3])

プロダクションでN+1を検出する

const prisma = new PrismaClient({ log: [{ emit: 'event', level: 'query' }], }); let queryCount = 0; prisma.$on('query', () => { queryCount++; }); if (queryCount > 10) { console.warn(`⚠️ N+1検出: 1リクエストで${queryCount}クエリ実行`); }

コネクションプーリング:誰も教えてくれないスループットの暗殺者

クエリを最適化して、適切なインデックスを付けて、N+1を潰した。開発環境ではレスポンスタイム完璧。でもデプロイして負荷がかかると、全部もっと遅くなるんです。

原因はほぼ確実にコネクションプーリングです。

なぜコネクションは高コストなのか

PostgreSQLの各コネクションはプロセスを1つ生成します(10MB RAM)。そして多くの開発者が見落としているポイントがあります。**アクティブなコネクションが50を超えると、ロック競合により性能が低下するんです。**

┌─────────────────────────────────────────┐
│         コネクション数 vs 性能           │
│                                         │
│  コネクション  スループット  レイテンシ  │
│  ───────   ────────  ───────  │
│      10    100%        基準       │
│      50    95%         1.1倍      │
│     100    70%         2.5倍      │
│     200    40%         8倍        │
│     500    15%         25倍       │
│                                         │
│  ⚠️  コネクションが多い ≠ スループット向上  │
└─────────────────────────────────────────┘

解決策: PgBouncer

; pgbouncer.ini [pgbouncer] pool_mode = transaction default_pool_size = 20 max_client_conn = 1000

pool_mode = transactionにすると、実際のDB接続はトランザクションの間だけ。終わったらプールに返却される。

最もよくある間違い

import { Pool } from 'pg'; const pool = new Pool({ max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, });

アプリインスタンス10台 × max: 50 = 500接続。PostgreSQLが死ぬやつです。max_connections / インスタンス数で計算してください。

上級デバッグツール

pg_stat_statements: プロダクション性能ダッシュボード

CREATE EXTENSION IF NOT EXISTS pg_stat_statements; SELECT round(total_exec_time::numeric, 2) AS total_ms, calls, round(mean_exec_time::numeric, 2) AS avg_ms, round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct, left(query, 80) AS query_preview FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;

1件2.81msのクエリでも15,000回呼ばれたら、DB全体の34.2%を占めます。ここが最適化の優先ポイントなんです。

pg_stat_user_tables: テーブルの健康診断

SELECT relname AS テーブル, n_live_tup AS 生存行, n_dead_tup AS 死亡行, round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct, last_autovacuum FROM pg_stat_user_tables WHERE n_live_tup > 1000 ORDER BY n_dead_tup DESC;

dead_pctが20%を超えていたら、autovacuumが追いついてない状態です。

ALTER TABLE orders SET ( autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.01 );

ロックモニタリング:ブロックされているクエリを見つける

SELECT blocked.pid AS blocked_pid, blocked_activity.query AS blocked_query, blocking.pid AS blocking_pid, blocking_activity.query AS blocking_query, now() - blocked_activity.query_start AS blocked_duration FROM pg_catalog.pg_locks blocked JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked.pid = blocked_activity.pid JOIN pg_catalog.pg_locks blocking ON blocking.locktype = blocked.locktype AND blocking.database IS NOT DISTINCT FROM blocked.database AND blocking.relation IS NOT DISTINCT FROM blocked.relation AND blocking.pid != blocked.pid JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking.pid = blocking_activity.pid WHERE NOT blocked.granted;

クエリが数秒以上ブロックされていたら、原因は2つです。長時間実行中のトランザクションがロックを保持している(解決:トランザクションを短く)か、スキーママイグレーションがCONCURRENTLYなしで実行されている(解決:CREATE INDEX CONCURRENTLYを使う)。

完全デバッグワークフロー

┌────────────────────────────────────┐
│  1. EXPLAIN (ANALYZE, BUFFERS)     │
│     疑わしいクエリを実行           │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  2. Seq Scanをチェック             │
│     1万行超のテーブルなら          │
│     → 適切なインデックスを追加     │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  3. Rows Removedをチェック         │
│     返却行数の10倍以上なら         │
│     → 複合/カバリングインデックス  │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  4. Estimated vs Actual Rows       │
│     10倍以上ずれなら              │
│     → ANALYZEを実行               │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  5. Buffers hit vs read            │
│     readが繰り返し多いなら         │
│     → shared_buffersを増やす       │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  6. pg_stat_statements             │
│     total_time順にトップクエリ確認 │
│     → 優先度順に最適化             │
└─────────────┬──────────────────────┘
              ▼
┌────────────────────────────────────┐
│  7. コネクション数                  │
│     アクティブ50超なら             │
│     → PgBouncer導入                │
└────────────────────────────────────┘

よくあるPostgreSQL設定ミス

設定デフォルト推奨値理由
shared_buffers128MBRAMの25%メインキャッシュ。小さすぎるとディスク読み取り乱発
effective_cache_size4GBRAMの75%OSキャッシュ量のヒント。インデックス使用判断に影響
work_mem4MB64-256MBソート/ハッシュジョイン用。少なすぎるとディスクソート
random_page_cost4.01.1 (SSD)SSDなら下げる。プランナーがインデックススキャンを選ぶように
maintenance_work_mem64MB1-2GBVACUUMとCREATE INDEX用

最もインパクトの大きい変更:SSDならrandom_page_cost = 1.1にすること。デフォルトの4.0はHDD時代の設定で、これのせいでPostgreSQLがインデックススキャンよりseq scanを選んでしまうんです。

実例: 12秒 → 15ミリ秒

SELECT date_trunc('day', o.created_at) AS day, COUNT(*) AS order_count, SUM(o.total) AS revenue, COUNT(DISTINCT o.customer_id) AS unique_customers FROM orders o WHERE o.created_at BETWEEN '2025-01-01' AND '2025-12-31' AND o.store_id = 42 GROUP BY date_trunc('day', o.created_at) ORDER BY day;

3つの問題:Seq Scan(500万行)、ディスクソート、I/O爆発。

CREATE INDEX idx_orders_store_date ON orders(store_id, created_at) INCLUDE (total, customer_id); SET work_mem = '128MB';

12,847ms → 15ms。856倍高速化。 複合インデックス + カバリングインデックス + 適切なwork_mem、この3つだけです。

まとめ

PostgreSQLのパフォーマンスデバッグは魔法じゃないんです。体系的なプロセスです:

  1. まず計測する。 遅いクエリにEXPLAIN (ANALYZE, BUFFERS)を実行。勘でやらない。
  2. シグナルを読む。 Seq Scan、Rows Removed by Filter、Buffers hit vs readが何が問題か正確に教えてくれます。
  3. 戦略的にインデックスを貼る。 等号優先の複合インデックス。頻出クエリにカバリングインデックス。偏りデータにパーシャルインデックス。
  4. N+1を潰す。 JOIN、バッチローディング、DataLoaderパターン。
  5. コネクションを管理する。 PgBouncerかアプリ側プーリング。実接続は50以下に。
  6. 設定を正しくする。 SSDならrandom_page_cost = 1.1shared_buffersはRAMの25%。
  7. 継続的にモニタリング。 pg_stat_statementsでクエリ、pg_stat_user_tablesでテーブルの健全性。

EXPLAIN ANALYZEから始めてください。ワークフローに従ってください。すべてを計測してください。クエリが変わります。

PostgreSQLdatabaseperformanceSQLbackendoptimizationEXPLAIN-ANALYZEindexingN+1-problem

関連ツールを見る

Pockitの無料開発者ツールを試してみましょう