パフォーマンス

EXPLAIN の読み方、スロークエリの見つけ方、N+1、キャッシュ、connection pool、 VACUUM / ANALYZE まで実用的に。

最適化の順序

  1. 計測 — どこが遅いか確認
  2. クエリ修正 — 一番効果的
  3. インデックス追加
  4. スキーマ調整(非正規化)
  5. キャッシュ
  6. Read Replica
  7. シャーディング / 分散 DB(最終手段)

EXPLAIN の使い方

EXPLAIN
SELECT * FROM posts WHERE user_id = 42;

-- 実行までして実時間も
EXPLAIN ANALYZE
SELECT * FROM posts WHERE user_id = 42;

-- 詳細・バッファ情報も
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT ...;

出力の読み方

Bitmap Heap Scan on posts  (cost=4.43..38.23 rows=20 width=512)
                            (actual time=0.040..0.080 rows=18 loops=1)
  Recheck Cond: (user_id = 42)
  Heap Blocks: exact=12
  Buffers: shared hit=15
  ->  Bitmap Index Scan on posts_user_idx
        Index Cond: (user_id = 42)
        actual time=0.020..0.020 rows=18 loops=1
Planning Time: 0.123 ms
Execution Time: 0.150 ms
      

スキャン種別

種別意味
Seq Scan全件読み。小テーブルや大量返却なら OK
Index Scanインデックスで行特定 + テーブル参照
Index Only Scanインデックスだけで完結(最速)
Bitmap Heap Scan多数行を効率的に集める

Join の種別

種別適用
Nested Loop1 側が小さく、index がある時
Hash Join1 側を hash 化して結合(一般的)
Merge Join両方ソート済みの大規模結合

N+1 問題

悪い例

const users = await sql`SELECT * FROM users`
for (const u of users) {
  const posts = await sql`SELECT * FROM posts WHERE user_id = ${u.id}`
  // ループ内でクエリ → 1 + N 回

良い例(JOIN)

SELECT u.id, u.email,
       json_agg(p.*) AS posts
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.id;

または IN で 2 クエリ

SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (...);
-- アプリ側で関連付け

カウント高速化

巨大テーブルでの COUNT(*) は重い。

近似値で良い場合

-- pg_class の統計(高速、近似)
SELECT reltuples::bigint
FROM pg_class
WHERE relname = 'posts';

-- 完全一致が必要なら
SELECT count(*) FROM posts;

ページング

OFFSET は後ろのページほど遅くなるカーソル式に切替:

-- NG (深いオフセットで遅い)
SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 100000;

-- OK (cursor)
SELECT * FROM posts WHERE id > $last_id ORDER BY id LIMIT 20;

巨大データの集計

マテリアライズドビュー

CREATE MATERIALIZED VIEW post_stats AS
SELECT user_id, count(*) AS post_count, max(created_at) AS last_post
FROM posts
GROUP BY user_id;

CREATE UNIQUE INDEX post_stats_user_idx ON post_stats (user_id);

-- 定期的に更新
REFRESH MATERIALIZED VIEW CONCURRENTLY post_stats;

パーティショニング

巨大テーブルを論理的に分割。Postgres は宣言的パーティション対応。

CREATE TABLE events (
  id BIGSERIAL,
  created_at TIMESTAMPTZ NOT NULL,
  payload JSONB
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_01 PARTITION OF events
  FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE events_2026_02 PARTITION OF events
  FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

クエリの書き換えテクニック

EXISTS は IN より速いことが多い

-- 遅い
SELECT * FROM users WHERE id IN (SELECT user_id FROM posts);

-- 速い
SELECT * FROM users WHERE EXISTS (SELECT 1 FROM posts WHERE user_id = users.id);

WHERE で関数を呼ぶと index 使えない

-- NG
WHERE LOWER(email) = 'alice@ex.com'

-- OK(関数式インデックス)
CREATE INDEX users_email_lower ON users (LOWER(email));

UNION より UNION ALL(重複削除が要らないなら)

-- 重複チェックのソートが入る
SELECT * FROM a UNION SELECT * FROM b;

-- そのまま結合
SELECT * FROM a UNION ALL SELECT * FROM b;

SELECT * を避ける

必要な列だけ。特に大きな text / json / blob があると転送コスト大。

キャッシュ階層

  1. クエリ結果キャッシュ(Redis)— 重いクエリを保存
  2. アプリレベルキャッシュ(メモリ / LRU)
  3. HTTP / CDN キャッシュ
  4. shared_buffers(DB のメモリ内キャッシュ)
  5. OS のページキャッシュ

Connection Pool

VACUUM と ANALYZE

VACUUM ANALYZE posts;
VACUUM FULL posts;  -- ロック取って完全圧縮(普段は不要)

スロークエリの見つけ方

pg_stat_statements

CREATE EXTENSION pg_stat_statements;

SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

auto_explain

# postgresql.conf
shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '1s'
auto_explain.log_analyze = on

ログ出力

log_min_duration_statement = 200ms
log_lock_waits = on

Postgres 設定の主要パラメータ

監視・観測

OLAP 用の選択肢

失敗パターン

症状対処
急に遅くなったANALYZE が古い / プラン変化 / データ量増加
夜だけ遅いautovacuum or バックアップ実行中
CPU 高い未インデックスのクエリ。pg_stat_statements で特定
I/O 高いshared_buffers / インデックス見直し
接続数枯渇外部 Pool / コネクションの leak
ロック待ち頻発長いトランザクション / 順序不一致

計測 → 改善のサイクル

  1. 遅い理由を必ず仮説化
  2. EXPLAIN ANALYZE で確認
  3. 1 つ変える、再計測
  4. 本番に入れる前に同等量のデータでテスト
大原則

計測なくして最適化なし」。EXPLAIN ANALYZE を読めるようになると世界が変わる。 そして本当に多くの問題はインデックスで解ける