LASSIC Media らしくメディア
N+1問題とは|発生原因とパフォーマンス対策
一覧画面を表示するたびにデータベースへの問い合わせが増え続け、件数が増えるにつれて表示が重くなる——開発会社とのレビューで「N+1問題が発生しています」と報告を受けたものの、何がどう問題なのか判断しづらいという声はよく聞かれます。原因も対策も専門的に見えるため、発注側やプロジェクトマネージャーが会話に加われず、修正の妥当性を評価できないまま任せきりになるケースも少なくありません。
この記事のねらいは、N+1問題がなぜ起こり、なぜ性能に響くのかを、実装コードに踏み込まずに整理することです。発生の仕組み、件数とクエリ数の具体的な関係、気づき方、対策の選び方とトレードオフ、そして発注・レビューの場で確認しておきたい観点まで、順を追って見ていきましょう。特定の言語やフレームワークに限った話ではなく、データベースを扱うシステム全般に共通する論点として押さえておくと、開発会社の選定や進行中プロジェクトのレビューで役立ちます。
目次
この記事のポイント
- N+1問題とは、一覧を取得する1回のクエリの後に各要素ごとの追加クエリが繰り返し発行され、合計で1+N回の問い合わせになってしまう性能上の問題です。
- クエリ数がデータ件数に比例して増えるため、件数が増えるほど遅くなり、開発時は少件数で気づきにくく本番投入後に顕在化しやすい特徴があります。
- Eager Loading(一括取得)やJOIN、IN句でのまとめ取得、DataLoaderなどで、必要なデータをまとめて取得する設計に見直すことで抑えられます。
N+1問題とは、クエリ数が件数に比例して増える性能問題
N+1問題とは、一覧を取得する1回のクエリのあとに、その各要素について追加のクエリが繰り返し発行され、合計で1+N回のクエリになってしまう性能上の問題です。名前の「N+1」は、この「1回+N回」という発行回数の内訳をそのまま表しています。
問題の核心は、クエリの回数が固定ではなく、扱うデータの件数(N)に比例して増えていく点にあります。件数が10件なら11回、1,000件なら1,001回というように、表示件数が増えるほどデータベースへの往復回数が線形に膨らんでいくのです。
データベースへの1回の問い合わせには、通信のやり取りや処理の準備といった一定のオーバーヘッドが伴います。この負担が件数分だけ積み重なるため、開発時には気づきにくい程度の遅延が、本番のデータ量では無視できない応答時間の悪化やデータベース負荷の増大として表面化することがあります。
発注側にとってのN+1問題は、単なる実装の巧拙にとどまらない論点です。応答時間の悪化はそのまま利用者の離脱につながりかねず、データベースへの負荷増大は同じ基盤を使う他機能の動作にも波及します。性能要件を仕様書やレビューでどう扱うかは、事業の見通しに直結する判断事項といえるでしょう。
この現象自体は特定の言語やフレームワークに固有のものではありません。Ruby on Rails、Django、Hibernate(Java向けのORM)など、扱う言語やデータベースが異なっても、一覧と関連データを組み合わせて表示するアプリケーションであれば、共通して起こりうる論点として各社の公式ドキュメントで扱われています。
ORMの遅延ロードとループ処理が引き起こす発生源
N+1問題の多くは、ORM(Object-Relational Mapping。プログラム上のオブジェクトとデータベースのテーブルを対応づけ、SQLを意識せずデータを扱えるようにする技術)の遅延ロード(Lazy Loading)と、ループ処理の組み合わせから生まれます。
遅延ロードとは、関連するデータを実際に使う瞬間まで取得を先延ばしにする仕組みです。使わないデータまで毎回読み込まずに済むため、単体でアクセスする分には無駄がなく、Ruby on Rails・Django・Hibernateをはじめとする主要なORMで標準の挙動として採用されています。
一覧取得のあとにループで関連データへアクセスする流れ
典型的な流れはこうです。まず一覧取得の1回のクエリで対象データをまとめて取得します。次に、画面表示のためにその一件ずつをループで処理し、各要素が持つ関連データ(カテゴリ名や在庫数など)に触れた瞬間、遅延ロードの仕組みが働いて個別のクエリが発行されるのです。
ループの外側から見れば単なる繰り返し処理に過ぎませんが、内部では要素の数だけデータベースへの問い合わせが積み上がっています。実際、Ruby on Railsの公式ガイドでも、10件の書籍一覧を表示する際に著者名を1件ずつ取得すると、1+10で合計11回のクエリが発行される例を挙げ、代表的な性能課題として取り上げています。
設計・実装レビューで見落とされやすいポイント
この構図が見落とされやすいのは、コードの見た目上は「一覧を取得して、ループで表示するだけ」の自然な処理に見えるためです。ループ内で関連データへアクセスする箇所そのものにクエリ発行の記述がないため、コードレビューでも一見して気づきにくいという事情があります。
一対多・多対多の関連ではさらに深刻化しやすい
関連が一対一であれば、追加のクエリは要素1件につき1回で済みます。しかし一対多や多対多の関連(1件の商品に複数のレビューが紐づく、1件の注文に複数の明細が紐づく、といった構造)では、関連データの取得先を複数持つことになり、1件あたりの追加クエリがさらに増えるケースもあります。
画面が表示する情報の種類が増えるほど、この構図は複雑になりがちです。一覧・詳細・集計といった複数の関連を同時に表示する画面では、どの関連についてEager Loadingが必要かを個別に洗い出す作業が欠かせません。
ここで押さえておきたいのは、N+1問題の多くが単純な実装ミスというより、ORMの初期設定を何も意識せずに使った結果として自然に生まれる、という点です。開発者の技量不足というより、性能要件をどこまで設計段階で意識できていたかという、プロジェクト運営側の課題として捉えたほうが実態に近いでしょう。
一覧画面の実例-件数に比例してクエリが増える具体例
具体的な画面で考えてみましょう。題材は、商品一覧画面で各商品のカテゴリ名を表示するケースです。まず商品一覧を取得する1回のクエリが実行され、続けて画面表示のループの中で、商品ごとにカテゴリ名を取得する追加のクエリが発行されます。
この積み上がり方を件数別に整理すると、増え方の性質がはっきりします。表示件数が10件なら1+10で11回、100件なら1+100で101回、1,000件なら1+1,000で1,001回というように、クエリ数は表示件数にほぼ比例して増えていくのです。
| 表示件数 | 素朴な実装(1+N) | 一括取得(Eager Loading) |
|---|---|---|
| 10件 | 11回 | 2回 |
| 100件 | 101回 | 2回 |
| 1,000件 | 1,001回 | 2回 |
この表からも分かるとおり、一括取得側のクエリ数は件数によらず一定です。件数が少ないうちは差が体感しにくくても、件数が増えるほど両者の開きは大きくなっていきます。
厄介なのは、開発環境ではテスト用に少数のデータしか用意していないことが多く、この差が表面化しにくい点でしょう。本番環境でデータが数百件・数千件規模に育ってから初めて、画面表示の遅さやタイムアウトという形で問題が顕在化するケースが見受けられます。
同様の構図は、商品一覧とカテゴリ名の組み合わせに限りません。注文一覧に対する明細件数の表示、ブログ記事一覧に対するコメント数の表示など、「一覧の各行に、別テーブルから取ってくる付随情報を添える」画面であれば、業種やシステムの種類を問わず同じ形で発生しえます。
クラウド上のマネージドデータベースを利用する構成では、アプリケーションとデータベースがネットワークを介してやり取りするため、1回あたりの往復にも相応の時間がかかります。クエリの回数が増えるほど、この往復の積み重ねが応答時間に直接反映されやすくなる点も押さえておきたいところです。
1つの一覧画面でカテゴリ名と在庫情報の両方を都度取得するように、複数の関連データを個別に呼び出す実装になっていると、影響はさらに大きくなります。1件につき2回の追加クエリが発生する構成であれば、件数がNのとき合計は1+2Nとなり、表示する関連データの種類が増えるほど、クエリ数の増え方も急になっていくのです。
クエリ数の計測とログで気づく検出方法
N+1問題は事前に仕組みを知っていれば防ぎやすい一方、放置すると本番投入後に気づくことになりがちです。開発の早い段階で検出するための代表的な手段を整理します。
開発環境でのクエリログの確認
多くのORMには、実行されたSQLをログに出力する機能があります。画面を1回表示させたときにログへ流れるクエリの本数を数えるだけでも、想定より多いクエリが発行されていないかを確認できます。件数の少ない開発データでも、1画面あたりのクエリ本数を意識する習慣が有効です。
スロークエリログ・APMによる検知
本番環境やステージング環境では、データベースのスロークエリログや、APM(Application Performance Monitoring。アプリケーションの処理時間やクエリ回数を可視化する監視の仕組み)のトレース機能を使うと、特定の画面やAPIで異常に多いクエリが発行されていないかを継続的に把握できます。件数が伸びた後の劣化を早期に検知するうえで欠かせない仕組みでしょう。
コードレビューで確認したい観点
コードレビューの段階では、ループの内側で関連データにアクセスしている箇所がないかを目視で確認することが有効です。一覧取得のロジックと、そこから関連データを取得するロジックが離れた場所に書かれていると見落としやすいため、レビュー担当者は「ループの中でデータベースにアクセスしていないか」を明確なチェック項目として持っておく必要があります。
個人の注意力だけに頼ったレビューには限界があるため、チェック項目をレビュー観点として文書化し、担当者が変わっても同じ基準で確認できるようにしておくことが、見落としを減らすうえで役立ちます。
Ruby on Railsの「bullet」やDjangoの「django-debug-toolbar」のように、N+1にあたるクエリパターンを自動で警告する開発支援ツールを導入しているプロジェクトもあります。こうしたツールを使えば、レビュー担当者の目視だけに頼らずに済むでしょう。
データベースに限らずAPI呼び出しでも起こりうる
N+1の構図は、データベースへのアクセスに限った話ではありません。マイクロサービス構成で、一覧を取得したあとに各要素の詳細を別サービスのAPIへ1件ずつ問い合わせるような設計でも、まったく同じ「1+N回」の呼び出しパターンが生まれます。
この場合は、後述するDataLoaderのようなバッチ処理の仕組みや、複数件のIDをまとめて渡せるAPI設計そのものが対策の軸になります。検出の際も、データベースのクエリログだけでなく、サービス間通信のトレースを確認する視点が必要です。
Eager Loading・JOIN・IN句・DataLoaderによる対策
N+1問題への対策はいずれも「関連データをまとめて取得する」という発想を共有していますが、実装方法とトレードオフはそれぞれ異なります。代表的な4つの方法を見ていきましょう。どれか一つが常に正解というわけではなく、画面の性質やデータの構造に応じて組み合わせて使うのが実務的な進め方です。
Eager Loading(一括先読み)でループ前にまとめて取得する
Eager Loadingとは、一覧取得の時点で、必要になる関連データもあらかじめまとめて取得しておく方式です。Ruby on Railsの「includes」、Djangoの「select_related」「prefetch_related」、Hibernateの「JOIN FETCH」やバッチフェッチなど、主要なORMにはこの仕組みが標準機能として用意されています。
ただし、Eager Loadingは万能ではありません。画面によっては使わない関連データまで先読みしてしまい、転送量やメモリ消費が増えてかえって遅くなることもあります。どの関連データを、どの画面で先読みするかを個別に見極める判断が必要です。
一覧画面と詳細画面のように、同じデータでも表示する情報量が異なる画面が複数ある場合は、画面ごとに先読みする関連データの範囲を変える設計が求められます。一律に「すべて先読み」としてしまうと、シンプルな一覧画面まで重くなりかねません。
JOIN・IN句でデータベース側にまとめて取得させる
SQLのJOINは、複数のテーブルを1回のクエリの中で結合して取得する方法です。IN句は、関連先のIDをまとめて指定し、対象行を1回のクエリでまとめて取得する方法にあたります。どちらもデータベース側で結合や絞り込みを済ませるため、往復回数そのものを減らせます。
一方でJOINは、一対多の関連を結合すると、結果セットの行数が増えて重複データを含む形になりやすいという特性があります。IN句もまとめて指定するIDの件数が極端に多いと、1回あたりのクエリが重くなることがあり、件数に応じたバッチ分割が必要になる場合もあります。
どちらの方式も、Eager Loadingを実現するための具体的な手段の一つという位置づけです。ORMのEager Loading機能は、内部的にJOINまたはIN句のいずれかを使ってこの仕組みを実現していることが多く、両者は対立する選択肢というより、組み合わせて理解すべき関係にあります。
バッチ処理・DataLoaderで呼び出しをまとめる
GraphQLのようにデータ取得の呼び出しが細かく分散しやすい構成では、DataLoaderと呼ばれる仕組みが使われます。複数の個別の取得要求を一定のタイミングでまとめてバッチ処理し、同じデータへの重複した取得はキャッシュで避ける、という考え方です。
DataLoaderの公式リポジトリでは、あるGraphQLのクエリを素朴に処理すると13回のデータベースへの問い合わせが発生しうる例に対し、DataLoaderを使うことで4回まで削減できると紹介されています。件数が増えるほど、この差は開いていきます。
導入にあたっては、キャッシュの生存期間をどう管理するか、ライブラリへの依存がどの程度増えるかといった点をふまえた設計判断が求められます。バッチ処理を挟む分、処理の流れが一段追加される点も理解しておく必要があるでしょう。
キャッシュで再問い合わせ自体を避ける
同じ関連データが繰り返し参照される場合は、Redisなどのキャッシュ層にあらかじめ結果を保持しておき、データベースへの再問い合わせ自体を避ける方法もあります。クエリ回数を根本から減らせる一方、元データが更新された際にキャッシュをどう最新化するかという、別の設計課題を抱えることになります。
対策の選び方をどう判断するか
実装を依頼する立場では、どの方法を選ぶかという技術的な決定そのものよりも、「なぜその方法を選んだのか」を開発側に説明してもらえる状態を作ることが大切です。画面の利用頻度、関連データの更新頻度、想定件数といった条件を共有し、対策の妥当性を一緒に確認できる関係を築いておくと、後々の性能トラブルを防ぎやすくなります。
発注・レビューで確認すべき性能要件と受け入れ観点
N+1問題は実装の細部に起因するため、発注側が仕様書の段階でデータ量の前提を伝えていないと、開発側が少数のテストデータだけで動作確認を済ませてしまい、本番投入後に初めて問題が表面化する、という流れをたどりがちです。技術的な詳細を判断できなくても、要点を押さえておけば会話は十分に成立します。
仕様書に盛り込みたい性能要件
想定する表示件数やデータ増加のペース、画面ごとのレスポンスタイムの目標値は、可能な限り仕様書に数値で明記しておくとよいでしょう。「将来的に増える見込み」といった曖昧な表現ではなく、現時点で見積もれる範囲の具体的な件数を伝えることが、開発側の設計判断の材料になります。
データ量の前提が共有されていないと、開発側は「動作すること」を基準に設計を進めがちです。件数が少ない前提と、将来数千件に増える前提とでは、選ぶべき取得方法やキャッシュ設計そのものが変わってくるため、早い段階でのすり合わせが手戻りの防止につながります。
受け入れテストで確認したい4つの観点
- 本番相当のデータ件数を用意した状態で、主要画面の応答時間を計測しているか
- 1画面あたりに発行されるクエリの本数を、開発側から提示してもらえるか
- データが将来増えた場合の性能劣化について、開発側の見解を確認できるか
- クエリログやAPMのダッシュボードなど、性能を裏づける根拠を納品時に共有してもらえるか
これらを契約や受け入れ条件に含めておくことで、「動くことは確認したが、件数が増えたときの性能は未検証」という状態のまま納品を受け入れてしまうリスクを抑えられます。レビュー体制を誰がどの段階で担うかも、あわせて取り決めておくと後工程の手戻りを防ぎやすくなります。
開発ベンダーとの会話で使える確認の切り口
専門用語が分からなくても、「この画面で表示件数が増えたとき、データベースへの問い合わせ回数はどう変わりますか」という一問を投げかけるだけで、開発側の設計意識を確認できます。明確な回答が得られない場合は、性能面の検証がまだ十分でない可能性を疑う一つの目安になるでしょう。
まとめ
本稿では、N+1問題の仕組みと対策を整理しました。要点を3つに集約すると、第一に、N+1問題は一覧取得の1回に加えて要素ごとの追加クエリがN回発生し、件数に比例してクエリ数が増える性能問題である点です。第二に、原因の多くはORMの遅延ロードとループ処理の組み合わせにあり、コードの見た目だけでは気づきにくいという点でしょう。第三に、Eager Loading・JOIN・IN句・DataLoader・キャッシュといった対策にはそれぞれトレードオフがあり、画面やデータ構造に応じて選び分ける必要があります。
- N+1問題は、一覧取得1回+要素ごとの追加N回で、合計1+N回のクエリになる性能問題である。
- 件数が10件なら11回、1,000件なら1,001回というように、クエリ数は件数にほぼ比例して増える。
- ORMの遅延ロードとループ内でのデータアクセスの組み合わせが、主な発生源になっている。
- Eager Loading・JOIN・IN句・DataLoader・キャッシュにはそれぞれ得意な場面とトレードオフがある。
- 発注・レビューの段階では、想定データ件数と受け入れテストの条件を明確にしておくことが有効である。
よくある質問
件数が少なければN+1問題は放置しても大丈夫ですか。
件数が少ないうちは体感できるほどの遅延にならないことが多く、見過ごされがちです。ただしクエリ数は件数に比例して増えるため、データが本番規模に育った段階で応答の遅さやタイムアウトとして表面化する可能性があります。開発時から本番相当のデータ量で検証しておくことが欠かせません。
Eager Loadingを使えば常に速くなりますか。
必ずしも常に有利になるとは限りません。実際の画面表示で使わない関連データまで先読みしてしまうと、転送量やメモリ消費が増え、かえって遅くなる場合があります。画面ごとに本当に必要な関連データを見極めたうえで使い分けることが大切です。
JOINとIN句は、どちらを選べばよいですか。
一対多の関連を1回のクエリでまとめて取得したい場合はJOINが向いています。関連先のIDがあらかじめ分かっている場合は、IN句でのまとめ取得も選択肢でしょう。JOINは結果セットの行数が増えやすく、IN句は指定件数が多いと1回のクエリが重くなりやすいため、データの構造や件数に応じて選ぶ必要があります。どちらか一方に固定せず、画面ごとに適した方を開発側と相談しながら決めていくのが現実的です。
発注者が契約前に確認しておくべきことは何ですか。
想定する表示件数やデータ増加のペース、画面ごとのレスポンスタイムの目標値を仕様書に数値で明記しておくことが有効です。あわせて、本番相当のデータ量での応答時間測定や、1画面あたりのクエリ本数の提示を受け入れ条件に含めておくと、性能面のリスクを抑えられます。
著者:テレリモ総研編集部 鈴木 亮佑
ご不明な点はお問い合わせフォームからもご連絡いただけます。
出典
- Ruby on Rails Guides「Active Record Query Interface」(Eager Loading Associations / N + 1 Queries Problem)
- Django公式ドキュメント「QuerySet API reference」(select_related / prefetch_related)
- Hibernate ORM「User Guide」(Fetching)
- GraphQL Foundation「DataLoader」(バッチ処理とキャッシュによるデータ取得の最適化)