はじめに
Entity Framework Core は LINQ で直感的にデータアクセスを書けるのが魅力ですが、書き方次第で簡単にN+1問題を踏み抜きます。
ローカル環境ではデータ件数が少なく気づかず、本番でデータが増えた途端にレスポンスが遅くなる、というのはEF Core案件でよくある話です。
この記事では、N+1問題が発生するパターンとその改善Tipsを、実務で役立つ視点でまとめます。コピペで試せるサンプルを多数用意しています。
N+1問題の基本
仕組みの要点
- N+1問題 とは、1件を取得するクエリの後に、関連データを取得するクエリが件数分(N回)発行されてしまう現象
- EF Core では 遅延読み込み(Lazy Loading) や、ループ内でのナビゲーションプロパティへのアクセスが主な原因
- クエリ回数が増えるほどDBラウンドトリップのコストが積み重なり、レスポンスタイムが線形に悪化する
発生例
var blogs = context.Blogs.ToList(); // 1回
foreach (var blog in blogs)
{
// Lazy Loadingが有効だと、ここでBlogの件数分クエリが発行される
Console.WriteLine(blog.Posts.Count);
}
パターン1 遅延読み込み(Lazy Loading)によるN+1
ポイント
- 仮想プロパティ(
virtual)にナビゲーションプロパティを定義していると、Lazy Loadingが有効な構成でアクセス時にクエリが発行される - ループの中でナビゲーションプロパティに触れると、件数分のクエリが飛ぶ
サンプル
// ❌ N+1が発生する
public class Blog
{
public int Id { get; set; }
public string Name { get; set; } = "";
public virtual List<Post> Posts { get; set; } = new();
}
var blogs = context.Blogs.ToList();
foreach (var blog in blogs)
{
Console.WriteLine($"{blog.Name}: {blog.Posts.Count}"); // ここで毎回クエリ発行
}
Blogが100件あれば、Postsへのアクセスのたびにクエリが発行され、合計101回のクエリが実行されます。
// ⭕ Includeで事前に読み込む
var blogs = context.Blogs
.Include(b => b.Posts)
.ToList();
foreach (var blog in blogs)
{
Console.WriteLine($"{blog.Name}: {blog.Posts.Count}"); // 追加クエリなし
}
Include で関連データを事前にJOINして取得することで、クエリ回数を1回に抑えられます。
パターン2 ループ内で都度クエリを発行してしまう
ポイント
- ループの中で
context.XXX.Where(...)のようなクエリを都度実行しない - 必要なIDを先にまとめて取得し、1回のクエリで関連データをすべて取得する
サンプル
// ❌ ループ内でクエリを都度発行
var orders = context.Orders.ToList();
foreach (var order in orders)
{
var customer = context.Customers
.FirstOrDefault(c => c.Id == order.CustomerId); // 毎回クエリ
Console.WriteLine(customer?.Name);
}
// ⭕ IDをまとめて取得し、1回のクエリでJOIN
var orders = context.Orders
.Include(o => o.Customer)
.ToList();
foreach (var order in orders)
{
Console.WriteLine(order.Customer?.Name);
}
「ループの中に context が出てきたら要注意」という感覚を持っておくと、この手のバグに気づきやすくなります。
パターン3 Includeの多用によるカーテシアン積の爆発
ポイント
- 複数のコレクションナビゲーションを同時に
Includeすると、結果セットが掛け算的に膨れ上がる(カーテシアン積) - 対策として
AsSplitQuery()でクエリを分割する
サンプル
// ❌ 複数コレクションを同時Includeすると行数が爆発しやすい
var blogs = context.Blogs
.Include(b => b.Posts)
.Include(b => b.Authors)
.ToList();
Blogに10件のPostと10件のAuthorが紐づいていると、JOIN結果は最大100行になり、同じBlogのデータが重複して返ってきます。
// ⭕ AsSplitQueryでクエリを分割する
var blogs = context.Blogs
.Include(b => b.Posts)
.Include(b => b.Authors)
.AsSplitQuery()
.ToList();
AsSplitQuery() を使うと、それぞれのIncludeが別クエリとして発行されるため、カーテシアン積を避けられます。ただし複数クエリになる分、クエリ間の一貫性(別トランザクションでの更新が挟まる可能性)にはトレードオフがある点に注意してください。
パターン4 必要な列だけを取得せず、エンティティ全体を読み込む
ポイント
- 一覧表示など、一部の列しか使わない場面でエンティティ全体を取得するのは無駄が多い
-
Selectで必要な列だけに絞り込む(射影)
サンプル
// ❌ 一覧表示なのにエンティティ全体を取得
var blogs = context.Blogs
.Include(b => b.Posts)
.ToList();
var summaries = blogs.Select(b => new { b.Name, PostCount = b.Posts.Count });
// ⭕ 必要な列だけをDB側で計算・取得する
var summaries = context.Blogs
.Select(b => new
{
b.Name,
PostCount = b.Posts.Count
})
.ToList();
射影を使うとDB側で COUNT などの集計が行われるため、転送量とメモリ使用量の両方を削減できます。関連データを全件読み込む必要がない場面では、まず射影を検討しましょう。
パターン5 追跡(Change Tracking)が不要な読み取り専用クエリ
ポイント
- 更新しない読み取り専用のクエリには
AsNoTracking()を付ける - Change Trackerへの登録コストを省略でき、パフォーマンスが向上する
サンプル
// ❌ 参照するだけなのに追跡ありで取得
var blogs = context.Blogs.ToList();
// ⭕ 読み取り専用ならAsNoTrackingを付ける
var blogs = context.Blogs
.AsNoTracking()
.ToList();
一覧表示APIやレポート生成など、取得したエンティティを更新しないシナリオでは AsNoTracking() を既定にしておくと、メモリ使用量とクエリ実行時間の両方にメリットがあります。
よくある落とし穴と回避策
-
Lazy Loadingによる暗黙のN+1
- 問題:ループ内でナビゲーションプロパティにアクセスするたびにクエリが発行される。
- 回避:
Includeで事前に関連データを読み込む。
-
ループ内での都度クエリ
- 問題:ループの中で
contextへのクエリを都度実行し、DBラウンドトリップが件数分発生する。 - 回避:必要なデータをまとめて1回のクエリで取得する。
- 問題:ループの中で
-
複数Includeによるカーテシアン積
- 問題:複数のコレクションを同時Includeすると結果行数が掛け算的に膨れる。
- 回避:
AsSplitQuery()でクエリを分割する。
-
不要な全列取得
- 問題:一覧表示など一部の列しか使わない場面でエンティティ全体を取得している。
- 回避:
Selectで必要な列だけに射影する。
-
不要な追跡コスト
- 問題:読み取り専用なのにChange Trackingが有効なままになっている。
- 回避:
AsNoTracking()を付ける。
実務チェックリスト
- ループの中に
context.XXXや未Includeのナビゲーションプロパティへのアクセスがないか - 複数のコレクションを同時にIncludeしていないか、していれば
AsSplitQuery()を検討したか - 一覧表示・集計系のクエリで
Selectによる射影を使っているか - 更新しないクエリに
AsNoTracking()を付けているか - Lazy Loadingを有効にしている場合、意図せずアクセスされていないか
テストとデバッグのコツ
-
発行されたSQLをログで確認する
-
LogToでSQLをコンソール出力し、クエリ回数と内容を目視で確認します。
-
using var context = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.Options);
-
クエリ発行回数をテストで検証する
-
IInterceptorや簡易的なカウンタで、想定回数を超えるクエリが発行されていないかをテストに組み込みます。
-
public class QueryCountInterceptor : DbCommandInterceptor
{
public int Count { get; private set; }
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
Count++;
return base.ReaderExecuting(command, eventData, result);
}
}
まとめ
- N+1問題は、ループ内での暗黙的なクエリ発行が主な原因です。まずは
Includeで関連データをまとめて取得することから始めましょう。 - 複数コレクションを同時にIncludeする場合は
AsSplitQuery()でカーテシアン積を回避できます。 - 一覧表示や読み取り専用のクエリでは
Selectによる射影とAsNoTracking()を組み合わせると、パフォーマンスを大きく改善できます。 - 発行されたSQLをログで確認する習慣をつけておくと、N+1問題に早い段階で気づけます。
おわりに
この記事がEF Coreのパフォーマンス改善に役立てば嬉しいです。
よければストック・フォローお願いします!