ストーリー
「EXPLAIN ANALYZEで問題は見つけた。じゃあ次はどうやって直す?」
高橋アーキテクトが実行計画を指差す。Seq Scan、大量のRows Removed、ネストループの嵐…。
「クエリチューニングには体系的なアプローチがある。闇雲にインデックスを追加するだけじゃダメだ。問題の根本を理解し、正しい手法を選ぶんだ」
チューニングの基本フロー
1. 遅いクエリを特定(スロークエリログ)
2. EXPLAIN ANALYZE で実行計画を確認
3. ボトルネックを特定
4. 改善手法を適用
5. 効果を計測(before/after)
技法1: SELECT句の最適化
-- NG: 全カラム取得
SELECT * FROM orders WHERE user_id = 1;
-- OK: 必要なカラムのみ
SELECT id, status, total, created_at
FROM orders WHERE user_id = 1;
-- 最善: Covering Index(Index Only Scan が可能)
CREATE INDEX idx_orders_covering
ON orders(user_id) INCLUDE (id, status, total, created_at);
技法2: WHERE句の最適化
インデックスを殺す書き方を避ける
-- NG: カラムに関数を適用
SELECT * FROM users WHERE YEAR(created_at) = 2024;
-- OK: 範囲検索に書き換え
SELECT * FROM users
WHERE created_at >= '2024-01-01'
AND created_at < '2025-01-01';
-- NG: 暗黙の型変換
SELECT * FROM users WHERE phone = 9012345678; -- phoneはVARCHAR
-- OK: 型を合わせる
SELECT * FROM users WHERE phone = '9012345678';
-- NG: OR条件(インデックスが効きにくい)
SELECT * FROM products WHERE category_id = 1 OR category_id = 2;
-- OK: IN句に書き換え
SELECT * FROM products WHERE category_id IN (1, 2);
式インデックス
-- 関数適用が避けられない場合は式インデックス
CREATE INDEX idx_users_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
技法3: JOINの最適化
JOIN順序とインデックス
-- 小さいテーブルから大きいテーブルへJOIN
-- (オプティマイザが自動調整する場合もある)
-- JOINカラムにインデックスがあることを確認
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
-- 効率的なJOIN
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE u.id = 1;
N+1問題の回避
// NG: N+1クエリ(ユーザーごとに注文を個別取得)
const users = await prisma.user.findMany();
for (const user of users) {
const orders = await prisma.order.findMany({
where: { userId: user.id }
});
}
// → 1 + N 回のクエリが発行される
// OK: JOINで一括取得
const usersWithOrders = await prisma.user.findMany({
include: { orders: true }
});
// → 2回のクエリ(users + orders)
技法4: サブクエリの最適化
-- NG: 相関サブクエリ(行ごとにサブクエリが実行される)
SELECT u.name,
(SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count
FROM users u;
-- OK: JOINに書き換え
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name;
-- NG: IN + サブクエリ(大量データで遅い)
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 10000);
-- OK: EXISTS に書き換え
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.total > 10000
);
技法5: ページネーション
-- NG: OFFSET方式(深いページで遅い)
SELECT * FROM articles
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000; -- 10,000行スキップが必要
-- OK: カーソルベース(Keyset Pagination)
SELECT * FROM articles
WHERE created_at < '2024-03-15 10:00:00' -- 前ページの最後のcreated_at
ORDER BY created_at DESC
LIMIT 20;
// TypeScript: カーソルベースページネーション
async function getArticles(cursor?: Date, limit: number = 20) {
return prisma.article.findMany({
where: cursor ? { createdAt: { lt: cursor } } : undefined,
orderBy: { createdAt: 'desc' },
take: limit,
});
}
技法6: 集計クエリの最適化
-- NG: 毎回COUNT(大テーブルで遅い)
SELECT COUNT(*) FROM orders WHERE status = 'pending';
-- OK: 近似値で十分な場合
SELECT reltuples::bigint AS estimate
FROM pg_class WHERE relname = 'orders';
-- OK: マテリアライズドビュー
CREATE MATERIALIZED VIEW order_stats AS
SELECT status, COUNT(*) AS cnt, SUM(total) AS total_amount
FROM orders
GROUP BY status;
-- 定期的にリフレッシュ
REFRESH MATERIALIZED VIEW CONCURRENTLY order_stats;
技法7: 部分インデックス
-- 全レコードにインデックスは不要
-- アクティブなレコードだけにインデックス
CREATE INDEX idx_orders_pending
ON orders(created_at)
WHERE status = 'pending';
-- 使用例
SELECT * FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC;
チューニング前後の比較テンプレート
-- Before
EXPLAIN ANALYZE SELECT ...;
-- Seq Scan on orders (actual time=250.000..250.100 rows=100)
-- Execution Time: 250.200 ms
-- 改善を適用
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- After
EXPLAIN ANALYZE SELECT ...;
-- Index Scan using idx_orders_user_status (actual time=0.030..0.050 rows=100)
-- Execution Time: 0.080 ms
-- 改善率: 250.200 / 0.080 = 約3,128倍
まとめ
| 技法 | 適用場面 | 効果 |
|---|---|---|
| SELECT最適化 | 不要カラム取得 | I/O削減 |
| WHERE最適化 | インデックス未使用 | Index Scan化 |
| JOIN最適化 | N+1問題 | クエリ回数削減 |
| サブクエリ最適化 | 相関サブクエリ | 実行回数削減 |
| ページネーション | 深いページ | OFFSET回避 |
| 集計最適化 | 毎回COUNT | マテリアライズドビュー |
| 部分インデックス | 条件付き検索 | インデックスサイズ削減 |
理解度チェックリスト
- インデックスを殺す書き方を3つ以上挙げられる
- N+1問題を検出し解決できる
- カーソルベースページネーションを実装できる
- サブクエリをJOINやEXISTSに書き換えられる
次のステップ
次のレッスンではパーティショニングとシャーディングを学ぶ。テーブルが大きくなりすぎた場合の分割戦略を理解しよう。
推定読了時間: 30分