チューニング

OR を速くする — IN / UNION ALL / EXISTS への書き換え

遅い OR 条件を IN・UNION ALL・EXISTS に書き換えるパターンと NOT IN の NULL 罠。

なぜ OR は遅くなりやすいのか

OR はパッと見シンプルですが、オプティマイザ目線ではとても扱いづらい条件です。理由:

  • 各 OR 枝ごとに別のインデックスを辿る必要がある。同じインデックスで両方を範囲スキャンできることはまれ
  • 異なる列の OR は、Bitmap OR(両方のインデックス結果を合算)が使える RDBMS もあるが、使えない場合は結局 Seq Scan
  • 複合インデックス (a, b) があっても、WHERE a = ? OR b = ?左端一致が崩れるので活用不能
  • サブクエリと組み合わさると、オプティマイザが最適化を諦めやすい

結果として「インデックスはあるのに Seq Scan」という実行計画になりがち。OR を見たら書き換えを検討、が実務の定石です。

パターン 1: 同じ列の OR → IN(...)

同じ列に対する複数の等値 OR は、IN (...) に書き換えるのが基本です。多くのオプティマイザは内部的に同等扱いしますが、可読性動的生成のしやすさで明確に勝ります。

sql
-- before
SELECT * FROM orders
WHERE status = 'PAID' OR status = 'SHIPPED' OR status = 'DELIVERED';

-- after
SELECT * FROM orders
WHERE status IN ('PAID', 'SHIPPED', 'DELIVERED');

-- 範囲 + 等値の混在も IN にまとめられる
-- before
WHERE id = 1 OR id = 5 OR id = 12;
-- after
WHERE id IN (1, 5, 12);

パターン 2: 異なる列の OR → UNION ALL 分解

列が違う OR はインデックスが活きにくい最たるパターン。それぞれに個別のインデックスがあるなら、UNION ALL で分解すると各枝がインデックススキャンに落ちます。

注意点:

  • UNION(重複除去あり)はソート + DISTINCTが入るので重い。重複を除去する必要がなければ UNION ALL を使う
  • 両枝に同じ行がヒットする可能性があるなら、2 枝目で重複を除く条件を追加する(下記の AND email <> ? のように)
  • ORDER BY / LIMIT は外側に置く(各枝で LIMIT して外側でまた LIMIT、のパターンもある)
sql
-- before: email, username にそれぞれインデックスがあっても使われにくい
SELECT * FROM users WHERE email = ? OR username = ?;

-- after: 各枝がインデックスを使える
SELECT * FROM users WHERE email = ?
UNION ALL
SELECT * FROM users WHERE username = ? AND email <> ?;
-- 2 枝目の email <> ? は「1 枝目ですでに取れた行を除く」ためのガード
-- 完全一致で重複除去したいなら UNION(ソート発生)に変える

-- LIMIT 付きの例
(SELECT * FROM users WHERE email = ? ORDER BY id DESC LIMIT 10)
UNION ALL
(SELECT * FROM users WHERE username = ? AND email <> ? ORDER BY id DESC LIMIT 10)
ORDER BY id DESC
LIMIT 10;

パターン 3: IN (サブクエリ) → EXISTS

IN (SELECT ...) は最近のオプティマイザなら EXISTS と同等に最適化されることが多いですが、意図が明確になるのと古いエンジンや複雑な相関では差が出るので、相関サブクエリでは EXISTS を優先するのがおすすめです。

EXISTS は「1 件でも見つかれば true」なので、ショートサーキット評価で効率的。一方、非相関の単純な候補リスト(定数の列挙や別テーブルの ID 一覧)を渡すなら IN のほうが読みやすいです。

sql
-- before
SELECT *
FROM users u
WHERE u.id IN (
  SELECT o.user_id FROM orders o WHERE o.amount > 100
);

-- after (意図が明確、古いエンジンで速い)
SELECT *
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id AND o.amount > 100
);

-- JOIN に寄せる手もあるが、重複行に注意
SELECT DISTINCT u.*
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.amount > 100;
-- DISTINCT が要るぶん、多くの場合 EXISTS のほうが速い

パターン 4: NOT IN の NULL 罠 → NOT EXISTS

NOT IN (SELECT ...)サブクエリ結果に 1 件でも NULL があると結果が全部「不明」になり、何も返ってこなくなるという有名な罠があります。SQL の 3 値論理(TRUE / FALSE / UNKNOWN)の帰結で、バグというより仕様です。

回避策は NOT EXISTS。NULL に対して「存在しない」と判定できるので、直感どおりに動きます。

sql
-- NG: banned.user_id に 1 件でも NULL があると 0 行しか返らない
SELECT *
FROM users u
WHERE u.id NOT IN (SELECT b.user_id FROM banned b);

-- OK: NOT EXISTS なら NULL があっても意図どおり動く
SELECT *
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM banned b WHERE b.user_id = u.id
);

-- どうしても NOT IN のままいくなら NULL を除去
WHERE u.id NOT IN (
  SELECT b.user_id FROM banned b WHERE b.user_id IS NOT NULL
);

パターン 5: 範囲 OR → BETWEEN / OR 削除

同じ列に対する連続した範囲の OR は、単一の不等号や BETWEEN にまとめられることがあります。オプティマイザは割と賢いので差が出ないことも多いですが、可読性が上がるのは確実です。

sql
-- before
WHERE (age >= 20 AND age <= 29) OR (age >= 30 AND age <= 39);
-- after
WHERE age BETWEEN 20 AND 39;

-- 飛び地ならまとめない(そのままで良いが IN にまとめ直せないか検討)
WHERE age BETWEEN 20 AND 29 OR age BETWEEN 40 AND 49;
-- → 多くの場合、複合インデックスの範囲スキャン 2 回で処理されるだけ

書き換え判断の流れ

  1. まず EXPLAIN で OR のインデックス状況を確認。Seq Scan / Full Table Scan なら書き換えの価値あり
  2. 同じ列の OR → IN (...)
  3. 異なる列の OR でそれぞれにインデックスがある → UNION ALL 分解
  4. サブクエリの IN → 相関なら EXISTS、非相関で単純なら IN のまま
  5. NOT IN + NULL の可能性ありNOT EXISTS 一択
  6. 範囲の ORBETWEEN にまとめる
  7. 書き換え後に再度 EXPLAIN でインデックス利用を確認。意図どおりなら PR

注意点: 書き換えで行数が大幅に変わる場合(特に UNION ALL で重複が出る / UNION でソートが挟まる)、意味的に等価かを必ずテストで担保してください。パフォーマンスのために結果を変えてしまうのが最悪の事故です。