はじめに
MySQLでクエリを書く際、「とりあえずレスポンスを早くするために LIMIT をつけておこう」と考えたことはありませんか?
実は、LIMITを付けることで逆にクエリが数倍〜数十倍遅くなるという現象が存在します。
今回は、インデックスが適切に張られているにもかかわらず、オプティマイザの「親切心」が仇となってしまった事例とそのメカニズム、対策を解説します。
検証環境
今回使用するテーブルとインデックスの構成は以下の通りです。約100万件のログデータが入っている状態を想定しています。
-- 顧客ログテーブル
CREATE TABLE customer_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
log_message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 今回のキーとなる2つのインデックス
CREATE INDEX idx_customer_id ON customer_logs (customer_id);
CREATE INDEX idx_created_at_customer_id ON customer_logs (created_at, customer_id);
-- 顧客マスタ
CREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
注目すべきは idx_created_at_customer_id です。これは、「特定の期間 (created_at) のデータを絞り込み、かつその中の顧客ID (customer_id) を高速に参照する」 ために用意した複合インデックスです。
遭遇した現象
以下のような、顧客ログ (customer_logs) と顧客マスタ (customers) を結合し、期間絞り込み・集計を行うクエリを実行しました。
SELECT customer_logs.customer_id, customers.name, COUNT(*)
FROM customer_logs
LEFT JOIN customers ON customer_logs.customer_id = customers.id
WHERE customer_logs.created_at >= '2023-09-01 00:00:00'
AND customer_logs.created_at < '2023-10-01 00:00:00'
GROUP BY customer_id
ORDER BY customer_id
LIMIT 500; -- これを付けると遅くなる!
| 条件 | 実行速度 |
|---|---|
| LIMIT なし | 0.05 sec |
| LIMIT 500 あり | 0.25 sec |
データを制限するはずの LIMIT 付きの方が、なぜ5倍も遅くなってしまったのでしょうか。
実行計画の比較
EXPLAIN ANALYZE を確認すると、 MySQL が選択した戦略に決定的な違いがありました。
1. LIMIT なしの場合 (期待通りの動作)
オプティマイザは、期間絞り込みに最適な idx_created_at_customer_id を選択しています。
- Scan手法: Covering index range scan
- 動作: インデックスを使い、2023年9月のデータ(8,640件)だけをピンポイントで読み取ります。
- 戦略: 「まず期間で絞り込み、その後で一時テーブルを使って集計・ソートする」という正攻法です。
2. LIMIT ありの場合 (オプティマイザの罠)
ここでオプティマイザの判断が狂います。
-
Scan手法: Table scan(全件走査)
-
動作: インデックスを無視し、100万件のデータを最初から読み始めました。
-
戦略: 「LIMIT 500 があり、
ORDER BY customer_idと指定されている。それなら、customer_id順に並んでいるデータ(あるいは全件)を端から見ていけば、500件なんてすぐに見つかって途中で止められる (ストップキー) はずだ!」と判断したのです。
しかし、実際には「9月分」という条件を満たすデータを探すために、結局かなりの範囲をスキャンすることになり、インデックスを使った絞り込みよりも高くついてしまいました。
なぜ「ストップキー」が誤作動したのか
MySQL のオプティマイザは、LIMIT が指定されると 「ソート済みの結果を最初から数件拾うコスト」を非常に低く見積もる 傾向があります。
-
通常: 8,640件を抽出してソートするコストを計算。
-
LIMITあり: 「どうせ500件でしょ? インデックス絞り込みをせず、データの並び順に沿って探せばソートの手間も省けるし、一瞬で終わるはず!」という「期待値」に賭けてしまいます。
今回のデータ分布では、その「賭け」が外れ、インデックスの利点を捨ててフルスキャンに近い動きをしたことが速度低下の原因です。
対策とその落とし穴
失敗した対策: FORCE INDEX
「オプティマイザがインデックスを使わないなら、強制(FORCE INDEX)させればいい」……そう考えるのが普通です。そこで以下のように FROM 句に FORCE INDEX を指定しました。
FROM customer_logs FORCE INDEX (idx_created_at_customer_id)
しかし、今回のケースでこれを実行すると、2分25秒(145秒) という致命的な遅延が発生しました。
なぜさらに遅くなったのか?
EXPLAIN ANALYZE を確認すると、結合アルゴリズムが Hash Join から Nested Loop Join に変わってしまっています。
-
Hash Join (通常): 顧客マスタを一度ハッシュ化して、高速に突き合わせる。
-
Nested Loop Join (今回): ログ 8,640行に対し、1回ごとに顧客マスタ 100,000行をフルスキャンして結合。
計算量:8,640 × 100,000 = 8.6億回 のループが発生!
オプティマイザは FORCE INDEX によって「行の読み込み順序」を固定された結果、全体の整合性を取るために結合戦略まで非効率なものを選んでしまったのです。
有効だった対策: サブクエリによる「関心の分離」
一番の解決策は、「データの絞り込み」と「テーブルの結合」を分離することでした。
SELECT t.customer_id, customers.name, t.cnt
FROM (
-- 1. まずログテーブルだけで絞り込み・集計・LIMITを完結させる
SELECT customer_id, COUNT(*) as cnt
FROM customer_logs
WHERE created_at >= '2023-09-01 00:00:00' AND created_at < '2023-10-01 00:00:00'
GROUP BY customer_id
ORDER BY customer_id
LIMIT 500
) t
-- 2. 確定した500件に対してだけ、マスタを結合する
LEFT JOIN customers ON t.customer_id = customers.id;
この方法であれば:
- 内側のクエリで idx_created_at_customer_id が適切に使われる。
- LIMIT によるコスト計算の狂いが外側の結合に影響しない。
- 結合対象がたった500件になるため、結合アルゴリズムが何であれ爆速になる。
まとめ
-
LIMITはオプティマイザを惑わす。 「早く終わるかも」という期待値で最適なインデックスが捨てられることがある。 -
FORCE INDEXは万能ではない。 スキャン順を強制すると、結合アルゴリズム(Hash Joinなど)が選ばれなくなり、今回のように100倍以上遅くなるリスクがある。 - 迷ったらサブクエリ。 複雑な結合を伴う場合は、先にインデックスが効く範囲でデータを絞り切ってから結合するのが、最も安全で高速な道である。
「急がば回れ」は、SQLの世界でも真理です。オプティマイザの「ショートカット提案」に乗る前に、まずは一歩引いて、サブクエリで外堀から埋めていくのが結局一番の近道かもしれません。