はじめに
SQLは、文法自体はとてもシンプルです。
SELECT、WHERE、JOINくらいを覚えれば、新卒でも未経験からの転向者でも、「正しい結果を返すSQL」はわりとすぐに書けるようになります。
ただし、ここに大きな落とし穴があります。
「正しい結果が返ってくること」と「そのSQLが本番で使い物になること」は、まったく別の話です。
SQLはシステムのボトルネックになりやすい部分です。開発中に使うテストデータは数十件〜数百件程度でも、本番環境では数百万件、数千万件というデータを相手にすることになります。テスト環境ではサクサク動いていたのに、本番リリース後に「画面が固まる」「バッチが終わらない」「DBがCPU100%に張り付く」といった障害を引き起こす——その原因の多くは、SQLの書き方にあります。
SQLパフォーマンスの世界は非常に奥が深く、とても1本の記事で語り尽くせるものではありません。私の主観ですが、「これだけはいくら新人でも絶対にやらないでほしい」 という2大NGパターンがあります。これまでの経験で数多く苦しんできた事象です。
- N+1クエリ問題(プログラムで何回もループさせ、SQLクエリを呼び出す)
- インデックスの効かないSQLクエリを書く
この記事では、この2つに絞って、「なぜダメなのか」「どう直せばいいのか」を解説します。
対象読者は、SQLの基本文法は理解しているものの、パフォーマンスについてはまだあまり意識したことがない新人エンジニア・未経験からの転向者の方を想定しています。
NGパターン①:N+1クエリ問題からの脱却
N+1クエリ問題とは何か
N+1クエリ問題とは、リストの1件1件に対してループを回し、その中で都度SQLクエリを発行してしまう実装のことです。「N件のデータに対して、N回(+αで1回)SQLを発行してしまう」ことからこう呼ばれます。テーブルから1レコードずつ取得(フェッチ)し、レコードがなくなるまで処理を続けるやり方、と言い換えることもできます。
いわゆる「1件ずつくるくるループしながらSQLを投げる」実装、とイメージしていただくと分かりやすいかもしれません。
補足:「N+1問題」の厳密な意味について
一般に「N+1問題」という言葉は、JPA/HibernateなどのORMを使った際に、「親レコードを1件取得した後、それに紐づく子レコードをN件、1件ずつ追加で取得してしまう(合計1+N回のクエリが発行される)」という文脈で使われることが多い用語です。本記事で扱うコード例(単一テーブルに対してリストをループしながらクエリを発行するケース)は、より広い意味での「ループクエリ(何度もクエリを発行してしまう問題)」にあたります。実務上のニュアンスとしては同じ問題として扱って差し支えありませんが、言葉の由来として頭の片隅に置いておくと、ORMを扱う場面でもすんなり理解が繋がるはずです。
具体的には、こんなコードです。
List<String> idList = Arrays.asList("111", "222", "333");
for (String id : idList) {
String sqlStatement = "SELECT NAME from 個人情報テーブル where ID = " + id;
// SQL実行(idListの回数分)
ps = con.prepareStatement(sqlStatement);
・・・・
}
「IDのリストがあるから、1件ずつSELECTすればいいや」——これは、プログラミング的にはごく自然な発想です。実際、多くのシステム開発者は「ループで1件ずつ処理する」ことに慣れています。そのため、こういう設計のシステムは世の中に多々存在します。
しかし、これは決していい設計とは言えません。
なぜパフォーマンスが悪いのか
このやり方がパフォーマンス上ダメな理由は、大きく2つあります。
① ネットワーク伝送のオーバーヘッド
SQL文の発行元であるアプリケーションと、データベースは、一般的に別サーバーに配置されています。つまり、SQLを1回発行するたびに、ネットワークを経由した通信が発生します。
これが idList の件数分——つまり3件なら3回、10万件なら10万回発生することになります。1回あたりの通信は数ミリ秒でも、件数が増えれば増えるほど、その積み重ねは無視できないレベルになっていきます。
② SQL解析・実行計画作成のオーバーヘッド
DBMSは、SQL文を受け取るたびに、構文解析(パース)を行い、「このSQLをどういう手順で処理するか」という実行計画を作成します。これもまた、SQLを発行するたびに(多少キャッシュの恩恵はあるにせよ)繰り返し発生するコストです。
ループ回数が増えれば、このコストもそのまま積み上がっていきます。
発想の転換:「表はファイルではない」
このNGパターンを避けるために持っておきたい考え方が、「表はファイルではない」 という発想です。
ファイル処理に慣れていると、「1行ずつ読んで、1行ずつ処理する」という発想がどうしても身についてしまいます。しかしSQLの世界では、表から表を作るイメージを持つことが重要です。「1件ずつ取り出して処理する」のではなく、「まとめて1回の問い合わせで結果の表を作る」という発想に切り替える必要があります。
改善イメージ
先ほどのコードは、次のように書き換えることができます。
List<String> idList = Arrays.asList("111", "222", "333");
// プレースホルダを動的に生成する(実際はフレームワークが提供するバッチ機能等を使うのが望ましい)
String placeholders = idList.stream()
.map(id -> "?")
.collect(Collectors.joining(","));
String sqlStatement = "SELECT ID, NAME from 個人情報テーブル where ID IN (" + placeholders + ")";
// SQL実行(1回のみ実行)
ps = con.prepareStatement(sqlStatement);
for (int i = 0; i < idList.size(); i++) {
ps.setString(i + 1, idList.get(i));
}
・・・・
List<Map> idNameList = テーブルから取得したIDとNAMEのMapのList;
for (Map idNameMap : idNameList) {
・・・・
}
ポイントは、SQLの発行そのものは1回にまとめてしまうことです。IN句を使えば、複数のIDに対する検索を1回のクエリで実現できます。ループが完全になくなったわけではありませんが、ループしているのは「取得後の結果セットに対する処理」であり、「SQLの発行」そのものはループしていない点に注目してください。ここが決定的な違いです。
※上記はあくまでイメージです。文字列を単純に連結する("IN " + idListのように書く)と、ListのtoString()結果([111, 222, 333]のような形式)がそのままSQLに埋め込まれ、構文エラーになってしまいます。必ずプレースホルダ(?)を使う、もしくはお使いのフレームワーク(MyBatis、JPA、jOOQなど)が提供するバッチ処理の作法に従って実装してください。IN句に渡す件数が非常に多い場合は、一括INSERTしたテンポラリテーブルとJOINするなど、別のアプローチが必要になることもあります。
どうしてもループが必要な場合は
業務ロジックの都合上、「1件ずつ取得して、複雑な条件分岐をしながら処理したい」というケースもゼロではありません。そうした場合は、アプリケーション側でループを回すのではなく、ストアドプロシージャを使って、DB側で完結させるのが一般的な手段です(OracleであればPL/SQLを使用します)。
いずれにせよ、「アプリ側からDBに何度も往復する」という構造そのものを避けることが重要です。
なぜ「N+1クエリ問題」はなくならないのか
これだけ広く知られたアンチパターンであるにもかかわらず、N+1クエリ問題はなぜ現場からなくならないのでしょうか。理由は大きく3つあると考えています。
① 処理するデータ件数が少ない
データ件数が少ないうちは、多少効率の悪い実装をしても大きな問題にはなりません。開発環境やテスト環境では数十件程度のデータでしか動作確認をしないため、この問題が表面化しにくいのです。
② ループ処理の方が圧倒的に可読性が高い
1件ずつループで処理する実装は、IN句などを使った複雑なSQLクエリよりも直感的で、コードを追いやすく可読性が高いという側面があります。「動けばいい」「読みやすければいい」という判断だけでは、この実装を避ける動機が生まれにくいのです。
③ SQLで難しい処理をさせるにはスキルが必要
SQLは基本文法こそ簡単ですが、複数条件の絞り込みや集計、サブクエリなどを駆使して「まとめて1回で処理する」SQLを書くには、それなりのスキルが求められます。ループ処理に頼ってしまうのは、ある意味で「SQLの学習コストを避けている」結果とも言えます。
こうした背景があるからこそ、新人のうちから「ループでSQLを何度も発行する実装は避けるべきだ」という意識を持っておくことには大きな価値があります。
NGパターン②:インデックスが効かないSQLを書く
インデックスとは何か
多くのRDBMSで採用されており、一般的かつ重要なのがB-Treeインデックスです(その改良版である B+Tree インデックスも同様の考え方です)。
インデックスの効果は、本で調べ物をするときの「索引」 をイメージすると分かりやすいです。索引がなければ、目的の情報を探すために本を最初から最後まで読む(=全件検索する)しかありません。索引があれば、目的のページに一気にたどり着けます。SQLにおけるインデックスも、これと同じ役割を果たします。
インデックスを作る指針
とはいえ、「とりあえず全部の列にインデックスを貼ればいい」というものでもありません。インデックスにはメンテナンスコスト(更新時の負荷や、ディスク容量)もかかるため、次のような指針で作成するのが基本です。
- 大規模なテーブルに作成する(データ件数が少なければ全件検索でも十分速い)
- カーディナリティの高い列に作成する(値の種類が多い列。逆に「性別」のように値の種類が少ない列は効果が薄い)
- WHERE句の選択条件、結合条件に使われている列に作成する
新人がやりがちな「インデックスを殺すSQL」
問題は、せっかくインデックスを作っても、SQLの書き方次第でそのインデックスが使われなくなってしまうケースが多々あることです。これは新人がかなりの確率でやってしまいます。代表的なパターンは以下の通りです。
- インデックス列に対して演算を行っている
- インデックス列に対してSQL関数を使用している
-
IS NULLを使用している -
否定形(
<>、NOT INなど)を使用している -
ORを使用している -
後方一致・中間一致の
LIKE(LIKE '%abc'やLIKE '%abc%') - 暗黙の型変換を行っている
これらに共通するのは、「インデックス列そのものを、SQL側で加工・変換してしまっている」という点です。インデックスは「列の値そのもの」を対象に作られているため、その値を加工してから比較すると、DBMSはインデックスを使った検索ができなくなり、結局全件検索(フルスキャン)になってしまいます。
※ここで挙げたパターンは原則論であり、実際にはDBMSの種類やバージョン、オプティマイザの進化によって挙動が異なる場合があります。たとえばIS NULLはOracleの標準的なB-Treeインデックス(単一列)では使われませんが、PostgreSQLやMySQL(InnoDB)ではインデックスが利用されるケースもあります。またOR条件も、オプティマイザによってIN句と同等に最適化されたり、内部的にインデックスが使われたりすることが増えています。とはいえ、こうした挙動はDBMSやバージョンに依存し確実性に欠けるため、アンチパターンとして避けておくのが無難です。
具体例で見てみる
たとえば、「入社年月日」という date 型の列にインデックスが設定されているとします。この列を使って、文字列形式の日付('20240401' のような形式)で検索したいとき、次のように書いてしまうと危険です。
-- インデックス無効(全件検索してしまう)
WHERE TO_CHAR(入社年月日, 'YYYYMMDD') = 日付(文字列)
一見自然な書き方に見えますが、TO_CHAR によってインデックス列である「入社年月日」自体を加工してしまっているため、インデックスが使われません。
正しくは、比較対象の値の側を変換するようにします。
-- インデックス有効
WHERE 入社年月日 = TO_DATE(日付(文字列), 'YYYYMMDD')
このように、「インデックスが張られている列」ではなく、「比較する側の値」を加工するようにすると、インデックスが正しく機能します。
同じ考え方は、暗黙の型変換にも当てはまります。たとえば数値型の列に対して、うっかり文字列と比較してしまうと、DBMS側で暗黙的に型変換が行われ、意図せずインデックスが使われなくなることがあります。「型を意識せずに書いたSQLが、実は暗黙の型変換を起こしていた」というのは、初心者だけでなくベテランでもやってしまいがちな落とし穴です。
まとめ
SQLの文法は簡単で、正しい結果を出すこと自体は難しくありません。しかし、結果が正しくても、遅いシステムは「使えないシステム」 です。特にデータ件数が大量になる本番環境では、ちょっとした書き方の違いが、致命的なパフォーマンス劣化や障害につながります。
新人のうちは、まず次の2点だけは絶対に押さえてください。
- ループでSQLを何度も発行しない。 「表はファイルではない」——1件ずつ処理するのではなく、集合としてまとめて処理する発想を持つ。
- インデックスを無効化する書き方をしない。 インデックス列そのものを加工・変換せず、比較対象の値の側を加工する。
この2つを意識するだけでも、「動くけど遅いSQL」から「動くし速いSQL」に大きく近づけるはずです。
SQLパフォーマンスの世界はここで語り尽くせるものではなく、実行計画の読み方や、統計情報、結合方式(Nested Loop / Hash Join など)といった、さらに踏み込んだテーマもたくさんあります。それらはまた別の記事で扱えればと思います。
まずはこの記事で紹介した2大NGパターンを、日々のコーディングの中で意識してみてください。
最後にご紹介
SQLを得意なものにするのは、ひたすら練習することです。
僭越ながら、SQLの練習サイトをご紹介します。環境構築することなく、すぐにSQLの学習をすることができます。Oracle向けですが、PostgreSQLなど、他のDBMSにも応用できる内容かと思います。
また、「完全版」も用意しております。250問以上の問題がありますので、これをこなせば、間違いなくSQLの達人になれます!