LESSON 30分

ストーリー

「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分