SQL アンチパターンとは
「アンチパターン」とは、一見問題なく動作するように見えながら、実際には深刻なパフォーマンス劣化・バグ・保守コスト増を招く記述のパターンをいう。 SQL における最大の問題源は インデックスの無効化 だ。
データベースはテーブルを高速に検索するためにインデックスを持つ。しかし、SQL の書き方によってはせっかくのインデックスが使われず、全行を読み込む「フルテーブルスキャン」に落ちてしまう。 テーブルが数百万行規模になると、この差は数十倍から数百倍のレスポンスタイム差に直結する。
💡 EXPLAIN / EXPLAIN ANALYZE を習慣化する
本シリーズで解説するパターンを把握しつつ、新しいクエリを書いたら必ず EXPLAIN ANALYZE で実行計画を確認する。「フルスキャン」「filesort」「Using temporary」などのキーワードが出たら要改善のサインだ。
1. WHERE 句でカラムに関数を適用する
インデックスは「カラムの値そのもの」に対して作成される。 WHERE 句でカラムに関数をかぶせると、データベースはインデックスを使えず、全行に対して関数を評価してから絞り込むことになる。
❌ NG-- created_at にインデックスがあっても YEAR() をかけると使われない
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- 同様に DATE(), MONTH(), UPPER(), LOWER(), SUBSTR() も同じ問題
SELECT * FROM users WHERE UPPER(email) = 'USER@EXAMPLE.COM';
SELECT * FROM logs WHERE DATE(logged_at) = '2024-06-01';
-- 関数をリテラル側にかけ、カラムはそのままにする
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- email は小文字で保存する運用にするか、生成列インデックスを使う
SELECT * FROM users WHERE email = LOWER('USER@EXAMPLE.COM');
-- 日付範囲に展開する
SELECT * FROM logs
WHERE logged_at >= '2024-06-01' AND logged_at < '2024-06-02';
⚠️ ポイント:「関数をカラム側に適用しない」
関数を使いたいなら、リテラル(定数)側にかける。カラム側に関数をかけた瞬間にインデックスは効かなくなる。どうしてもカラム側に変換が必要なら、MySQL の 生成列インデックス(Generated Column) や PostgreSQL の 関数インデックス を検討する。
2. 暗黙の型変換
カラムの型とリテラルの型が異なると、データベースは暗黙的に型変換を行う。 この変換がカラム側に適用されると、上述の「関数適用と同等」の状態になり、インデックスが無効化される。
❌ NG-- user_id カラムが VARCHAR 型なのに整数リテラルで比較
SELECT * FROM users WHERE user_id = 12345;
-- phone_number が VARCHAR なのに数値で比較
SELECT * FROM contacts WHERE phone_number = 09012345678;
-- status が VARCHAR(1) なのに数値で比較
SELECT * FROM orders WHERE status = 1;
-- シングルクォートで文字列として渡す
SELECT * FROM users WHERE user_id = '12345';
SELECT * FROM contacts WHERE phone_number = '09012345678';
SELECT * FROM orders WHERE status = '1';
型変換は DB 側が内部的にキャスト関数を適用することで行われるため、インデックスが効かなくなる。 アプリケーション側から SQL を発行する際は プリペアドステートメントでバインド変数の型を合わせる ことで自動的に正しい型が渡される。
3. LIKE の前方ワイルドカード
LIKE の検索パターンが % で始まる場合、データベースはインデックスをソートされた順序で探索できないため、フルスキャンになる。
-- 先頭に % があるとインデックス不使用
SELECT * FROM products WHERE name LIKE '%スニーカー%';
SELECT * FROM articles WHERE title LIKE '%入門%';
-- 後方一致(前方一致)はインデックスが使える
SELECT * FROM products WHERE name LIKE 'スニーカー%';
-- 全文検索が必要な場合は FULLTEXT インデックスを使う (MySQL)
ALTER TABLE articles ADD FULLTEXT INDEX ft_title (title);
SELECT * FROM articles WHERE MATCH(title) AGAINST('入門' IN BOOLEAN MODE);
-- PostgreSQL の場合は tsvector / tsquery を利用
SELECT * FROM articles WHERE to_tsvector('japanese', title) @@ to_tsquery('japanese', '入門');
| パターン | インデックス | 用途 |
|---|---|---|
'keyword%' | ✅ 使われる | 前方一致(例:郵便番号の上位桁) |
'%keyword' | ❌ 使われない | 後方一致 → 全文検索インデックスへ |
'%keyword%' | ❌ 使われない | 部分一致 → 全文検索インデックスへ |
4. NOT IN / NOT EXISTS の使い分け
NOT IN はサブクエリの結果に NULL が含まれると常に FALSE を返すという致命的なバグがある。
加えてインデックス効率も NOT EXISTS より低い場合が多い。
-- banned_users.id に NULL が1件でも含まれると結果が全件空になる
SELECT * FROM orders
WHERE user_id NOT IN (SELECT id FROM banned_users);
-- NULL を含む IN リストは全て false になる SQL の仕様
-- 例: 1 NOT IN (2, NULL) → UNKNOWN (falseとして扱われる)
-- NOT EXISTS は NULL 問題がなく、インデックスも使われやすい
SELECT * FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM banned_users b WHERE b.id = o.user_id
);
-- または LEFT JOIN + IS NULL パターン
SELECT o.* FROM orders o
LEFT JOIN banned_users b ON b.id = o.user_id
WHERE b.id IS NULL;
✅ NULL が絶対に入らないと保証できる場合のみ NOT IN を使う
NOT NULL 制約があり NULL が混入しないと確信できる場合は NOT IN でも可。それ以外は常に NOT EXISTS を選ぶのが安全だ。
5. OR 条件の多用
OR をまたぐ条件は複合インデックスのレンジスキャンを分断する。
同一カラムに複数の値を比較する場合は IN に書き換えることで、インデックスの効率が上がる。
-- 同一カラムへの OR はインデックスが分断されやすい
SELECT * FROM logs WHERE status = 'error' OR status = 'warn';
-- 異なるカラムへの OR は特に最適化されにくい
SELECT * FROM products WHERE category = 'shoes' OR brand = 'Nike';
-- 同一カラムへの OR は IN に書き換える
SELECT * FROM logs WHERE status IN ('error', 'warn');
-- 異なるカラムへの OR は UNION ALL が有効な場合もある
SELECT * FROM products WHERE category = 'shoes'
UNION ALL
SELECT * FROM products WHERE brand = 'Nike' AND category != 'shoes';
異なるカラムをまたぐ OR は本質的にインデックスを活用しにくい。
この場合は UNION ALL で条件を分割し、それぞれのクエリでインデックスを使わせる手法が有効だ。
ただし重複行の扱いに注意する(UNION ALL は重複を残す)。
まとめ
| アンチパターン | 原因 | 対策 |
|---|---|---|
| WHERE 句でカラムに関数適用 | インデックス対象の値が変化する | リテラル側に関数を移す / 関数インデックス |
| 暗黙の型変換 | 内部でキャスト関数が実行される | カラム型と一致するリテラルを使う |
| LIKE 前方ワイルドカード | ソート順での探索が不可 | 前方一致に変更 / 全文検索インデックス |
| NOT IN(NULL 問題) | NULL を含むと全件 false | NOT EXISTS / LEFT JOIN IS NULL |
| OR 多用 | インデックスが分断される | IN に書き換え / UNION ALL で分割 |
✅ 次のページでは…
PART 02 では 不要なデータ読み込み・結合・サブクエリ に関するアンチパターンを解説する。SELECT * や大 OFFSET、N+1 問題、相関サブクエリ、デカルト積など実務でよく踏む落とし穴をまとめる。