PostgreSQL: PostgreSQLパフォーマンスチューニング実践
最終更新:2026-08-26
1. 学習内容
- EXPLAIN ANALYZE実行計画を深く読み解く
- Seq Scan / Index Scan / Bitmap Scan / Nest Loop / Hash Join / Merge Join を理解する
- pg_stat_statementsを使用してスロークエリを特定する
- 接続プール(PgBouncer)を設定する
- 主要パラメータのチューニング: shared_buffers, work_mem, effective_cache_size
- autovacuumを理解しチューニングする
2. ストーリー
CharlieはEコマースシステムを引き継ぎました。ホームページの読み込みに8秒かかり、月次レポートがタイムアウトします。彼はEXPLAIN ANALYZEで各クエリを分析し、3つの問題を発見しました: (1) ordersテーブルにインデックスがなくSeq Scanが発生。(2) work_memがわずか4MBで、複雑なソートがディスクにスピル。(3) autovacuumが書き込み速度に追いつかず、テーブルが深刻に肥大化。それぞれ修正 — インデックスを追加してクエリがIndex Scanを使用するようにし、work_memを64MBに上げてソートをメモリ内に収め、autovacuumの頻度を倍にして肥大化を抑制 — により、全体のパフォーマンスが10倍向上し、ホームページは0.8秒で読み込まれるようになりました。
3. 概念: EXPLAIN ANALYZE詳細
(1) コア実行計画オペレータ
| オペレータ | 意味 | 適したケース |
|---|---|---|
| Seq Scan | テーブル全体の逐次スキャン | 小テーブル、利用可能なインデックスなし、多数の行を返す |
| Index Scan | B-Treeインデックススキャン | 高選択性クエリ(5%未満の行を返す) |
| Bitmap Heap Scan | ビットマップヒープスキャン | 中選択性(5%〜15%)。TIDを収集してから行を取得 |
| Bitmap Index Scan | ビットマップインデックススキャン | Bitmap Heap Scanとペア |
| Nest Loop | ネストループ結合 | 小外部テーブル + インデックス付き内部テーブル |
| Hash Join | ハッシュ結合 | 等価結合、内部テーブルがメモリに収まる |
| Merge Join | マージ結合 | 両テーブルがソート済み、等価結合 |
(2) EXPLAINの主要出力フィールド
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE user_id = 1001;
Index Scan using idx_orders_user_id on orders (cost=0.42..8.44 rows=1 width=72) (actual time=0.015..0.016 rows=1 loops=1)
Index Cond: (user_id = 1001)
Buffers: shared hit=4
Planning Time: 0.085 ms
Execution Time: 0.032 ms
| フィールド | 意味 |
|---|---|
| cost=X..Y | 起動コスト .. 合計コスト(推定値) |
| rows=N | 返される推定行数 |
| actual time | 実際の所要時間(ミリ秒) |
| rows(actual) | 実際に返された行数 |
| loops | 実行回数 |
| Buffers: shared hit | 共有バッファヒット数 |
| Planning Time | 計画時間 |
| Execution Time | 実行時間 |
(3) 実行計画の読み方フロー
flowchart TD
A["EXPLAIN ANALYZE 出力"] --> B{"トップノードの種類は?"}
B -->|"Seq Scan"| C{"行数と推定値の差は?"}
C -->|"推定が外れている"| D["ANALYZEを実行<br/>統計情報を更新"]
C -->|"推定OK"| E{"フィルタ選択性は?"}
E -->|"低(5%未満)"| F["フィルタカラムにインデックス追加"]
E -->|"高(15%超)"| G["Seq ScanでOK"]
B -->|"Index Scan"| H["✅ 低選択性に良好"]
B -->|"Hash Join"| I{"ハッシュテーブルがスピル?"}
I -->|"はい(work_mem不足)"| J["work_memを増加"]
I -->|"いいえ"| K["✅ 良好"]
B -->|"Nest Loop"| L{"外部行数 × 内部コストは?"}
L -->|"大きすぎる"| M["Hash Joinを検討<br/>または内部インデックス追加"]
L -->|"妥当"| N["✅ 良好"]
4. 操作: スキャン種類の比較
▶ サンプル: Seq Scan フルテーブルスキャン
-- statusにインデックスがないため、プランナーはSeq Scanを選択
EXPLAIN (ANALYZE, COSTS OFF)
SELECT COUNT(*) FROM orders WHERE status = 'pending';
Aggregate (actual time=45.123..45.124 rows=1 loops=1)
-> Seq Scan on orders (actual time=0.012..42.890 rows=50000 loops=1)
Filter: (status = 'pending'::text)
Rows Removed by Filter: 950000
▶ サンプル: Index Scan 精密検索
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- 高選択性: プランナーはIndex Scanを使用
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders WHERE user_id = 1001;
Index Scan using idx_orders_user_id on orders (actual time=0.015..0.018 rows=3 loops=1)
Index Cond: (user_id = 1001)
▶ サンプル: Bitmap Scan 中選択性
-- 中程度の選択性: プランナーはBitmapを選択
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders WHERE user_id BETWEEN 1000 AND 1100;
Bitmap Heap Scan on orders (actual time=0.523..2.145 rows=523 loops=1)
Recheck Cond: (user_id >= 1000 AND user_id <= 1100)
-> Bitmap Index Scan on idx_orders_user_id (actual time=0.412..0.412 rows=523 loops=1)
Index Cond: (user_id >= 1000 AND user_id <= 1100)
| スキャン種類 | 選択性 | I/Oパターン | 最適なケース |
|---|---|---|---|
| Seq Scan | テーブル全体または15%超 | 逐次読み取り | 小テーブル / 大結果セット |
| Index Scan | 5%未満 | ランダム読み取り | 精密検索 |
| Bitmap Scan | 5%〜15% | ランダム→逐次 | 範囲 + ソート |
▶ サンプル: Nest Loop vs Hash Join
-- 小外部 + インデックス付き内部 = Nest Loop
EXPLAIN (COSTS OFF)
SELECT o.id, u.name
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id = 1001;
-- 大外部 + 未ソート内部 = Hash Join
EXPLAIN (COSTS OFF)
SELECT o.id, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.order_date > '2024-01-01';
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
▶ サンプル: 不足インデックスを特定して修正
-- 遅いクエリ: 大テーブルへのSeq Scan
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at > now() - interval '7 days';
-- Seq Scan, cost=0.00..15432.00, actual time=120ms
-- インデックスを追加
CREATE INDEX idx_orders_created_at ON orders (created_at);
-- 正確な統計のために再分析
ANALYZE orders;
-- 同じクエリが今度はIndex Scanを使用
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at > now() - interval '7 days';
-- Index Scan, cost=0.42..890.00, actual time=2ms
Output:
CREATE TABLE
5. 概念: pg_stat_statements スロークエリ
(1) 有効化と設定
# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
pg_stat_statements.max = 10000
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
(2) 主要指標の解釈
| カラム | 意味 | 最適化の方向性 |
|---|---|---|
| total_exec_time | 合計実行時間 | 最も時間がかかるクエリを最適化 |
| mean_exec_time | 平均実行時間 | 単一の遅いクエリ |
| calls | 呼び出し回数 | 高頻度クエリを優先 |
| rows | 返された合計行数 | 返される行が多すぎないか確認 |
| shared_blks_hit | バッファヒット | ヒット率が低い → shared_buffers増加 |
| shared_blks_read | ディスク読み取り | ディスク読み取りが多い → インデックス追加 / キャッシュ増加 |
▶ サンプル: 上位N件のスロークエリを検索
-- 合計時間トップ10
SELECT left(query, 80) AS query_preview,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 1) AS avg_ms,
rows,
round((100.0 * shared_blks_hit /
nullif(shared_blks_hit + shared_blks_read, 0))::numeric, 1) AS cache_hit_pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Output:
result
----------
42.50
(1 row)
▶ サンプル: I/O集中クエリを検索
-- ディスク読み取りが最も多いクエリ
SELECT left(query, 80) AS query_preview,
shared_blks_read AS disk_reads,
shared_blks_hit AS cache_hits,
calls,
round(mean_exec_time::numeric, 1) AS avg_ms
FROM pg_stat_statements
WHERE shared_blks_read > 1000
ORDER BY shared_blks_read DESC
LIMIT 10;
Output:
result
----------
42.50
(1 row)
6. 概念: 接続プールと設定チューニング
(1) PgBouncer接続プール
| モード | 説明 | 最適なケース |
|---|---|---|
| セッションプーリング | 接続がクライアントに紐付く | セッション変数/一時テーブルが必要 |
| トランザクションプーリング | トランザクション後に接続を返却 | ほとんどのWebアプリケーション |
| ステートメントプーリング | 文の後に接続を返却 | トランザクション不要の単純なクエリ |
# pgbouncer.ini
[databases]
shop_db = host=127.0.0.1 port=5432 dbname=shop
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
| パラメータ | 推奨値 | 説明 |
|---|---|---|
| max_client_conn | 500〜1000 | 最大クライアント接続数 |
| default_pool_size | 20〜50 | データベース/ユーザーあたりのプールサイズ |
| reserve_pool_size | 5〜10 | バーストトラフィック用予備プール |
| pool_mode | トランザクション | Webアプリに推奨 |
(2) PostgreSQLコアパラメータ
# 16GB RAMサーバー向けのpostgresql.confチューニング
shared_buffers = 4GB # RAMの25%
work_mem = 64MB # ソート操作あたりのメモリ
effective_cache_size = 12GB # RAMの75%
maintenance_work_mem = 1GB # VACUUM, CREATE INDEX用
effective_io_concurrency = 200 # SSDの場合。HDDは2
random_page_cost = 1.1 # SSDの場合。HDDは4.0
| パラメータ | デフォルト | 推奨(16GB RAM) | 説明 |
|---|---|---|---|
| shared_buffers | 128MB | RAMの25% | 共有バッファ |
| work_mem | 4MB | 32〜128MB | ソート/ハッシュメモリ |
| effective_cache_size | 4GB | RAMの75% | プランナーのキャッシュ推定 |
| maintenance_work_mem | 64MB | 512MB〜1GB | メンテナンス操作メモリ |
| max_parallel_workers | 8 | CPUコア数 | 並列ワーカープロセス |
▶ サンプル: パラメータの反映確認
-- 現在の設定を確認
SHOW shared_buffers;
SHOW work_mem;
SHOW effective_cache_size;
-- 実行時に変更(一部は再起動不要)
ALTER SYSTEM SET work_mem = '64MB';
SELECT pg_reload_conf();
-- 再起動必須のパラメータ
SELECT name, setting, boot_val, context
FROM pg_settings
WHERE name IN ('shared_buffers', 'work_mem', 'effective_cache_size');
Output:
ALTER TABLE
7. 概念: autovacuumの原理とチューニング
(1) autovacuumが必要な理由
PostgreSQLのMVCC機構: UPDATE/DELETEはデッドタプルを生成し、VACUUMで領域を回収する必要があります。回収しないとテーブルが肥大化し、クエリが遅くなります。
-- デッドタプル率を確認
SELECT relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
(2) autovacuum主要パラメータ
| パラメータ | デフォルト | チューニング提案 |
|---|---|---|
| autovacuum | on | 必ず有効に |
| autovacuum_vacuum_threshold | 50 | vacuumトリガーの基本デッドタプル数 |
| autovacuum_vacuum_scale_factor | 0.2 | 行の20%がデッドタプルでvacuumトリガー |
| autovacuum_analyze_scale_factor | 0.1 | 10%の変更でanalyzeトリガー |
| autovacuum_vacuum_cost_delay | 2ms | vacuumのスロットリング遅延 |
| autovacuum_vacuum_cost_limit | 200 | 1ラウンドあたりのvacuum I/O制限 |
▶ サンプル: 大テーブル向けautovacuumチューニング
-- 高書き込みテーブルではscale_factorを下げて頻繁にvacuum
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_cost_delay = '1ms',
autovacuum_vacuum_cost_limit = 1000
);
-- 1000万行のordersテーブルでは、vacuumのトリガーは:
-- 1000万 × 0.05 = 50万デッドタプル(デフォルトの200万と比較)
Output:
-- SQL statement executed successfully
▶ サンプル: Vacuum進捗の監視
-- PG 12+: vacuum進捗の追跡
SELECT pid,
relid::regclass AS table_name,
phase,
heap_blks_total,
heap_blks_scanned,
heap_blks_vacuumed,
index_vacuum_count
FROM pg_stat_progress_vacuum;
Output:
count
-------
5
(1 row)
8. 操作: 並列クエリと監視
▶ サンプル: 並列クエリの有効化
-- 並列クエリを有効化(PG 10+)
SET max_parallel_workers_per_gather = 4;
SET max_parallel_workers = 8;
SET parallel_tuple_cost = 0.001;
SET min_parallel_table_scan_size = '8MB';
-- 並列計画を確認
EXPLAIN (ANALYZE, COSTS OFF)
SELECT COUNT(*), category
FROM products
GROUP BY category;
Finalize Aggregate (actual time=12.3..12.4 rows=5 loops=1)
-> Gather (actual time=12.1..12.3 rows=15 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (actual time=8.5..8.6 rows=5 loops=3)
-> Parallel Seq Scan on products (actual time=0.2..6.1 rows=33333 loops=3)
▶ サンプル: pg_stat_user_tables監視
SELECT relname AS table_name,
seq_scan,
seq_tup_read,
idx_scan,
idx_tup_fetch,
round(100.0 * idx_scan / nullif(idx_scan + seq_scan, 0), 2) AS idx_scan_pct,
n_live_tup,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY seq_tup_read DESC;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
| 監視指標 | アラート閾値 | アクション |
|---|---|---|
| seq_tup_read >> idx_tup_fetch | 高いseq_scan | インデックス追加 |
| dead_pct > 20% | 深刻な肥大化 | autovacuumチューニング |
| cache_hit_pct < 95% | バッファ不足 | shared_buffers増加 |
| last_autovacuum > 7日 | vacuumが適時でない | scale_factorを下げる |
▶ サンプル: パフォーマンスアンチパターンの特定と修正
-- アンチパターン1: OR条件がインデックス使用を妨げる
-- 悪い
SELECT * FROM orders WHERE user_id = 1001 OR status = 'pending';
-- 良い: UNION ALLを使用
SELECT * FROM orders WHERE user_id = 1001
UNION ALL
SELECT * FROM orders WHERE status = 'pending' AND user_id != 1001;
-- アンチパターン2: 関数がインデックス付きカラムをラップ
-- 悪い
SELECT * FROM orders WHERE lower(status) = 'pending';
-- 良い
SELECT * FROM orders WHERE status = lower('PENDING');
-- アンチパターン3: 先頭ワイルドカード付きLIKE
-- 悪い
SELECT * FROM products WHERE name LIKE '%phone%';
-- 良い: pg_trgmまたは全文検索
SELECT * FROM products WHERE name % 'phone';
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
9. 総合サンプル
-- CharlieのEコマースシステム向け完全なパフォーマンス最適化ワークフロー
-- ステップ1: スロークエリを特定
SELECT left(query, 60) AS q, calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 1) AS avg_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 5;
-- ステップ2: 最悪のクエリを分析
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total_amount, u.name, p.name AS product_name
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id
WHERE o.order_date BETWEEN '2024-01-01' AND '2024-03-31'
AND o.status = 'completed';
-- ステップ3: 不足インデックスを追加
CREATE INDEX idx_orders_date_status ON orders (order_date, status);
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
CREATE INDEX idx_order_items_product_id ON order_items (product_id);
-- ステップ4: 統計情報を更新
ANALYZE orders;
ANALYZE order_items;
ANALYZE products;
-- ステップ5: 高書き込みテーブルのautovacuumチューニング
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_cost_limit = 1000
);
-- ステップ6: 複雑なソート用にwork_memを増加
ALTER SYSTEM SET work_mem = '64MB';
ALTER SYSTEM SET effective_cache_size = '12GB';
SELECT pg_reload_conf();
-- ステップ7: 改善を確認
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total_amount, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.order_date BETWEEN '2024-01-01' AND '2024-03-31'
AND o.status = 'completed';
-- ステップ8: テーブル健全性を監視
SELECT relname,
n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname IN ('orders', 'order_items', 'products')
ORDER BY n_dead_tup DESC;
❓ よくある質問
📖 まとめ
- EXPLAIN ANALYZEはパフォーマンスチューニングの第一ツール。オペレータとコストの読み方が鍵
- Seq Scan / Index Scan / Bitmap Scanは異なる選択性のケースに適する
- pg_stat_statementsでスロークエリを特定: total_time, calls, cache_hitを監視
- PgBouncerのトランザクションモードはWebアプリの標準接続プール
- shared_buffersはRAMの25%、work_memはクエリ複雑度に応じて、effective_cache_sizeはRAMの75%
- 高書き込みテーブルのautovacuumはscale_factorを下げ、cost_limitを上げる
- 一般的なアンチパターンを避ける: OR条件、関数でラップされたインデックスカラム、先頭ワイルドカードLIKE
📝 練習問題
-
⭐ 100万行のテストテーブルで、EXPLAIN ANALYZEを使ってSeq ScanとIndex Scanのactual-timeの差を観察し、コスト推定と実時間のギャップを記録してください。
-
⭐⭐ pg_stat_statementsを設定し、24時間分のクエリ統計を収集して、スロークエリレポートを作成: total_exec_time、mean_exec_time、shared_blks_readの各観点でTop 5をリストアップし、最適化提案を記述してください。
-
⭐⭐⭐ Charlieのケースをシミュレーション: 5つのテーブル(orders/order_items/users/products/payments)を作成し、テストデータを挿入し、EXPLAIN ANALYZEで3つのパフォーマンス問題(インデックス不足、work_mem不足、autovacuum遅延)を特定し、それぞれ修正して、各修正のパフォーマンス改善倍率をEXPLAIN ANALYZEで確認してください。