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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?