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はディスクから読んだページ。readがhitよりはるかに大きい場合、ワーキングセットがメモリに収まっていないサインなんです。
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:インデックスを貼らないべき時
インデックスはタダじゃないんですよね。各インデックスがINSERT、UPDATE、DELETEを遅くするし、ディスクとメモリを食うし、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_buffers | 128MB | RAMの25% | メインキャッシュ。小さすぎるとディスク読み取り乱発 |
effective_cache_size | 4GB | RAMの75% | OSキャッシュ量のヒント。インデックス使用判断に影響 |
work_mem | 4MB | 64-256MB | ソート/ハッシュジョイン用。少なすぎるとディスクソート |
random_page_cost | 4.0 | 1.1 (SSD) | SSDなら下げる。プランナーがインデックススキャンを選ぶように |
maintenance_work_mem | 64MB | 1-2GB | VACUUMと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のパフォーマンスデバッグは魔法じゃないんです。体系的なプロセスです:
- まず計測する。 遅いクエリに
EXPLAIN (ANALYZE, BUFFERS)を実行。勘でやらない。 - シグナルを読む。 Seq Scan、Rows Removed by Filter、Buffers hit vs readが何が問題か正確に教えてくれます。
- 戦略的にインデックスを貼る。 等号優先の複合インデックス。頻出クエリにカバリングインデックス。偏りデータにパーシャルインデックス。
- N+1を潰す。 JOIN、バッチローディング、DataLoaderパターン。
- コネクションを管理する。 PgBouncerかアプリ側プーリング。実接続は50以下に。
- 設定を正しくする。 SSDなら
random_page_cost = 1.1、shared_buffersはRAMの25%。 - 継続的にモニタリング。
pg_stat_statementsでクエリ、pg_stat_user_tablesでテーブルの健全性。
EXPLAIN ANALYZEから始めてください。ワークフローに従ってください。すべてを計測してください。クエリが変わります。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう