パフォーマンス問題の根本を理解する
SQLのパフォーマンス問題の80%以上は、インデックスの不足または不適切なクエリ設計に起因します。まず「なぜ遅いのか」を正確に診断することが最適化の第一歩です。推測で最適化するのではなく、実行計画(EXPLAIN)を活用して根本原因を特定しましょう。
ステップ1:実行計画を読む
PostgreSQLのEXPLAIN ANALYZEはクエリの実行計画と実際の実行時間を詳細に表示します。
-- パフォーマンス分析の基本
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2025-01-01'
GROUP BY u.id, u.name
ORDER BY order_count DESC
LIMIT 100;
-- 注目すべき項目:
-- Seq Scan(全表スキャン) → インデックスが使われていない
-- cost=... rows=... → 推定コストと行数
-- actual time=... → 実際の実行時間
-- Nested Loop / Hash Join → 結合アルゴリズム
ステップ2:効果的なインデックス設計
インデックスは諸刃の剣です。読み取りを高速化しますが、書き込みを遅くしメモリを消費します。適切なカラムに適切な種類のインデックスを作成することが重要です。
-- 単一カラムインデックス
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- 複合インデックス(よく組み合わせて検索するカラム)
-- WHERE status = 'active' AND created_at >= '2025-01-01' のような場合
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);
-- 部分インデックス(条件を絞ることでサイズを削減)
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';
-- カバリングインデックス(インデックスだけでクエリを完結させる)
CREATE INDEX idx_users_email_covering ON users(email) INCLUDE (id, name);
-- SELECT id, name FROM users WHERE email = ? がインデックスだけで完結
-- 不要なインデックスを確認
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 -- 一度も使われていないインデックス
ORDER BY pg_relation_size(indexrelid) DESC;
ステップ3:クエリの最適化テクニック
インデックスが適切でもクエリの書き方によってはパフォーマンスが低下します。一般的なアンチパターンとその改善方法を紹介します。
-- ❌ 悪い例:SELECT *(不要なカラムも取得)
SELECT * FROM orders WHERE user_id = $1;
-- ✅ 良い例:必要なカラムのみ指定
SELECT id, total_amount, status, created_at FROM orders WHERE user_id = $1;
-- ❌ 悪い例:N+1問題(ループ内でクエリを実行)
-- users = SELECT * FROM users
-- for each user: SELECT * FROM orders WHERE user_id = user.id
-- ✅ 良い例:JOINで一度に取得
SELECT u.id, u.name, o.id AS order_id, o.total_amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.id = ANY($1::uuid[]);
-- ❌ 悪い例:インデックスが効かない関数適用
SELECT * FROM orders WHERE YEAR(created_at) = 2025;
-- ✅ 良い例:範囲条件に変換
SELECT * FROM orders
WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';
-- ❌ 悪い例:LIKE検索のワイルドカード前置き
SELECT * FROM products WHERE name LIKE '%phone%';
-- ✅ 良い例:全文検索インデックスを使用
CREATE INDEX idx_products_name_fts ON products USING GIN(to_tsvector('english', name));
SELECT * FROM products WHERE to_tsvector('english', name) @@ plainto_tsquery('phone');
ステップ4:接続プールとクエリキャッシュ
データベースへの接続はコストが高い操作です。接続プールを活用してオーバーヘッドを削減しましょう。また、同じクエリが頻繁に実行される場合はマテリアライズドビューを検討します。
-- マテリアライズドビューで集計クエリを高速化
CREATE MATERIALIZED VIEW monthly_sales AS
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total_amount) AS total_sales,
COUNT(*) AS order_count
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at);
CREATE INDEX idx_monthly_sales_month ON monthly_sales(month);
-- 定期的に更新(夜間バッチなど)
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales;