クエリ最適化のパターン集 ── 「遅い」を「速い」に変える
このスキルの役割:EXPLAIN で原因を特定した後、どう直すか。具体的な最適化テクニックのカタログ。

この章で何ができるようになるか:「このクエリパターンにはこの最適化」という引き出しを持ち、実際のパフォーマンス問題を解決できるようになる。
パターン1: カーディナリティが高いカラムから絞り込む
-- ❌ カーディナリティが低いカラム(status: 3種類)で先に絞る
SELECT * FROM orders WHERE status = 'shipped' AND user_id = 12345;
-- Index: (status, user_id) → status='shipped' が多すぎて絞り込み不足
-- ✅ カーディナリティが高いカラム(user_id: 100万種類)で先に絞る
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
SELECT * FROM orders WHERE user_id = 12345 AND status = 'shipped';
-- → user_id=12345 で数十件に絞り込み → status のフィルタは微小コスト
パターン2: OR を UNION ALL に変換
-- ❌ OR はインデックスを効率的に使えないことがある
SELECT * FROM products WHERE category_id = 5 OR brand_id = 10;
-- → Bitmap Or で2つのインデックスを結合するが、非効率な場合がある
-- ✅ UNION ALL で分離
SELECT * FROM products WHERE category_id = 5
UNION ALL
SELECT * FROM products WHERE brand_id = 10 AND category_id != 5;
-- → 各 SELECT が独立してインデックスを使える
パターン3: EXISTS vs IN vs JOIN
-- 「注文があるユーザー」を取得する3つの方法
-- IN(サブクエリの結果が大きいと遅い)
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders);
-- EXISTS(相関サブクエリ。外側の行ごとに内側を評価するが、
-- 最初の1行が見つかれば即終了するため効率的)
SELECT * FROM users u WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- JOIN(重複が生じうるので DISTINCT が必要な場合がある)
SELECT DISTINCT u.* FROM users u JOIN orders o ON u.id = o.user_id;
実測が最重要。PostgreSQL のプランナーは多くの場合で IN / EXISTS を同等に最適化するが、データ分布やインデックスの有無で差が出る。
パターン4: OFFSET をやめて Keyset Pagination に
-- ❌ OFFSET(ページが深くなるほど遅い)
SELECT * FROM articles ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
-- → 10020行を読んで10000行を捨てる
-- ✅ Keyset Pagination(常に O(log n + limit))
SELECT * FROM articles
WHERE created_at < '2026-03-15T10:30:00' -- 前ページの最後の値
ORDER BY created_at DESC
LIMIT 20;
-- → インデックスで直接その位置にジャンプ
パターン5: COUNT(*) の高速化
-- ❌ 正確な件数(全件スキャンが必要)
SELECT COUNT(*) FROM users WHERE status = 'active';
-- → MVCC のため全タプルの可視性チェックが必要 → 大テーブルで遅い
-- ✅ 近似値で十分な場合
SELECT reltuples::bigint AS estimated_count
FROM pg_class WHERE relname = 'users';
-- → 統計情報の推定値(瞬時に返る。ANALYZE の精度に依存)
-- ✅ 条件付き件数の近似
EXPLAIN SELECT * FROM users WHERE status = 'active';
-- → 実行計画の rows= が推定件数
パターン6: 不要なカラムを SELECT しない
-- ❌ SELECT *(全カラム取得)
SELECT * FROM users WHERE id = 12345;
-- → TOAST 化された大きなカラム(bio TEXT)も読み込む
-- → Index Only Scan が使えない
-- ✅ 必要なカラムだけ
SELECT name, email FROM users WHERE id = 12345;
-- → TOAST の読み込みを回避
-- → カバリングインデックスがあれば Index Only Scan
パターン7: バッチ処理の最適化
-- ❌ 1行ずつ INSERT(N回のラウンドトリップ + N回の WAL 書き込み)
INSERT INTO logs (message) VALUES ('event1');
INSERT INTO logs (message) VALUES ('event2');
INSERT INTO logs (message) VALUES ('event3');
-- ✅ マルチ行 INSERT(1回のラウンドトリップ + 1回の WAL 書き込み)
INSERT INTO logs (message) VALUES ('event1'), ('event2'), ('event3');
-- ✅✅ COPY(最速のバルクロード)
COPY logs (message) FROM '/tmp/events.csv' WITH (FORMAT csv);
-- → WAL の書き込みも最小化、パーサーのオーバーヘッドもなし
パターン8: マテリアライズドビューで重いクエリをキャッシュ
-- 毎回計算すると重い集約クエリ
CREATE MATERIALIZED VIEW monthly_sales AS
SELECT
date_trunc('month', created_at) AS month,
product_id,
SUM(amount) AS total_sales,
COUNT(*) AS order_count
FROM orders
GROUP BY 1, 2;
-- 瞬時に結果を返す
SELECT * FROM monthly_sales WHERE month = '2026-03-01';
-- 定期的にリフレッシュ(ロックなし)
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales;
-- CONCURRENTLY: リフレッシュ中も古いデータで SELECT 可能
-- ユニークインデックスが必要
CREATE UNIQUE INDEX ON monthly_sales (month, product_id);
パターン9: 関数インデックスで計算結果をインデックス化
-- ❌ 関数を WHERE 句で使うとインデックスが効かない
SELECT * FROM events WHERE date_trunc('day', created_at) = '2026-04-01';
-- → created_at のインデックスは使えない(関数が噛んでいるため)
-- ✅ 式インデックスを作成
CREATE INDEX idx_events_day ON events(date_trunc('day', created_at));
-- → Index Scan が使えるようになる
-- ✅ Generated Column(PostgreSQL 12+)
ALTER TABLE events ADD COLUMN event_day DATE
GENERATED ALWAYS AS (created_at::date) STORED;
CREATE INDEX idx_events_event_day ON events(event_day);
パターン10: CTE の最適化(PostgreSQL 12+)
-- PostgreSQL 11 以前: CTE は「最適化の壁」(必ずマテリアライズされる)
-- PostgreSQL 12+: CTE はインライン展開される(プランナーが最適化できる)
-- 明示的にマテリアライズを強制(CTE の結果を再利用したい場合)
WITH heavy_query AS MATERIALIZED (
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
)
SELECT * FROM heavy_query WHERE cnt > 10
UNION ALL
SELECT * FROM heavy_query WHERE cnt = 1;
-- → heavy_query は1回だけ実行される
-- インライン展開を許可(プランナーに任せる)
WITH light_query AS NOT MATERIALIZED (
SELECT * FROM users WHERE status = 'active'
)
SELECT * FROM light_query WHERE age > 30;
-- → WHERE status = 'active' AND age > 30 にインライン展開される
まとめ
| パターン | 問題 | 解決策 |
|---|---|---|
| 絞り込み順序 | 低カーディナリティで先に絞る | 高カーディナリティを複合インデックスの先頭に |
| OR 条件 | インデックスが効かない | UNION ALL に分離 |
| ページネーション | OFFSET が深い | Keyset Pagination |
| COUNT(*) | 全件スキャン | 近似値(pg_class)/ マテリアライズドビュー |
| SELECT * | 不要なデータの読み込み | 必要カラムのみ取得 |
| バッチ挿入 | 1行ずつ INSERT | マルチ行 INSERT / COPY |
| 重い集約 | 毎回計算 | マテリアライズドビュー |
| 関数条件 | インデックス無効 | 式インデックス / Generated Column |