SQL アンチパターンとは

「アンチパターン」とは、一見問題なく動作するように見えながら、実際には深刻なパフォーマンス劣化・バグ・保守コスト増を招く記述のパターンをいう。 SQL における最大の問題源は インデックスの無効化 だ。

データベースはテーブルを高速に検索するためにインデックスを持つ。しかし、SQL の書き方によってはせっかくのインデックスが使われず、全行を読み込む「フルテーブルスキャン」に落ちてしまう。 テーブルが数百万行規模になると、この差は数十倍から数百倍のレスポンスタイム差に直結する。

💡 EXPLAIN / EXPLAIN ANALYZE を習慣化する

本シリーズで解説するパターンを把握しつつ、新しいクエリを書いたら必ず EXPLAIN ANALYZE で実行計画を確認する。「フルスキャン」「filesort」「Using temporary」などのキーワードが出たら要改善のサインだ。

1. WHERE 句でカラムに関数を適用する

インデックスは「カラムの値そのもの」に対して作成される。 WHERE 句でカラムに関数をかぶせると、データベースはインデックスを使えず、全行に対して関数を評価してから絞り込むことになる。

❌ NG
SQL — インデックス不使用
-- 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';
✅ OK
SQL — レンジスキャンに書き換え
-- 関数をリテラル側にかけ、カラムはそのままにする
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
SQL — 型不一致でインデックス不使用
-- 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;
✅ OK
SQL — 型を合わせる
-- シングルクォートで文字列として渡す
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 の検索パターンが % で始まる場合、データベースはインデックスをソートされた順序で探索できないため、フルスキャンになる。

❌ NG
SQL — 前方ワイルドカード
-- 先頭に % があるとインデックス不使用
SELECT * FROM products WHERE name LIKE '%スニーカー%';
SELECT * FROM articles WHERE title LIKE '%入門%';
✅ OK
SQL — 前方一致またはFULLTEXTインデックス
-- 後方一致(前方一致)はインデックスが使える
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 より低い場合が多い。

❌ NG
SQL — NOT IN の落とし穴
-- 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として扱われる)
✅ OK
SQL — NOT EXISTS に置き換え
-- 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 に書き換えることで、インデックスの効率が上がる。

❌ NG
SQL — OR の非効率
-- 同一カラムへの OR はインデックスが分断されやすい
SELECT * FROM logs WHERE status = 'error' OR status = 'warn';

-- 異なるカラムへの OR は特に最適化されにくい
SELECT * FROM products WHERE category = 'shoes' OR brand = 'Nike';
✅ OK
SQL — IN に置き換え
-- 同一カラムへの 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 を含むと全件 falseNOT EXISTS / LEFT JOIN IS NULL
OR 多用インデックスが分断されるIN に書き換え / UNION ALL で分割

次のページでは…

PART 02 では 不要なデータ読み込み・結合・サブクエリ に関するアンチパターンを解説する。SELECT * や大 OFFSET、N+1 問題、相関サブクエリ、デカルト積など実務でよく踏む落とし穴をまとめる。