- 技術 💻
N+1問題とは?原因と見つけ方・解決方法【フレームワーク別】
N+1問題とは、一覧データを1回のクエリで取得したあと、各行に関連するデータを1件ずつ取得するために、さらにN回のクエリが発行されてしまう性能問題です。投稿100件の一覧で著者名を表示すると、「投稿の取得1回+著者の取得100回」で、合計101回のクエリが実行されます。
開発中のデータが少ない環境では気づきにくく、本番でデータが増えてから画面が急に遅くなるのが典型的なパターンです。この記事では、N+1問題が起きる仕組みから、見つけ方、Rails・Laravel・Django・JPA(Hibernate)・GraphQLそれぞれの解決方法、解決するときに起きやすい別の問題までを解説します。
N+1問題が起きる仕組み
投稿(posts)と著者(users)のテーブルで、投稿一覧に著者名を表示する例です。
# Rails の例
posts = Post.limit(100)
posts.each do |post|
puts post.user.name # ここで著者を1件ずつ取得している
end
実行されるSQLは、次のようになります。
SELECT * FROM posts LIMIT 100; -- 1回
SELECT * FROM users WHERE id = 1; -- ここから
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
... -- 100回
コードにはループと、関連データへのアクセスが書いてあるだけで、SQLは見えません。ORM(データベースをオブジェクトとして扱う仕組み)が、関連データに初めてアクセスした時点で自動的にSQLを発行する「遅延読み込み(Lazy Loading)」を行うため、意図せずにクエリが増えるのです。
なぜ遅くなるのか
1回1回のクエリは速くても、アプリケーションとデータベースの間では、クエリのたびに通信の往復が発生します。
| 1回の往復にかかる時間 | 101回のクエリ | 2回のクエリ(解決後) |
|---|---|---|
| 1ミリ秒(同じサーバー内など) | 約0.1秒 | 約0.002秒 |
| 5ミリ秒(別のサーバー・クラウド) | 約0.5秒 | 約0.01秒 |
一覧が1,000件になれば10倍、さらに「著者」と「カテゴリ」と「タグ」のように関連が増えるほど、クエリ数は掛け算で増えていきます。件数に比例して遅くなるため、データの少ない開発環境では問題が見えないのです。
N+1問題の見つけ方
1. SQLログを見る
最も基本的な方法です。同じ形のSQLが、IDだけ変えて何度も並んでいたら、N+1問題を疑います。
| フレームワーク | SQLログの出し方 |
|---|---|
| Rails | 開発環境ではログ(log/development.log)に標準で出力される |
| Laravel | DB::listen() でクエリを記録、またはLaravel Debugbar |
| Django | django-debug-toolbar、または connection.queries |
| Spring(JPA) | spring.jpa.show-sql=true、Hibernateの統計情報(hibernate.generate_statistics) |
2. 検出ツール・仕組みを使う
| フレームワーク | 検出方法 |
|---|---|
| Rails | Bullet、Prosopite(gem)/ strict_loading で遅延読み込みを例外にする |
| Laravel | Model::preventLazyLoading() で遅延読み込みを例外にする |
| Django | テストで assertNumQueries を使い、クエリ数を固定する |
| Spring(JPA) | Hibernateの統計でクエリ数を確認、テストでクエリ数を検証するライブラリを使う |
| 共通 | APM(アプリケーション性能監視)ツールの、N+1検出機能 |
開発環境で「遅延読み込みをしたら例外にする」設定を入れておくと、N+1問題をコードレビューより前に見つけられます。
// Laravel:AppServiceProvider の boot() に書く(本番以外で有効)
Model::preventLazyLoading(! app()->isProduction());
3. テストでクエリ数を固定する
# Django
def test_post_list(self):
with self.assertNumQueries(2):
self.client.get("/posts/")
一覧画面のテストでクエリ数を固定しておくと、後から誰かがN+1問題を持ち込んだときに、テストが失敗して気づけます。
解決方法の基本:JOINか、IN句か
N+1問題の解決方法は、大きく2つです。
▶ 方法A:JOINで1回にまとめる
SELECT posts.*, users.*
FROM posts
JOIN users ON users.id = posts.user_id
LIMIT 100;
▶ 方法B:IN句で2回にまとめる
SELECT * FROM posts LIMIT 100;
SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100);
| JOIN(1回) | IN句(2回) | |
|---|---|---|
| クエリ数 | 1回 | 関連1つにつき1回 |
| 向いている関連 | 多対1(投稿→著者)、1対1 | 1対多(投稿→コメント)、多対多 |
| 注意点 | 1対多をJOINすると、行が掛け算で増える | IN句に入る件数が非常に多いと、DBの上限に注意 |
| 関連先の条件で絞り込み・並べ替え | できる | できない |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「N+1問題とは?原因と見つけ方・解決方法【フレームワーク別】」
「1件に対して1件」の関連はJOIN、「1件に対して複数件」の関連はIN句が基本の使い分けです。各フレームワークには、この2つを選んで使う仕組みが用意されています。
フレームワーク別の解決方法
Rails(Active Record)
Post.includes(:user).limit(100) # 自動で判断
Post.preload(:user).limit(100) # 必ずIN句(2回)
Post.eager_load(:user).limit(100) # 必ずJOIN(1回)
| メソッド | 取得方法 | 使いどころ |
|---|---|---|
includes |
通常はIN句。関連先の条件で絞り込むとJOINに切り替わる | 迷ったらこれ |
preload |
IN句 | 1対多・多対多の関連 |
eager_load |
JOIN(LEFT OUTER JOIN) | 関連先の列で絞り込み・並べ替えをしたい |
Laravel(Eloquent)
$posts = Post::with('user')->limit(100)->get(); // 取得時にまとめて読む
$posts->load('comments'); // 取得後にまとめて読む
$posts = Post::with(['user', 'comments.author'])->get(); // 複数・入れ子の関連
Eloquentの with は、IN句で関連データを取得します。取得済みのコレクションに後から関連を読み込む load も、ループの前に1回呼べばN+1問題になりません。
Django
Post.objects.select_related("user") # JOIN(外部キー・1対1)
Post.objects.prefetch_related("comments") # IN句(逆参照・多対多)
| メソッド | 取得方法 | 使える関連 |
|---|---|---|
select_related |
JOIN | ForeignKey・OneToOneField(1件に対して1件) |
prefetch_related |
IN句 | 逆参照・ManyToManyField(1件に対して複数件) |
prefetch_related に Prefetch オブジェクトを渡すと、関連データ側の絞り込みや並べ替えも指定できます。
JPA(Hibernate)・Spring Data JPA
// JPQLの JOIN FETCH
@Query("SELECT p FROM Post p JOIN FETCH p.user")
List<Post> findAllWithUser();
// @EntityGraph
@EntityGraph(attributePaths = {"user"})
List<Post> findAll();
1対多の関連には、バッチ取得が有効です。
# application.properties:遅延読み込み時に、最大100件ずつIN句でまとめて取得する
spring.jpa.properties.hibernate.default_batch_fetch_size=100
FetchType.EAGER にしても、N+1問題の解決にはなりません。JPQLで一覧を取得した場合、EAGERの関連は、結局1件ずつ追加のクエリで取得されることがあります。関連は基本的にLAZYにし、必要な画面でJOIN FETCHや@EntityGraphを使うのが定石です。
GraphQL
GraphQLは、フィールドごとにデータを取得する仕組みのため、リゾルバーを素直に書くとN+1問題が起きやすい構造です。
// DataLoader:同じタイミングで要求されたIDをまとめ、1回のIN句で取得する
const userLoader = new DataLoader(async (ids) => {
const users = await db.users.findMany({ where: { id: { in: ids } } });
return ids.map((id) => users.find((u) => u.id === id));
});
// リゾルバー
Post: { user: (post) => userLoader.load(post.userId) }
DataLoaderは、リクエストごとに作り直すのが原則です。複数のリクエストで使い回すと、別のユーザーのデータがキャッシュから返る原因になります。
解決するときに起きやすい、別の問題
1対多をJOINすると、行が増える
投稿100件に、それぞれコメントが50件あると、JOINの結果は5,000行になります。投稿の列も5,000回繰り返して転送されるため、クエリ数は1回でも、転送量とメモリ使用量が大きくなります。1対多・多対多の関連には、IN句(preload・prefetch_related・with・バッチ取得)を使います。
JOINとページングの組み合わせ
1対多をJOINした状態で LIMIT 20 をかけると、「投稿20件」ではなく「JOIN後の20行」になります。Hibernateでは、コレクションをJOIN FETCHしてページングすると、全件をメモリに読み込んでからページングする旨の警告が出ます。ページングが必要な一覧では、まず親のIDだけをページングで取得し、関連はIN句で取得する2段階の方式にします。
複数の1対多を同時にJOINする
「コメント」と「タグ」のように、1対多の関連を2つ同時にJOINすると、行が掛け算(コメント数×タグ数)になります。Hibernateでは、このようなJOIN FETCHが例外(MultipleBagFetchException)になる場合もあります。関連ごとにIN句で取得する方が安全です。
必要以上に読み込む
N+1問題を恐れて、使わない関連まで常に読み込むと、今度は無駄なクエリと転送が発生します。関連の読み込みは、その画面・そのAPIで使うものだけを指定します。
データベース以外のN+1問題
N+1問題は、データベースに限りません。ループの中で、外部のAPIや別のマイクロサービスを1件ずつ呼び出す場合も同じ構造です。
// N+1:注文100件に対して、在庫APIを100回呼ぶ
for (const order of orders) {
order.stock = await stockApi.get(order.productId);
}
// 改善:まとめて取得するAPIを1回呼ぶ
const stocks = await stockApi.getMany(orders.map((o) => o.productId));
APIの呼び出しは、データベースよりも1回の往復に時間がかかることが多いため、影響はさらに大きくなります。まとめて取得できるAPI(バルクAPI)を用意するか、並列で呼び出す、キャッシュを使うなどの対策を検討します。
N+1問題を許容してよい場合
N+1問題は、常に解決しなければならないわけではありません。
- Nが小さく、増えないことが決まっている(管理画面の設定項目など)
- 関連データがキャッシュから返る(同じ著者が何度も出てくる場合など)
- 詳細画面のように、対象が1件だけ
ただし「今は件数が少ない」は、将来変わる可能性があります。許容する場合も、なぜ許容したのかをコメントに残しておくと、後から見直しやすくなります。
AIが書いたコードでも起きやすい
AIのコーディング支援で生成されたコードも、ループの中で関連データにアクセスする、素直な書き方になりやすく、N+1問題が含まれていることがあります。動作としては正しいため、テストでは見つかりません。
一覧を返す処理のレビューでは、「このループの中でクエリやAPI呼び出しが発生していないか」を確認するのが効果的です。前述の「遅延読み込みを例外にする設定」や「クエリ数を固定するテスト」は、AIが書いたコードの品質を保つ仕組みとしても役立ちます。
よくある質問
Q. N+1問題はどのくらいの件数から問題になりますか? A. 環境によりますが、1回の往復が数ミリ秒なら、数十件でも体感できる遅さになることがあります。件数が増える可能性がある一覧では、件数に関係なく対策しておくのが基本です。
Q. インデックスを張れば解決しますか? A. 1回1回のクエリは速くなりますが、クエリの回数は変わりません。N+1問題の本質は回数なので、まとめて取得する対策が必要です。
Q. 生のSQLを書いていれば、N+1問題は起きませんか? A. ループの中でSQLを実行すれば、同じ問題が起きます。ORMかどうかではなく、「ループの中で問い合わせをしているか」が判断の基準です。
Q. 「1+N問題」と同じですか? A. 同じ問題を指します。最初の1回と、その後のN回という順序から「1+N」と呼ばれることもあります。
まとめ
- N+1問題は、一覧の取得1回+関連の取得N回でクエリが増える問題。件数に比例して遅くなる
- 原因は、ORMの遅延読み込みと、ループの中での関連アクセス
- 見つけ方は、SQLログ・検出ツール・遅延読み込みを例外にする設定・クエリ数を固定するテスト
- 解決は、1対1・多対1はJOIN、1対多・多対多はIN句
- Railsは
includes・preload・eager_load、Laravelはwith、Djangoはselect_related・prefetch_related、JPAはJOIN FETCH・@EntityGraph・バッチ取得、GraphQLは DataLoader - 1対多のJOINは行の増加とページングに注意。API呼び出しのループも同じ構造
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「N+1問題とは?原因と見つけ方・解決方法【フレームワーク別】」
株式会社RJCでは、性能の問題を「動いているから大丈夫」で終わらせず、仕組みから理解することを大切にしています。こうした知識を仲間と共有しながら、一緒に成長を楽しんでいます。
株式会社RJC ― SI事業・SES事業・AI駆動開発。未経験からエンジニアに育てるのが得意な会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。