PostgreSQL: PostgreSQLパフォーマンスチューニング実践

最終更新:2026-08-26

1. 学習内容


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の主要出力フィールド

SQL
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE user_id = 1001;
TEXT 📖 参照専用
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) 実行計画の読み方フロー

100%
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 フルテーブルスキャン

SQL
-- statusにインデックスがないため、プランナーはSeq Scanを選択
EXPLAIN (ANALYZE, COSTS OFF)
SELECT COUNT(*) FROM orders WHERE status = 'pending';
TEXT 📖 参照専用
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 精密検索

SQL
CREATE INDEX idx_orders_user_id ON orders (user_id);

-- 高選択性: プランナーはIndex Scanを使用
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders WHERE user_id = 1001;
TEXT 📖 参照専用
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 中選択性

SQL
-- 中程度の選択性: プランナーはBitmapを選択
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders WHERE user_id BETWEEN 1000 AND 1100;
TEXT 📖 参照専用
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

SQL
-- 小外部 + インデックス付き内部 = 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:

TEXT 📖 参照専用
 id | name     | value 
----+----------+-------
  1 | example  | 42
(1 row)

▶ サンプル: 不足インデックスを特定して修正

SQL
-- 遅いクエリ: 大テーブルへの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:

TEXT 📖 参照専用
CREATE TABLE

5. 概念: pg_stat_statements スロークエリ

(1) 有効化と設定

BASH
# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
pg_stat_statements.max = 10000
SQL
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件のスロークエリを検索

SQL
-- 合計時間トップ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:

TEXT 📖 参照専用
  result  
----------
   42.50
(1 row)

▶ サンプル: I/O集中クエリを検索

SQL
-- ディスク読み取りが最も多いクエリ
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:

TEXT 📖 参照専用
  result  
----------
   42.50
(1 row)

6. 概念: 接続プールと設定チューニング

(1) PgBouncer接続プール

モード 説明 最適なケース
セッションプーリング 接続がクライアントに紐付く セッション変数/一時テーブルが必要
トランザクションプーリング トランザクション後に接続を返却 ほとんどのWebアプリケーション
ステートメントプーリング 文の後に接続を返却 トランザクション不要の単純なクエリ
BASH
# 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コアパラメータ

BASH
# 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コア数 並列ワーカープロセス

▶ サンプル: パラメータの反映確認

SQL
-- 現在の設定を確認
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:

TEXT 📖 参照専用
ALTER TABLE

7. 概念: autovacuumの原理とチューニング

(1) autovacuumが必要な理由

PostgreSQLのMVCC機構: UPDATE/DELETEはデッドタプルを生成し、VACUUMで領域を回収する必要があります。回収しないとテーブルが肥大化し、クエリが遅くなります。

SQL
-- デッドタプル率を確認
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チューニング

SQL
-- 高書き込みテーブルでは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:

TEXT 📖 参照専用
-- SQL statement executed successfully

▶ サンプル: Vacuum進捗の監視

SQL
-- 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:

TEXT 📖 参照専用
 count 
-------
     5
(1 row)

8. 操作: 並列クエリと監視

▶ サンプル: 並列クエリの有効化

SQL
-- 並列クエリを有効化(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;
TEXT 📖 参照専用
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監視

SQL
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:

TEXT 📖 参照専用
 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を下げる

▶ サンプル: パフォーマンスアンチパターンの特定と修正

SQL
-- アンチパターン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:

TEXT 📖 参照専用
 id | name     | value 
----+----------+-------
  1 | example  | 42
(1 row)

9. 総合サンプル

SQL
-- 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;

❓ よくある質問

Q EXPLAINとEXPLAIN ANALYZEの違いは何ですか?
A EXPLAINは推定計画のみを表示し、クエリを実行しません。EXPLAIN ANALYZEは実際に実行し、実際の時間と行数を返します。ANALYZEはINSERT/UPDATE/DELETEを実行するため、DMLを分析する際はトランザクションでラップし、後でROLLBACKしてください。
Q コスト値が大きいのに実際は速いのはなぜですか?
A costはプランナーが推定する単位値(ミリ秒ではない)で、統計情報の影響を受けます。ANALYZEの統計が古い場合、costが不正確になる可能性があります。定期的にANALYZEを実行して統計を最新に保ってください。
Q shared_buffersはどのくらいに設定すべきですか?
A 一般的に物理RAMの25%で、40%を超えないようにします。Linuxでは40%を超えるとOSのページキャッシュと競合する可能性があります。Windowsでは512MB〜1GB以内に収めてください。
Q 大きなwork_memはOOMを引き起こしますか?
A work_memはソート/ハッシュ操作あたりのメモリ制限であり、1つの複雑なクエリで複数の操作が同時に実行されることがあります。大きく設定しすぎるとOOMの原因になります。32〜128MBを推奨し、実際の使用状況を監視してチューニングし��ください。
Q autovacuumが書き込み速度に追いつかな��場合は?
A scale_factorを下げ(例: 0.02)、cost_limitを上げ(例: 2000)、cost_delayを下げ(例: 1ms)、大テーブルごとに設定します。極端なケースでは手動でVACUUMを並列実行します。
Q 並列クエリはいつ有効になりますか?
A テーブルサイズがmin_parallel_table_scan_size(デフォルト8MB)を超え、クエリコストが並列起動閾値を超え、max_parallel_workersに空きがある場合です。小テーブルや単純なクエリは並列化されません。
Q Seq Scanは常にIndex Scanより悪いのでしょうか?
A 必ずしもそうではありません。テーブルの大部分を返す必要がある場合、Seq Scanの逐次読み取りはIndex Scanのランダム読み取りより効率的です。PGプランナーは自動的に低コストの選択肢を選びます。

📖 まとめ


📝 練習問題

  1. ⭐ 100万行のテストテーブルで、EXPLAIN ANALYZEを使ってSeq ScanとIndex Scanのactual-timeの差を観察し、コスト推定と実時間のギャップを記録してください。

  2. ⭐⭐ pg_stat_statementsを設定し、24時間分のクエリ統計を収集して、スロークエリレポートを作成: total_exec_time、mean_exec_time、shared_blks_readの各観点でTop 5をリストアップし、最適化提案を記述してください。

  3. ⭐⭐⭐ Charlieのケースをシミュレーション: 5つのテーブル(orders/order_items/users/products/payments)を作成し、テストデータを挿入し、EXPLAIN ANALYZEで3つのパフォーマンス問題(インデックス不足、work_mem不足、autovacuum遅延)を特定し、それぞれ修正して、各修正のパフォーマンス改善倍率をEXPLAIN ANALYZEで確認してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%