本番環境でデータ量が増えた瞬間、アプリケーションが突然重くなる
すべてのエンジニアが一度は直面するこの悪夢。その原因の多くは “気づかぬうちにDBを苦しめているSQL” です。
この記事では、実務で特に被害が大きかった 5つの致命的なSQLアンチパターン と、追加で バッチ処理の最適化(バルクINSERT)、EXISTS vs IN の違い を取り上げ、
- なぜ遅くなるのか(原理)
- どう直すべきか(改善策)
- どんな落とし穴があるか(トレードオフ)
- 実務チェックリスト
を、技術書レベルでわかりやすく整理します。
目次
- インデックスを殺す「関数・演算子」利用
- アプリ性能を破壊する「N+1問題」
- 隠れたパフォーマンスキラー「SELECT *」
- 前方一致以外の LIKE 検索
- バッチ処理最適化:バルクINSERT
- EXISTS vs IN の違い
- 補足:
EXPLAINを使った実践的な確認フロー - 現場で効く小ネタ(Tips)
- まとめ & Next Step
1. NG1:インデックスを殺す「関数・演算子」利用
1.1 原理とNGパターン
インデックスは カラムの素の値 をもとに作られます。WHERE句の中でカラムに関数・演算をかけると、DBはインデックスを使えません。
NG例
WHERE YEAR(created_at) = 2024;
問題点: created_at のインデックスが無効化 → 全件に関数適用 → フルテーブルスキャン
図解
1.2 改善策とトレードオフ
OK例
WHERE created_at >= '2024-01-01'
AND created_at < '2025-01-01';
メリット: カラム値を加工せず比較できるため、インデックスをフル活用できる。
実務チェックリスト
- WHEREでカラムに関数をかけていないか?
- 範囲検索に置き換えられないか検討したか?
- 関数インデックス導入時は書き換えコストと維持コストを評価したか?
2. NG2:アプリ性能を破壊する「N+1問題」
2.1 原理とNGパターン
親テーブルを取得し、その結果のループ内で子テーブルを個別に取得するパターン。
NG例(図付き)
結果: 1 + N クエリ。ユーザー1000件なら1001クエリ発行。
2.2 改善策とトレードオフ
OK案
- Eager Loading を使用し JOIN か IN で集約
トレードオフ
JOIN が巨大になると、データ転送量・メモリ消費が増える(ワイドJOIN問題)。
実務チェックリスト
- ORM の N+1 チェック機能(bullet, rack-mini-profiler等)を有効にしているか?
- Eager Loading にした際の返却データ量を計測したか?
3. NG3:隠れたパフォーマンスキラー「SELECT *」
3.1 原理とNGパターン
NG例
SELECT * FROM products;
問題点:
- 不要な巨大カラム(BLOB等)まで転送 → ネットワークI/O肥大
- カバリングインデックスが使えず高速化の機会損失
図解(データ転送量)
3.2 改善策とトレードオフ
OK例
SELECT id, name, price FROM products;
実務チェックリスト
- API や管理画面で必要なカラムだけを返しているか?
- カバリングインデックスで対応できないか検討したか?
4. NG4:前方一致以外の LIKE 検索
4.1 原理とNGパターン
NG例
WHERE name LIKE '%tanaka%';
問題点: B-Treeインデックスが利用できず、全文走査が発生。
図解
4.2 改善策とトレードオフ
OK案
WHERE name LIKE 'tanaka%';
または全文検索エンジン(Elasticsearch, Solr, PGroonga)を導入。
トレードオフ
- ミドルウェア追加による構成・運用コスト増
- データ同期・整合性の追加実装が必要
実務チェックリスト
- 検索要求は前方一致で足りるか?
- 全文検索導入時のコスト(運用・インフラ)を試算したか?
5. バッチ処理最適化:バルクINSERT
5.1 原理とNGパターン
大量データを1行ずつINSERTすると、1回のINSERTにつき1回トランザクション処理が走り、極端に遅くなります。
NG例
INSERT INTO logs(message) VALUES ('a');
INSERT INTO logs(message) VALUES ('b');
INSERT INTO logs(message) VALUES ('c');
問題点:
- 毎回ネットワーク往復
- 毎回トランザクション管理
5.2 改善策:バルクINSERT
OK例
INSERT INTO logs(message) VALUES
('a'),
('b'),
('c');
またはDB/ORMが提供するバルクAPIを活用する。
図解
5.3 トレードオフ
- バッチサイズが大きすぎるとメモリ使用量が増える
- トランザクションが長くなるとロック時間も増える
実務チェックリスト
- 1件ずつINSERTしていないか?
- 適切なバッチサイズ(100〜10,000)を計測で決めたか?
- ORMのbulk APIが正しくインデックスを活用しているか確認したか?
6. EXISTS vs IN の違い
6.1 原理
IN は 左側のテーブルの各行に対して右側のリストを検索します。
EXISTS は 右側のテーブルに該当行が1件あるかどうかだけを評価します。
NG例(IN 乱用)
SELECT * FROM users
WHERE id IN (SELECT user_id FROM posts);
OK例(EXISTS)
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM posts p WHERE p.user_id = u.id
);
6.2 違い(概念図)
6.3 いつ使うべき?
- 右側が大量データ → EXISTS が有利(早期終了できる)
- 右側が小さな固定リスト → IN が有利(最適化されやすい)
実務チェックリスト
- IN の右側が巨大ではないか?
- RIGHT JOIN + IS NOT NULL のほうが速くならないか?
- 実際に EXPLAIN で比較したか?
7. 補足:EXPLAINを使った実践的な確認フロー
なぜ EXPLAIN を見るのか
- 実際にDBがどのインデックスを使っているか、どれだけの行を読み込むかが分かる。
最低限見るべきポイント
- 実行計画の最上位ノード(Seq Scan, Index Scan など)
- estimated rows / actual rows(可能なら)
- cost と actual time(Postgres)
小さな例(PostgreSQL風の擬似EXPLAIN)
EXPLAIN ANALYZE SELECT id, name FROM products WHERE created_at >= '2024-01-01';
-- 例出力(要約)
Index Scan using idx_products_created_at on products (cost=0.29..8.52 rows=10 width=64) (actual time=0.10..0.15 rows=12 loops=1)
チェックリスト
- Seq Scan が頻発していないか?
- 実際の行数(actual rows)が想定と大きく違わないか?
8. 追加で覚えておくべき小ネタ(現場で効くTips)
- 慢ログ(slow query log)を有効化してまずは“火の手”を見つける
- 統計情報の更新(ANALYZE)が古いと不適切なプランが選ばれる
- インデックスの肥大化を監視(特に複合インデックスと関数インデックス)
- クエリパラメータの型が一致しているか(暗黙の型変換でIndexが非効率になる場合あり)
9. まとめ & Next Step
SQLの裏側で何が起こるかを理解することは、強いエンジニアになるための必須スキルです。この記事のNGパターンを避け、バッチ処理やEXISTSの使い分けまで押さえることで、アプリ性能は劇的に改善します。
今すぐできるアクション(3分でできる)
- 5つのチェックリストを手元のクエリに適用してみる
- slow query log を確認して上位10件を洗い出す
- 重要なクエリに対して
EXPLAIN (ANALYZE, BUFFERS)を実行する
🔥 最後に:この記事が役立ったら
いいね・フォローしていただけると励みになります!
今後とも初心者にもベテランにも役立つような記事を投稿していきます!!