パフォーマンス
EXPLAIN の読み方、スロークエリの見つけ方、N+1、キャッシュ、connection pool、 VACUUM / ANALYZE まで実用的に。
最適化の順序
- 計測 — どこが遅いか確認
- クエリ修正 — 一番効果的
- インデックス追加
- スキーマ調整(非正規化)
- キャッシュ
- Read Replica
- シャーディング / 分散 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
- cost: プランナの推定(任意単位)
- rows: 推定 / 実際の行数(大きく違うと統計古い)
- actual time: 実時間 ms
- Buffers: shared hit(キャッシュ命中)/ read(ディスク)
- loops: ネスト時の繰り返し数
スキャン種別
| 種別 | 意味 |
|---|---|
| Seq Scan | 全件読み。小テーブルや大量返却なら OK |
| Index Scan | インデックスで行特定 + テーブル参照 |
| Index Only Scan | インデックスだけで完結(最速) |
| Bitmap Heap Scan | 多数行を効率的に集める |
Join の種別
| 種別 | 適用 |
|---|---|
| Nested Loop | 1 側が小さく、index がある時 |
| Hash Join | 1 側を 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;
巨大データの集計
- マテリアライズドビュー
- カウンタキャッシュ列(トリガーで同期)
- 事前集計テーブル(夜間バッチ)
- OLAP 系(ClickHouse / DuckDB / BigQuery)に流す
マテリアライズドビュー
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');
- 古いパーティションをDROP するだけで削除できる(DELETE より圧倒的に速い)
- パーティションプルーニングで該当範囲だけ走査
- 主に時系列ログ向き
クエリの書き換えテクニック
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 があると転送コスト大。
キャッシュ階層
- クエリ結果キャッシュ(Redis)— 重いクエリを保存
- アプリレベルキャッシュ(メモリ / LRU)
- HTTP / CDN キャッシュ
- shared_buffers(DB のメモリ内キャッシュ)
- OS のページキャッシュ
Connection Pool
- 新規接続は重い(数十 ms)
- サーバ側 Pool(PgBouncer / Supavisor)
- アプリ側 Pool(pg / postgres / Prisma)
- Serverless 環境では外部 Pool 必須
VACUUM と ANALYZE
- VACUUM: 古い行を回収
- ANALYZE: 統計情報を更新(プランナ用)
- autovacuum で自動だが、巨大更新後は手動も
- 統計が古いと実行計画が狂う
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 設定の主要パラメータ
- shared_buffers: DB のメモリキャッシュ(メモリの 25% 目安)
- effective_cache_size: OS キャッシュ含む推定値(メモリの 50〜75%)
- work_mem: ソート / ハッシュの作業メモリ(4〜64MB)
- maintenance_work_mem: VACUUM / CREATE INDEX 用(256MB〜1GB)
- max_connections: 接続上限(外部 Pool 前提なら少なめ)
- random_page_cost: SSD なら 1.1(HDD なら 4.0 既定)
監視・観測
- クエリレイテンシ p50 / p95 / p99
- 接続数と ロック待ち
- CPU / Memory / Disk I/O
- レプリカラグ
- Bloat(テーブル膨張)
- tools: pgAdmin / pgHero / DataDog / Grafana + postgres_exporter
OLAP 用の選択肢
- DuckDB: ローカル分析、Parquet を直接読める
- ClickHouse: 列指向、超高速集計
- BigQuery / Snowflake / Redshift: クラウド DWH
- Postgres でも CTE + Window 関数 + マテビューでかなり戦える
失敗パターン
| 症状 | 対処 |
|---|---|
| 急に遅くなった | ANALYZE が古い / プラン変化 / データ量増加 |
| 夜だけ遅い | autovacuum or バックアップ実行中 |
| CPU 高い | 未インデックスのクエリ。pg_stat_statements で特定 |
| I/O 高い | shared_buffers / インデックス見直し |
| 接続数枯渇 | 外部 Pool / コネクションの leak |
| ロック待ち頻発 | 長いトランザクション / 順序不一致 |
計測 → 改善のサイクル
- 遅い理由を必ず仮説化
- EXPLAIN ANALYZE で確認
- 1 つ変える、再計測
- 本番に入れる前に同等量のデータでテスト
大原則
「計測なくして最適化なし」。EXPLAIN ANALYZE を読めるようになると世界が変わる。 そして本当に多くの問題はインデックスで解ける。