#06 データベースの高速化

クエリチューニングのテクニック

インデックスとN+1問題を押さえたら、次はクエリそのものを最適化するテクニックを身につけましょう。「動くクエリ」から「速いクエリ」への書き換えパターンを体系的に紹介します。

クエリチューニングテクニックの分類 取得データの削減 • SELECT * を避ける • 必要な列だけ指定 • LIMIT で件数制限 • カバリングインデックス → データ転送量を減らす → メモリ消費を減らす クエリ構造の改善 • 相関サブクエリ → JOIN • DISTINCT を排除 • EXISTS で存在確認 • CTE で可読性向上 → アクセス回数を減らす → 一時テーブルを回避 ページネーション • OFFSET → Keyset 方式 • WHERE id > last_id • ORDER BY id DESC LIMIT N → スキップ行数ゼロ → ページ数に依存しない その他 • インデックスヒント • ANALYZE TABLE • COUNT(*) vs EXISTS • バッチ処理の分割 → 統計情報を最新に → 適切な処理単位 OFFSET ページネーション(遅くなる) LIMIT 20 OFFSET 1000000 → 100万行を読んで捨てる → ページが後半になるほど遅くなる 遅↑ ページ数→ Keyset ページネーション(一定速度) WHERE id < 98765 ORDER BY id DESC LIMIT 20 → インデックスで直接ジャンプ → ページ数に依存せず一定速度 一定 ページ→
図1: クエリチューニングテクニックの分類

テクニック1:SELECT * を避ける

SELECT * はすべての列を取得するため、不要なデータの転送とメモリ消費が発生します。特に TEXT 型や BLOB 型の大きなカラムが含まれる場合は顕著です。また、 カバリングインデックス? クエリで必要な全カラムがインデックスに含まれていて、テーブル本体を参照せずに結果を返せる状態。EXPLAINのExtraに`Using index`と表示される。非常に高速。 の恩恵を受けられません。

-- Before
SELECT * FROM users WHERE id = 100;

-- After: 必要な列だけ指定
SELECT id, name, email FROM users WHERE id = 100;

必要な列だけを指定することで、ネットワーク転送量が減り、カバリングインデックスが適用されやすくなります。

テクニック2:サブクエリ vs JOIN の選択

サブクエリ? SQL文の中に入れ子になった別のSELECT文。WHERE句・FROM句・SELECT句などで使用できる。「平均より多い注文をしたユーザー」のような複雑な条件に使う。 は読みやすいですが、相関サブクエリ(外側のクエリの各行に対してサブクエリが実行される)はN+1と同様の問題を引き起こします。

-- Before: 相関サブクエリ(各ユーザーに対してサブクエリが実行)
SELECT
  u.id,
  u.name,
  (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count
FROM users u;

-- After: JOIN + GROUP BY に書き直す
SELECT
  u.id,
  u.name,
  COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

GROUP BY? 指定した列の値が同じ行をまとめてグループ化する句。COUNT・SUM・AVGなどの集計関数と組み合わせて使う。「カテゴリ別の件数を集計」などに使用する。 を使ったJOINは、相関サブクエリと比べてテーブルへのアクセス回数が大幅に減ります。

CTE を活用する

MySQL 8.0以降では CTE(共通テーブル式)? WITH句で定義する一時的な名前付きクエリ。`WITH monthly_sales AS (SELECT ...)` のように使い、複雑なサブクエリを読みやすく整理できる。再帰CTEでツリー構造も扱える。 (WITH句)を使うと複雑なクエリを読みやすくできます。

-- Before: ネストしたサブクエリで可読性が低い
SELECT u.name, stats.total
FROM users u
INNER JOIN (
  SELECT user_id, SUM(total_amount) AS total
  FROM orders
  WHERE status = 'completed'
  GROUP BY user_id
) AS stats ON u.id = stats.user_id
WHERE stats.total > 10000;

-- After: CTE で分割して読みやすく
WITH completed_stats AS (
  SELECT user_id, SUM(total_amount) AS total
  FROM orders
  WHERE status = 'completed'
  GROUP BY user_id
)
SELECT u.name, cs.total
FROM users u
INNER JOIN completed_stats cs ON u.id = cs.user_id
WHERE cs.total > 10000;

CTEはパフォーマンス的にも同等かそれ以上になることが多く、可読性向上の観点からも積極的に使いましょう。

テクニック3:COUNT の最適化

カウントのクエリは書き方によって速度が大きく変わります。

-- 件数の存在確認には EXISTS が最速
-- Before: COUNT で全件スキャン
SELECT COUNT(*) FROM orders WHERE user_id = 100;

-- After: EXISTS は1件見つかった時点で終了
SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = 100) AS has_orders;

なお COUNT(*) は全行をカウントし、COUNT(column) は NULL 以外の行をカウントします。単純な件数なら COUNT(*) が正しいです。

テクニック4:LIMIT の活用

必要な件数だけ取得するよう LIMIT を積極的に使いましょう。最初に絞り込めるほど後続の処理が軽くなります。

-- 最新1件だけ必要な場合
SELECT * FROM logs WHERE user_id = 100 ORDER BY created_at DESC LIMIT 1;

-- サブクエリ内でも LIMIT を活用
SELECT * FROM users
WHERE id IN (
  SELECT user_id FROM orders ORDER BY total_amount DESC LIMIT 100
);

テクニック5:不要な DISTINCT を避ける

DISTINCT は重複排除のためにソートや一時テーブルを使います。本当に必要でない限り、JOIN の条件を修正して重複が出ない設計に直しましょう。

-- Before: JOIN で重複が出るので DISTINCT で除去
SELECT DISTINCT u.id, u.name
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.status = 'pending';

-- After: EXISTS で書き直すと DISTINCT 不要
SELECT u.id, u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id AND o.status = 'pending'
);

テクニック6:ページネーションの高速化

OFFSET を使った一般的なページネーションは、ページ数が増えるほど遅くなります。

-- Before: OFFSET ページネーション(100万件目付近は非常に遅い)
SELECT * FROM articles ORDER BY created_at DESC LIMIT 20 OFFSET 1000000;

OFFSET 1000000 は100万行スキップするため、実際には100万行を読んで捨てています。

Keyset Pagination(カーソルページネーション)で解決します。

-- After: 最後に取得したIDを使って次ページを取得
-- 初回
SELECT * FROM articles ORDER BY id DESC LIMIT 20;

-- 次ページ(前ページ最後のid = 98765 とする)
SELECT * FROM articles WHERE id < 98765 ORDER BY id DESC LIMIT 20;

id にインデックスがあるため、どのページでも同じ速度で取得できます。

Before / After クエリ書き換え例 例1: 相関サブクエリ → JOIN Before(遅い) SELECT u.id, u.name, (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS cnt FROM users u; After(速い) SELECT u.id, u.name, COUNT(o.id) AS cnt FROM users u LEFT JOIN orders o ON u.id = o.user_id + GROUP BY u.id, u.name 例2: DISTINCT → EXISTS Before(DISTINCT で重複排除) SELECT DISTINCT u.id, u.name FROM users u INNER JOIN orders o ON u.id = o.user_id WHERE o.status = 'pending'; After(EXISTS で重複なし) SELECT u.id, u.name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o 例3: 存在確認の最適化 Before(全件カウント) SELECT COUNT(*) FROM orders WHERE user_id = 100; 存在確認だけなのに全件数える After(1件見つかった時点で終了) SELECT EXISTS( SELECT 1 FROM orders WHERE user_id = 100 ) AS has_orders; ← 早期終了
図2: Before/Afterのクエリ書き換え例

テクニック7:インデックスヒント

MySQL のオプティマイザが誤ったインデックスを選択することがあります。その場合は USE INDEXFORCE INDEX で明示指定できます。

-- 特定のインデックスを使うよう指示
SELECT * FROM orders USE INDEX (idx_created_at)
WHERE created_at >= '2026-01-01';

-- インデックスを使わないよう指示(稀なケース)
SELECT * FROM small_table IGNORE INDEX (idx_status)
WHERE status = 'active';

ただしインデックスヒントはオプティマイザの判断を上書きするため、スキーマやデータ分布が変わった際にかえって遅くなるリスクがあります。まずは統計情報の更新(ANALYZE TABLE)を試みてください。

クエリチューニングは1つのテクニックで劇的に改善することもあれば、複数の改善を組み合わせて効果が出ることもあります。常に EXPLAIN で効果を確認しながら進めましょう。次のエピソードはDBの外側——キャッシュ戦略を取り上げます。