SQLを実務で扱う際、多くのエンジニアが直面するのが「同じ結果を得られる複数の書き方のうち、どれを選択すべきか」という課題です。
特にデータ量が増大し、リアルタイム性が求められる現代のシステム開発において、クエリの書き方一つでシステムのレスポンスが劇的に変わることは珍しくありません。
本記事では、日常的な業務で頻出するSQLクエリの使い分けとその背景にあるパフォーマンスへの影響について、具体例を交えて詳しく解説します。
構文の表面的な違いだけでなく、データベース内部でどのような処理が行われているかという視点を持つことで、より堅牢で高速なアプリケーション開発が可能になります。
1. データの抽出と比較:INとEXISTSの決定的な違い
実務で最も頻繁に議論されるテーマの一つが、IN 句と EXISTS 句の使い分けです。
どちらも「特定の集合に値が含まれているか」を確認するために使用されますが、その動作原理とパフォーマンス特性には大きな違いがあります。
IN句の動作と特徴
IN 句は、右辺に指定されたリストまたはサブクエリの結果セットの中に、対象の列の値が含まれているかを判定します。
-- IN句を使用した例
-- 注文履歴があるユーザーを取得する
SELECT
user_id,
user_name
FROM
users
WHERE
user_id IN (SELECT user_id FROM orders);
IN 句を使用する場合、データベースはまずサブクエリを実行し、その結果をメモリ上にリスト化します。
その後、メインクエリの各行に対して、そのリストの中に値が存在するかをスキャンします。
このため、サブクエリの結果セットが膨大になる場合、メモリ消費量が増加しパフォーマンスが低下する傾向にあります。
EXISTS句の動作と特徴
対照的に EXISTS 句は、「条件に合致する行が1行でも存在するかどうか」のみを確認します。
-- EXISTS句を使用した例
SELECT
u.user_id,
u.user_name
FROM
users u
WHERE
EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.user_id
);
EXISTS 句の最大のメリットは、「条件に一致する行が見つかった瞬間に、その行の探索を終了する(ショートカット評価)」点にあります。
サブクエリ内で大量のデータが合致する場合でも、最初の1件が見つかれば判定が終了するため、特にインデックスが適切に貼られている環境では非常に高速に動作します。
NULL値に関する挙動の違い
パフォーマンス以上に注意が必要なのが、NULL 値が含まれる場合の挙動です。
NOT IN を使用した際、サブクエリの結果に1つでも NULL が含まれていると、全体の判定結果が常に「不明(UNKNOWN)」となり、1行も取得されないという現象が発生します。
実務においては、意図しない不具合を避けるため、またパフォーマンスの安定性を確保するために、相関サブクエリを伴う EXISTS 句を優先的に使用するのが定石です。
| 比較項目 | IN 句 | EXISTS 句 |
|---|---|---|
| 評価方法 | 結果リストを構築して比較 | 行の存在有無のみを評価 |
| 得意なケース | 固定のリスト(定数)との比較 | 大規模なサブクエリとの比較 |
| NULL への耐性 | 不定な結果を返すリスクあり | 安全に評価可能 |
2. データの結合:JOINとサブクエリの使い分け
リレーショナルデータベースの醍醐味である「テーブル結合」においても、JOIN を使うべきか、それとも SELECT 句や FROM 句の中でサブクエリを使うべきか迷う場面があります。
JOINによる結合のメリット
標準的な JOIN (特に行数を絞り込んでからの内部結合)は、多くのデータベースエンジンのオプティマイザにとって最も解析しやすい形式です。
-- JOINを使用した結合
SELECT
p.product_name,
c.category_name
FROM
products p
INNER JOIN
categories c ON p.category_id = c.category_id;
JOIN を使用すると、オプティマイザは統計情報を基に「どちらのテーブルを先に読み込むか(駆動テーブルの決定)」や「どのインデックスを使うか」を最適に判断します。
可読性も高く、実務におけるデータ結合の基本とすべき手法です。
スカラ・サブクエリの使用場面
一方で、SELECT 句の中に記述する「スカラ・サブクエリ」は、特定の1行だけを付加情報として取得したい場合に便利です。
-- スカラ・サブクエリの例
SELECT
p.product_name,
(SELECT MAX(order_date) FROM orders o WHERE o.product_id = p.product_id) AS last_order_date
FROM
products p;
ただし、スカラ・サブクエリはメインクエリの 1行ごとに実行されるリスク があります。
メインクエリの件数が1万件あれば、サブクエリも1万回発行されることになり、これを「N+1問題」と呼びます。
現代のオプティマイザはこれを自動で結合処理に変換(クエリ変換)してくれることもありますが、基本的には大量データを扱う際にスカラ・サブクエリを多用するのは避けるべきです。
使い分けの基準
- 複数列を取得する場合: 迷わず
JOINを使用します。 - 1つの値だけを付加し、かつインデックスが効いている場合: 可読性重視でスカラ・サブクエリも検討の余地がありますが、基本は
JOINです。 - 計算済みの集計値を結合したい場合: 後述する共通テーブル式(CTE)を活用します。
3. 重複排除:DISTINCTとGROUP BYの使い分け
結果から重複を除外したい場合、DISTINCT と GROUP BY のどちらを使うべきかという議論もよく行われます。
DISTINCTの役割
DISTINCT は、最終的な結果セットから完全に一致する行を1つにまとめる命令です。
-- 重複する部署IDを除いて取得
SELECT DISTINCT department_id FROM employees;
内部的には、全データを取得した後に「ソート」または「ハッシュ化」を行い、重複を排除します。
用途が「重複排除」に特化しているため、コードの意図が明確になります。
GROUP BYの役割
GROUP BY は、本来「集計関数(SUM, AVG, COUNTなど)」と共に使用するために設計されています。
-- 部署ごとにグループ化して取得
SELECT department_id FROM employees GROUP BY department_id;
実は、多くのデータベースエンジンにおいて DISTINCT と「集計関数を伴わない GROUP BY」の実行計画は同じになります。
しかし、セマンティクス(意味論)的には明確に異なります。
「単に重複を消したいだけなら DISTINCT」、「グループごとの統計値を計算したいなら GROUP BY」と使い分けるのが、保守性の高いコードを書くコツです。
4. 集合演算:UNIONとUNION ALLのパフォーマンス差
複数のクエリ結果を統合する際に使用する UNION と UNION ALL ですが、この使い分けには明確なパフォーマンス上の理由があります。
UNION (重複排除あり)
UNION は、2つの結果セットを結合した後、重複する行を削除します。
-- 2024年の顧客と2025年の顧客を統合(重複なし)
SELECT customer_id FROM orders_2024
UNION
SELECT customer_id FROM orders_2025;
重複を削除するために、データベースは内部で「ソート処理」や「重複チェック」を行います。
このオーバーヘッドはデータ量に比例して大きくなります。
UNION ALL (重複排除なし)
一方の UNION ALL は、結果をそのまま単純に結合して返します。
-- 2024年の顧客と2025年の顧客をそのまま統合
SELECT customer_id FROM orders_2024
UNION ALL
SELECT customer_id FROM orders_2025;
重複チェックを行わないため、UNION に比べて圧倒的に高速です。
実務において「重複が発生しないことが論理的に保証されている場合」や「重複していても問題ない場合」は、必ず UNION ALL を選択してください。
5. 条件フィルタリング:WHEREとHAVINGの実行タイミング
フィルタリングを行う2つの句ですが、その適用タイミングを理解することはパフォーマンスチューニングの第一歩です。
WHERE句による事前フィルタリング
WHERE 句は、データをグループ化したり集計したりする「前」に適用されます。
SELECT
category_id,
COUNT(*)
FROM
products
WHERE
discontinued = 0 -- 集計前に絞り込み
GROUP BY
category_id;
集計対象となる行数をあらかじめ減らすことができるため、メモリ消費を抑え、処理速度を向上させることができます。
HAVING句による事後フィルタリング
HAVING 句は、グループ化された「後」の結果に対して適用されます。
SELECT
category_id,
COUNT(*)
FROM
products
GROUP BY
category_id
HAVING
COUNT(*) > 10; -- 集計後に絞り込み
「集計結果(COUNTやSUMなど)を条件に使いたい場合」は HAVING を使うしかありませんが、通常の列に対するフィルタリングであれば WHERE に記述すべきです。
HAVING 句で本来 WHERE に書ける条件を指定してしまうと、不要なデータまでグループ化処理の対象となり、著しくパフォーマンスを損なう原因となります。
6. クエリの可読性と再利用性:CTE (WITH句) vs サブクエリ
複雑なクエリを書く際、サブクエリを入れ子にするよりも、共通テーブル式 (Common Table Expressions, CTE) を使う場面が増えています。
サブクエリによる記述
サブクエリが深くなると、いわゆる「SQLの秘伝のタレ」化が進み、解読が困難になります。
-- 可読性が低い例
SELECT
tmp.avg_price,
p.product_name
FROM (
SELECT category_id, AVG(price) as avg_price
FROM products
GROUP BY category_id
) tmp
JOIN products p ON tmp.category_id = p.category_id;
CTE (WITH句) による記述
WITH 句を使用すると、クエリを論理的なステップに分割して記述できます。
-- CTEを使用した可読性の高い例
WITH CategoryAvg AS (
SELECT
category_id,
AVG(price) AS avg_price
FROM
products
GROUP BY
category_id
)
SELECT
ca.avg_price,
p.product_name
FROM
CategoryAvg ca
JOIN
products p ON ca.category_id = p.category_id;
CTEは単なる構文糖衣(シンタックスシュガー)ではなく、一部のデータベースエンジン(PostgreSQLの旧バージョンなど)では「マテリアライズ(一時テーブル化)」されるかどうかの制御に関わることもありました。
現代の多くのエンジン(MySQL 8.0+, PostgreSQL 12+, SQL Server)では、サブクエリと同様に最適化されますが、可読性の向上はデバッグ効率やコードレビューの精度に直結するため、実務ではCTEの使用が強く推奨されます。
7. ウィンドウ関数とGROUP BYの使い分け
最新のSQL標準で非常に強力なのがウィンドウ関数(OVER 句)です。
集計を行いつつ、元の行データも保持したい場合に威力を発揮します。
GROUP BYでの限界
GROUP BY を使用すると、集計単位ごとに1行にまとめられてしまうため、詳細な行データと集計値を並べて表示するには、再度元のテーブルと結合する必要があります。
ウィンドウ関数による解決
ウィンドウ関数を使えば、結合なしで「各行の横にグループ統計値」を並べることができます。
-- 商品価格と、そのカテゴリ内の平均価格を並べて表示
SELECT
product_name,
price,
AVG(price) OVER(PARTITION BY category_id) AS category_avg_price
FROM
products;
このクエリは、自己結合を行うよりもスキャン回数が少なく済むことが多く、パフォーマンスと記述の簡潔さを両立できます。
特に「累計(SUM OVER)」や「ランキング(RANK, DENSE_RANK)」などの要件では、ウィンドウ関数の使用が必須と言えるでしょう。
8. パフォーマンスに直結する「インデックス」を意識したクエリ作成
クエリの書き方そのものの違いも重要ですが、それ以上に重要なのが「インデックスが効く書き方をしているか」という点です。
インデックスが無効化されるケース
たとえ優秀な結合アルゴリズムを選択していても、以下の書き方をするとインデックスが使用されず、フルテーブルスキャンが発生します。
- 列に対して演算を行っている:
WHERE price \* 1.1 > 1000 - 列に対して関数を適用している:
WHERE SUBSTR(product\_code, 1, 3) = 'ABC' - 暗黙の型変換が発生している: 数値型の列に対して文字列で検索する
WHERE id = '100' - 後方一致または中間一致のLIKE検索:
WHERE name LIKE '%keyword%'
これらは、「インデックスは、加工されていない生の値に対して作成されている」ためです。
改善案
-- 改善前
SELECT * FROM orders WHERE DATE_FORMAT(order_date, '%Y-%m-%d') = '2024-01-01';
-- 改善後 (インデックスが効く)
SELECT * FROM orders WHERE order_date >= '2024-01-01 00:00:00' AND order_date < '2024-01-02 00:00:00';
このように、「検索条件の左辺には加工していない列名を置く」というルールを徹底するだけで、パフォーマンスの問題の多くを未然に防ぐことができます。
9. 実行計画(EXPLAIN)の読み解き方
「どのクエリが速いか」を最終的に判断するのは、開発者の勘ではなく、データベースが生成する「実行計画」です。
EXPLAINコマンドの活用
ほとんどのRDBMSには、クエリの前に EXPLAIN を付けることで、どのようにデータにアクセスするかを表示する機能があります。
EXPLAIN SELECT * FROM users WHERE user_id = 1;
出力結果で特に注目すべきポイントは以下の通りです。
- type (MySQL) / Method (PostgreSQL):
constやeq\_refなら高速。ALLはフルスキャンを意味するため改善が必要。 - rows: その処理で読み込むと予想される行数。少なければ少ないほど良い。
- Extra:
Using filesortやUsing temporaryが出ている場合は、メモリやディスクへの負荷が高いため、インデックスによる改善の余地があります。
実務においては、「少しでも重いな」と感じたら即座に EXPLAIN を叩く習慣をつけることが、SQLスキルの向上に直結します。
まとめ
SQLには同じ結果を得るためのルートがいくつも用意されていますが、その選択は決して自由なものではありません。
- EXISTS 句を活用して、不必要なスキャンと NULL トラップを回避する。
- UNION ALL を優先し、不必要なソートコストを削る。
- WHERE 句で事前に絞り込み、集計エンジンの負荷を下げる。
- ウィンドウ関数やCTEを用いて、可読性と効率を両立させる。
- インデックスを意識した左辺を徹底し、実行計画で答え合わせをする。
これらの使い分けを意識することで、あなたの書くSQLは単なる「データ抽出命令」から、システムのパフォーマンスを支える「洗練されたアルゴリズム」へと進化します。
データ量が増え続ける現代のシステムにおいて、こうした細かな違いの積み重ねが、最終的なユーザー体験の差を生み出すのです。
