目次を表示する

RDB内部構造完全ガイド

クエリ最適化のパターン集 ── 「遅い」を「速い」に変える

クエリ最適化のパターン集 ── 「遅い」を「速い」に変える

このスキルの役割:EXPLAIN で原因を特定した後、どう直すか。具体的な最適化テクニックのカタログ。


クエリ最適化パターン — 10の実践テクニック

この章で何ができるようになるか:「このクエリパターンにはこの最適化」という引き出しを持ち、実際のパフォーマンス問題を解決できるようになる。


パターン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