実務で役立つSQLパフォーマンス改善:遅いクエリを「集合」の視点で整理する
はじめに
SQLのパフォーマンス改善というと、まず「インデックスを貼る」という発想になりがちです。
もちろんインデックスは重要ですが、実務ではクエリの書き方そのものを見直すことで改善するケースも多くあります。
今回、実務の不具合調査、新機能開発、パフォーマンス改善を行う中で、どうやったらSQLの性能を上げられるか、色々試したり、調べて得られたノウハウをまとめてみました。
具体的には、以下のような改善を行いました。
- COUNT集計をEXISTSに変更
- 複雑なサブクエリ→一時テーブルに切り出す
この記事では、そのときの考え方と実行計画、そこから得られた知見を紹介します。
検証環境
- MySQL 8.0.45
- Parent: 100,000件、Child: 169,812件(親子関係テーブル)
- Users: 50,000件、Orders: 92,588件(注文データ)
- インデックス設定済み
実行計画の取得にはEXPLAIN ANALYZEを使用しています。
集計をやめて存在判定にした
元のクエリでは、関連レコードの存在確認のためCOUNTを使って判定していました。
SELECT
p.id AS parent_id,
p.name AS parent_name,
sub.cnt,
CASE
WHEN sub.cnt > 0 THEN '⚪︎'
ELSE '×'
END AS has_child
FROM
Parent p
LEFT JOIN (
-- 【問題点】Parentの絞り込みに関係なく、Childテーブルを全件スキャンして集計してしまう
SELECT
parent_id,
COUNT(*) AS cnt
FROM
Child
GROUP BY
parent_id
) sub ON p.id = sub.parent_id
LIMIT 1000;
COUNT方式の実行計画:
-> Materialize (cost=39890 rows=55108) (actual time=60.3ms)
-> Group aggregate: count(0) (actual time=0.575ms..27.5ms rows=57388)
-> Covering index scan on Child (actual time=0.511ms..18.4ms rows=169812)
問題点:
- Childテーブル全件(169,812件)をスキャン
- 全レコードをGROUP BYで集計
- Parentの絞り込み条件(LIMIT 1000)に関係なく全件処理
- 実行時間: 約88ms
しかし、要件を確認したところ、集計は参考値で、実際に必要だったのは「レコードが存在するかどうか」だけでした。
そこで、クエリをEXISTSに書き換えました。
SELECT
p.id AS parent_id,
p.name AS parent_name,
CASE
-- 条件に合うものが1件でも「存在するか」だけ見る(最速!)
WHEN EXISTS (
SELECT 1
FROM Child c
WHERE c.parent_id = p.id
) THEN '⚪︎'
ELSE '×'
END AS has_child
FROM
Parent p
LIMIT 1000;
EXISTS方式の実行計画:
-> Covering index lookup on c using idx_parent_id (parent_id=p.id)
(actual time=890μs rows=0.5 loops=1000)
改善点:
- 1件見つかった時点で終了(短絡評価)
- インデックスを使った効率的な検索
- 必要な行だけを処理
- 実行時間: 平均890マイクロ秒/Parent(1000件で約1ms)
パフォーマンス比較:
- COUNT方式: 約88ms
- EXISTS方式: 約1ms
- 約88倍高速化!
集合的に考えると、COUNTは対象の全件スキャンが必要な完全走査です。しかし、EXISTSは、集合を探索して一件でも条件に合致するものを見つけたら終了する短絡評価です。
LATERAL JOINという選択肢
途中で一度、LATERAL JOINを使った形にも書き換えました。
LATERAL JOINとは
LATERAL JOINは、MySQL 8.0.14以降でサポートされている機能で、左側のテーブルの各行に対して、右側のサブクエリを実行できる構文です。通常のサブクエリと異なり、外側のテーブルの列を参照できるため、「行ごとの相関サブクエリ」を効率的に実行できます。
SELECT
p.id AS parent_id,
p.name AS parent_name,
agg.cnt,
CASE
WHEN agg.cnt > 0 THEN '⚪︎'
ELSE '×'
END AS has_child
FROM
Parent p
LEFT JOIN LATERAL (
-- Parent1行ごとに必要な分だけカウント(効率的!)
SELECT COUNT(*) AS cnt
FROM Child c
WHERE c.parent_id = p.id
) agg ON TRUE
LIMIT 1000;
LATERAL JOIN方式の実行計画:
-> Materialize (invalidate on row from p) (actual time=1.43μs rows=1 loops=1000)
-> Aggregate: count(0) (actual time=1.15μs rows=1 loops=1000)
-> Covering index lookup on c (actual time=814μs..930μs rows=1 loops=1000)
特徴:
- Parent1行ごとに集計を実行
- 必要な行だけを処理(効率的)
- 件数が必要な場合に有効
- 実行時間: 約1.4ms(1000件)
3つの方式の比較:
| 方式 | 実行時間 | 用途 | 特徴 |
|---|---|---|---|
| COUNT (LEFT JOIN) | 88ms | 全体集計 | 全件走査が必要 |
| EXISTS | 1ms | 存在確認のみ | 短絡評価で最速 |
| LATERAL JOIN | 1.4ms | 行ごとの集計 | 件数も取得できる |
この形でもある程度改善はしましたが、今回は単純な存在確認が目的だったため、EXISTSの方が実行計画も安定して、パフォーマンスも高くなりました。
ただし、「実際の件数」が必要な場合は、LATERAL JOINの方が適しています。
複雑なサブクエリを一時テーブルに切り出す
別のクエリでは、サブクエリの条件式がかなり複雑になっていました。
シナリオ: 「最近6ヶ月に3回以上購入した、かつ合計金額が10,000円以上のアクティブユーザー」を抽出して、そのユーザーの詳細情報と注文統計を取得する。
パターン1: IN句 + 複雑なサブクエリ(遅い)
SELECT
u.id,
u.name,
COUNT(o.id) AS total_orders,
SUM(o.amount) AS total_amount
FROM
users u
INNER JOIN orders o ON u.id = o.user_id
WHERE
u.id IN (
-- 【問題点】このサブクエリが複雑で、実行コストが高い
SELECT
o2.user_id
FROM
orders o2
WHERE
o2.order_date >= DATE_SUB(CURRENT_DATE, INTERVAL 180 DAY)
AND o2.status = 'completed'
GROUP BY
o2.user_id
HAVING
COUNT(*) >= 3
AND SUM(o2.amount) >= 10000
)
AND u.status = 'active'
AND o.status = 'completed'
GROUP BY
u.id, u.name
ORDER BY
total_amount DESC
LIMIT 100;
実行計画:
-> Aggregate using temporary table (actual time=182ms rows=6037)
-> Nested loop inner join (actual time=63.5ms..152ms rows=43615)
-> Filter: <in_optimizer>(o.user_id, o.user_id in (select #2))
(actual time=63.5ms..124ms rows=50762)
-> Materialize with deduplication (actual time=62.2ms rows=7128)
実行時間: 185ms
問題点:
- サブクエリが物質化(Materialize)される(62.2ms)
- IN句の評価にコストがかかる
パターン2: サブクエリをJOINに変更(中間的)
SELECT
u.id,
u.name,
COUNT(o.id) AS total_orders,
SUM(o.amount) AS total_amount
FROM
users u
INNER JOIN (
-- サブクエリをJOINに変更
SELECT
o2.user_id
FROM
orders o2
WHERE
o2.order_date >= DATE_SUB(CURRENT_DATE, INTERVAL 180 DAY)
AND o2.status = 'completed'
GROUP BY
o2.user_id
HAVING
COUNT(*) >= 3
AND SUM(o2.amount) >= 10000
) qualified_users ON u.id = qualified_users.user_id
INNER JOIN orders o ON u.id = o.user_id
WHERE
u.status = 'active'
AND o.status = 'completed'
GROUP BY
u.id, u.name
ORDER BY
total_amount DESC
LIMIT 100;
実行時間: 132ms(約40%改善)
IN句の評価コストが削減され、JOINで直接結合するため効率的になりました。
パターン3: 一時テーブルに切り出す(最速)
-- 1. 条件に合うユーザーIDを一時テーブルに格納
CREATE TEMPORARY TABLE qualified_user_ids (
user_id BIGINT PRIMARY KEY
) ENGINE=MEMORY;
INSERT INTO qualified_user_ids (user_id)
SELECT
o.user_id
FROM
orders o
INNER JOIN users u ON o.user_id = u.id
WHERE
o.order_date >= DATE_SUB(CURRENT_DATE, INTERVAL 180 DAY)
AND o.status = 'completed'
AND u.status = 'active'
GROUP BY
o.user_id
HAVING
COUNT(*) >= 3
AND SUM(o.amount) >= 10000;
-- 2. メインクエリはシンプルなJOINに
SELECT
u.id,
u.name,
COUNT(o.id) AS total_orders,
SUM(o.amount) AS total_amount
FROM
users u
INNER JOIN qualified_user_ids q ON u.id = q.user_id
INNER JOIN orders o ON u.id = o.user_id
WHERE
o.status = 'completed'
GROUP BY
u.id, u.name
ORDER BY
total_amount DESC
LIMIT 100;
DROP TEMPORARY TABLE qualified_user_ids;
実行計画:
-> Aggregate using temporary table (actual time=72.6ms rows=6037)
-> Nested loop inner join (actual time=0.0211ms..41.9ms rows=43615)
-> Nested loop inner join (actual time=0.00858ms..5.18ms rows=6037)
-> Table scan on q (actual time=625μs..0.297ms rows=6037)
実行時間: 74.2ms(60%改善)
改善点:
- 複雑な条件の評価が1回だけ
- メインクエリは単純なJOIN
- 一時テーブルのスキャンが非常に高速(0.297ms)
パフォーマンス比較:
| パターン | 実行時間 | 改善率 | 特徴 |
|---|---|---|---|
| IN句 + サブクエリ | 185ms | - | サブクエリの物質化コストが大きい |
| サブクエリ→JOIN | 132ms | 40%改善 | IN句の評価コストを削減 |
| 一時テーブル | 74.2ms | 60%改善 | 条件評価1回、シンプルJOIN |
一時テーブルのメリット:
-
複雑な条件の評価は1回だけ
- サブクエリ: 実行中に評価されるコストが高い
- 一時テーブル: 事前に1回だけ計算
-
デバッグしやすい
-- 一時テーブルの内容を確認できる SELECT COUNT(*) FROM qualified_user_ids; SELECT * FROM qualified_user_ids LIMIT 10; -
メインクエリがシンプル
- 実行計画が読みやすい
- パフォーマンスの予測がしやすい
-
ENGINE=MEMORY で高速化
- メモリ上に展開されるため、ディスクI/Oなし
- 一時テーブルのスキャンが非常に高速(0.297ms)
EXPLAIN ANALYZEで実行計画を確認する
実行計画の確認には、EXPLAIN ANALYZEを使用します。
EXPLAIN ANALYZE
SELECT ...;
EXPLAINとの違い:
| コマンド | 説明 |
|---|---|
EXPLAIN |
推定の実行計画(予測コスト) |
EXPLAIN ANALYZE |
実際の実行計画(実測時間) |
EXPLAIN ANALYZEは実際にクエリを実行して、各ステップの実測時間を表示します。そのため、パフォーマンス測定に非常に有用です。
実行計画の読み方:
-> Covering index lookup on c using idx_parent_id (parent_id=p.id)
(cost=0.561 rows=3.1)
(actual time=890μs rows=0.5 loops=1000)
- cost: 推定コスト
- rows: 推定行数
- actual time: 実際の実行時間
- rows: 実際の行数
- loops: ループ回数
重要なのはactual timeと実際のrowsです。 これらの値がパフォーマンスの実測値になります。
SQLチューニングを集合の視点で考える
今回の改善を整理すると次のようになります。
| 元の書き方 | 改善後 | 効果 |
|---|---|---|
| COUNT集計 | EXISTS | 88倍高速化 |
| COUNT集計(件数必要) | LATERAL JOIN | 効率的な行ごと集計 |
| 複雑サブクエリ | 一時テーブル+JOIN | 60%高速化 |
どれも「条件として書かれていた処理を集合として扱いやすい形に整理する」という方向の変更です。
データベースは以下の集合処理を非常に得意としています:
- JOIN(結合)
- UNION(和集合)
- INTERSECT(積集合)
- EXCEPT(差集合)
クエリを集合として扱いやすい形に整理することで、オプティマイザが扱いやすいクエリになります。
まとめ
SQLチューニングでは、インデックスだけでなく、以下の視点が重要です:
要件の確認が最優先
本当に集計が必要か?存在確認だけで十分か?
COUNT → EXISTS で 88倍高速化できた事例のように、要件を見直すだけで劇的に改善することがあります。
適切な手法の使い分け
- 存在確認: EXISTS(短絡評価で最速)
- 件数も必要: LATERAL JOIN(行ごとの効率的な集計)
- 複雑条件: 一時テーブルで処理を分離(60%高速化)
実行計画で実測値を確認
-
EXPLAIN ANALYZEで実測時間を確認する - 推定コストではなく、実測値(actual time)を見る
- 処理行数(rows)とループ回数(loops)に注目
集合として整理する
- 条件や処理を集合演算(JOIN/UNION/INTERSECT)に落とし込む
- データベースが得意な演算処理を活用する
- 複雑な条件は一時テーブルに分離して整理
クエリや条件を集合として整理し、データベースが得意な演算処理に落としてあげることが、パフォーマンス改善の鍵になると感じました。
そして何より、「本当にその処理が必要か?」という要件の確認が、最も大きな改善につながることを実感しました。
Discussion