EF6 + LINQ で N+1 問題を踏まない3つの書き方

みなさんこんにちは!ヒロポンです!

X眺めてると、EF6でLINQ書いたら裏でSQLが1000本飛んでた、本番で朝までエンドポイント詰まってた、みたいな話。。マジで多い。

ローカルDBは50件しかないから12msで終わってた。でも、、本番で5万件+同時500ユーザーになった瞬間にエンドポイントが4秒に化ける、ってやつ。同業から「EFのInclude漏れでチーム全員ハマった」って聞いたこともあります。

正直N+1って開発中はほぼ顔出さないんですよね。マジで

で、本番で初めてゴロっと出てくる。私の現場でも危うく踏みかけた経験があります。

この記事は業務系のWinForms / ASP.NET MVC 5でEF6使ってる人向けです。

N+1を踏まないための3つの書き方をまとめました。コピペで動くやつ!!!

💡 LINQ の基本的な書き方は別記事 C# Linq で Null を回避する書き方とパフォーマンス(業務SEのコピペで動くやつ) でまとめてます。今回は EF6 と組み合わせた時に SQL が爆発するパターンに絞った話です。

目次

忙しい人向けのまとめ

  • EF6 + LINQ の N+1 は、ローカル12ms / 本番4秒の典型パターン。Include 忘れ + foreach 内 navigation アクセスが主犯
  • 防ぐ3パターン: (a) Include で eager loading / (b) ToList で materialize / (c) Select 射影で1クエリに
  • OLTP の高頻度クエリで N+1 を踏むと、SQL Server CPU が天井に張り付く構図になる
  • 動作確認は .NET 9 SDK + LINQ-to-Objects で N+1 のクエリ発行回数を再現 (実 EF6 ランタイムは Windows + VS2019 推奨)
  • 復旧して終わりじゃなく、業務側に頭下げに行く時間 (信頼回復コスト) が技術復旧と同じくらい乗っかる

以上!

動作確認メモ: 今回のサンプルコードは .NET 9 SDK + LINQ-to-Objects (List<T> ベース) で N+1 のクエリ発行回数だけを再現しています。実 EF6 (.NET Framework 4.7.2 / SQL Server 2019 / VS2019) の DbContext / Include / Database.Log 周りは Docker container では検証範囲外なので、本番投入前に Windows 環境で SQL Profiler か db.Database.Log = Console.Write; で発行 SQL を確認するのをおすすめします。

結論: N+1 を踏まないための3つの軸

最初に身も蓋もない結論から行きます。

EF6 + LINQ で N+1 を踏まないための軸は3つ。これはマジで覚えて!

  1. 関連エンティティが要るなら最初に Include する — foreach 内で navigation property を叩かない
  2. IQueryable を foreach する前に ToList() で確定させる — 遅延実行のまま回さない
  3. 画面に出す列だけ欲しいなら Select 射影 — Include より転送量で勝てる場面が多い

ぶっちゃけ業務系の画面コードでこの3つを意識してるかどうかでN+1を踏む確率は8割落ちます。

逆にどれか1個でも抜けてたら開発中は気付かず本番でSQL1000本飛ばす側に倒れます。

3パターンを「データ構造 / 必要な列数 / 関連の深さ」の軸でまとめるとこんな感じになります。

EF6 + LINQ で N+1 を防ぐ3パターン比較 — Include / ToList / Select 射影を、関連の深さ・転送列数・1次キャッシュ・SQL 発行数で観点別整理

本命用途」行を見ると一目瞭然。

編集画面みたいに関連エンティティを丸ごと触りたい時はInclude、集計や一覧画面で画面に出す列だけ欲しい時はSelect、ループ内で複数回叩く中間処理ならToListで先にmaterialize、という棲み分けになります。

なぜ「LINQ 書いたら SQL が爆発する」のか

ここでEF6+LINQの中身を1段だけ覗いてみましょう。

EF6のLINQは遅延実行(deferred execution)です。

db.Customers.Where(...) と書いた時点ではまだSQLは飛ばない。

でもToList() / foreach / Count() などの enumeration が走った瞬間に初めてSQL Serverにクエリが飛ぶ。

ここが N+1 の温床になる構造。

// 開発時はこれで動いた
var customers = db.Customers.ToList();
foreach (var c in customers) {
    // 顧客ごとに c.Orders を見る ← ここで1ループに1 SQL 飛ぶ
    Console.WriteLine($"{c.Name}: 注文 {c.Orders.Count()} 件");
}

customers.ToList() で顧客一覧の SELECTが1本飛ぶ。

まあまあここまでは普通。想定内です。

問題はforeachの中。c.Orders.Count() を叩いた瞬間、、、、顧客ごとに一気にSELECT * FROM Orders WHERE CustomerId = ? が追加で飛ぶ

これがN+1の正体。

顧客が1000人なら、SQL は 1 (顧客一覧) + 1000 (各顧客の注文) = 1001本

ローカルDBで50件しかなければ全然気付かない。いい感じや。てなる。

でも、、本番で5万件になった瞬間エンドポイントが4秒に化ける。これがN+1の怖さ

でもさ、、普通にログ見たら気付くでしょ?って思うでしょ?

ところがEF6のデフォルトはSQLログがオフ。

db.Database.Log = Console.Write; を入れるまで、発行 SQL は完全に裏で隠れてる。これがマジでやばい。大きな落とし穴

3パターンのコード比較 (動作確認つき)

ここから実際のコードと動作確認結果。

動作確認は .NET 9 SDK + LINQ-to-Objects で N+1 の発行 SQL 回数を再現する形にしました。実 EF6 でも同じ構造で SQL が飛びます。

パターン1: 罠 (Include 忘れ + foreach 内 navigation)

class Customer { public int Id; public string Name = ""; }
class Order { public int Id; public int CustomerId; public int Amount; }

var customers = new List<Customer> {
    new() { Id = 1, Name = "A社" },
    new() { Id = 2, Name = "B社" },
    new() { Id = 3, Name = "C社" },
};
var orders = new List<Order> {
    new() { Id = 1, CustomerId = 1, Amount = 1000 },
    new() { Id = 2, CustomerId = 1, Amount = 2000 },
    new() { Id = 3, CustomerId = 2, Amount = 3000 },
};

int sqlCount = 0;
List<Order> GetOrdersByCustomer(int cid) {
    sqlCount++;
    Console.WriteLine($"[SQL #{sqlCount}] SELECT * FROM Orders WHERE CustomerId = {cid}");
    return orders.Where(o => o.CustomerId == cid).ToList();
}

Console.WriteLine("[SQL #0] SELECT * FROM Customers");
foreach (var c in customers) {
    var cOrders = GetOrdersByCustomer(c.Id);
    Console.WriteLine($"  {c.Name}: 注文 {cOrders.Count} 件");
}
Console.WriteLine($"発行 SQL 合計: {sqlCount + 1} 本 (顧客 {customers.Count} 件 + 1)");

実行結果 (.NET 9 SDK):

Pattern 1 N+1 再現 — 顧客 N 件で SQL が N+1 本発行される証跡

顧客3人なら4本、1000人なら1001本。

顧客数に比例してSQLが増えるのがログから一発で読めます。

これが本番5万件で500ユーザー同時にアクセスしてきたら、、、、瞬殺でSQL ServerがCPU天井張り付き。

実 EF6 だとこんなコードになる。

// EF6 (.NET Fx 4.7.2)・これが本番で爆発するやつ
using var db = new MyDbContext();
var customers = db.Customers.ToList();   // SQL #1: SELECT * FROM Customers
foreach (var c in customers) {
    var count = c.Orders.Count();        // ループのたびに SQL 追加発行
    Console.WriteLine($"{c.Name}: {count}件");
}

ぱっと見て普通に動きそうに見えでしょ?でもこれがいちばんタチ悪い。マジでタチ悪い。

パターン2: Include で eager loading (1クエリで関連ごと取得)

EF6だとIncludeでnavigation propertyを最初にSQL側でJOINしておく。

// EF6 (.NET Fx 4.7.2)・Include で関連を1クエリにまとめる
using var db = new MyDbContext();
var customers = db.Customers
    .Include(c => c.Orders)             // JOIN Orders を含める
    .ToList();                           // SQL #1: SELECT ... FROM Customers c LEFT JOIN Orders o ...
foreach (var c in customers) {
    var count = c.Orders.Count();       // メモリ上のコレクション参照・SQL 追加なし
    Console.WriteLine($"{c.Name}: {count}件");
}

.NET 9 SDK + LINQ-to-Objects で同じ構造を再現するとこう。

Console.WriteLine("[SQL #1] SELECT c.*, o.* FROM Customers c LEFT JOIN Orders o ON c.Id = o.CustomerId");
var customersWithOrders = customers
    .GroupJoin(orders,
        c => c.Id,
        o => o.CustomerId,
        (c, os) => new { Customer = c, Orders = os.ToList() })
    .ToList();

foreach (var x in customersWithOrders) {
    Console.WriteLine($"  {x.Customer.Name}: 注文 {x.Orders.Count} 件");
}
Console.WriteLine($"発行 SQL 合計: 1 本 (顧客の数によらず固定)");

実行結果 (.NET 9 SDK):

Pattern 2 Include eager loading 再現 — 顧客 N 件でも SQL は1本固定

顧客が3人でも1000人でもSQLは1本。

これがIncludeの威力。いい感じにJOIN が一発で済みます。

注意点としてはInclude は関連エンティティを丸ごと持ってくる
なのでOrdersに列が30個あったら全部転送される。不要なデータも増えるってこと。

集計しか要らないなら次のSelectの方が転送量で勝てる場面が多いです。

パターン3: Select 射影で1クエリ集計 (画面に出す列だけ)

集計や一覧画面みたいに、画面に出す列だけ欲しい場面の最強パターン。

// EF6 (.NET Fx 4.7.2)・Select で必要な列だけ射影
using var db = new MyDbContext();
var summary = db.Customers
    .Select(c => new {
        c.Name,
        OrderCount = c.Orders.Count(),     // SQL 内で COUNT(*) に翻訳される
        TotalAmount = c.Orders.Sum(o => (int?)o.Amount) ?? 0
    })
    .ToList();                              // SQL #1: SELECT c.Name, (SELECT COUNT(*) ...), (SELECT SUM(...) ...) FROM Customers c
foreach (var x in summary) {
    Console.WriteLine($"{x.Name}: {x.OrderCount}件 合計{x.TotalAmount}円");
}

.NET 9 SDK 再現コードはこう。

Console.WriteLine("[SQL #1] SELECT c.Name, (SELECT COUNT(*) FROM Orders o WHERE o.CustomerId = c.Id) AS OrderCount, (SELECT ISNULL(SUM(o.Amount),0) FROM Orders o WHERE o.CustomerId = c.Id) AS TotalAmount FROM Customers c");
var summary = customers
    .Select(c => new {
        c.Name,
        OrderCount = orders.Count(o => o.CustomerId == c.Id),
        TotalAmount = orders.Where(o => o.CustomerId == c.Id).Sum(o => o.Amount)
    })
    .ToList();

foreach (var x in summary) {
    Console.WriteLine($"  {x.Name}: 注文 {x.OrderCount} 件 合計 {x.TotalAmount} 円");
}
Console.WriteLine("発行 SQL 合計: 1 本 (集計済み・転送量も最小)");

実行結果 (.NET 9 SDK):

Pattern 3 Select 射影 再現 — 顧客 N 件で集計も1 SQL で完結

SQL1本、転送列も最小、集計済み。一覧画面で「顧客名 + 注文件数 + 合計金額だけ表示」みたいな場面ならこれがいちばん速い。

EF6がCOUNT/SUMをSQL側に押し込んでくれるのでこんな感じでメモリ転送量も抑えられます!!

ハマりポイント: LINQ 遅延実行と「複数列挙」の罠

3パターンは押さえましたが、、、欲張りなあなたのためにもう1個だけ罠を共有しておきます。

LINQにはdeferred execution (遅延実行)multiple enumeration (複数列挙)という性質がある。

これがN+1の親戚として現場で踏まれる。

// これが地雷
IQueryable<Order> recentOrders = db.Orders.Where(o => o.OrderDate > DateTime.Now.AddDays(-7));

var count = recentOrders.Count();          // SQL #1: SELECT COUNT(*) ...
var first = recentOrders.FirstOrDefault(); // SQL #2: SELECT TOP(1) ...
foreach (var o in recentOrders) {          // SQL #3: SELECT ... (全件)
    // 処理
}
// 同じ recentOrders を3回叩いた → SQL 3本飛んでる

IQueryableのまま持ち回ると、enumerationするたびにSQLが飛びます。

recentOrders.Count()recentOrders.FirstOrDefault()foreach でそれぞれ別 SQL になる構図。

var list = recentOrders.ToList(); で1回 materialize しておけば、以降の list.Count() / list.FirstOrDefault() / foreach は全部メモリ上の操作。SQL は飛びません。

業務系の業務ロジックって1つのクエリ結果を 件数表示 / 1件目だけ別画面 / 全件 foreach みたいに何度も使い回す場面が多いんですよね。

ここでToList()忘れると見た目は同じコードなのにSQLが3倍飛ぶ。

ぶっちゃけ。。N+1 と「複数列挙」を両方踏むとSQLが顧客数×3本まで膨らみます。

ここに高頻度アクセスが乗ったら即SQL ServerのCPUが天井に張り付く感じ。絶対にやらないで。。

チェック項目 確認方法
Include 漏れ foreach 内で navigation property を叩いてないか目視
複数列挙 IQueryable 型のまま2回以上使ってないか grep
遅延実行の暴発 db.Database.Log = Console.Write; で発行 SQL を眺める
プロファイラ SQL Server Profiler で同じ SELECT が連続して並んでないか

「LINQ の見た目」と「実 SQL の本数」は別物、というのを腹落ちさせるかどうかで、N+1 を踏む確率がだいぶ変わってきます。

私の物流系基幹での失敗談

ここはからは現場メモ

数年前、物流系の基幹でピッキング進捗一覧画面を作りました。

EF6+ASP.NET MVC 5、.NET Framework 4.7.2 / VS2019の構成。顧客マスタが2億レコード、注文テーブルが日次で大量INSERTされる構造。

最初の実装は普通に LINQ で書いたんですよ。
db.Customers.Where(...).ToList() して、foreach で c.Orders.Count() を回す。ローカル DB で開発してた時は 50件で 12ms、普通にサクサク動いてました。「これで完了!」って気分でテスト環境にデプロイ。

ところが本番に近いステージング (顧客5万件) に乗せた瞬間、エンドポイントが4秒。「ん??画面遅すぎ??」って血の気が引きました。

何が起きてたかというと:

  • db.Customers.Where(...).ToList() で顧客一覧 SELECT が1本
  • foreach 内で各顧客の c.Orders.Count() を叩いた → 顧客5万件ぶん 5万本の追加 SQL
  • SQL Server の Profiler 見たら、同じ SELECT COUNT(*) FROM Orders WHERE CustomerId = ? が延々と並んでた
  • それが本番想定の同時500ユーザーで掛け算されると、SQL Server CPU は天井

慌ててInclude(c => c.Orders)入れて1クエリにJOINしたら、エンドポイントは 180msに落ち着いた。。。マジで良かった。

さらに「画面に出すのは件数だけや」と気付いてSelect射影に直したら 50ms

技術的な復旧は半日で済んだんですよね。めでたしめでたし。。

となるわけなく。問題はそこから。。

ステージング遅延でリリース日が1日後ろ倒しになって業務側に頭下げに行く時間がついて回りました。技術復旧時間と同じくらい信頼回復のほうにエネルギー使った印象。

「テストで気付かなかった私の落ち度」を朝礼で説明しに行ったやつです。

今では普通にローカルでも顧客1000件くらいでテストしとけって話やんって思う。

でもさ、当時は「ローカルで動いた = 本番でも動く」という思い込みが完全に頭にあった。ありました。マジで未熟。

N+1の本質はデータ量に比例してSQLが増えるから開発中の少量データでは絶対に顔出さない罠なんですよね。

教訓は2個。ローカルでもN+1検出ログ (db.Database.Log) を必ず仕込むこと!!!

そして画面コードを書く時は「LINQの見た目」じゃなく「実SQLの本数」で見る

まとめ

EF6+LINQでN+1を踏まない3パターンの話でした。業務系のWinForms/ASP.NET MVC 5でEF6使ってると、いちばん踏みやすい罠です。

軸はシンプル。

  • 関連エンティティを丸ごと触りたい → Include で eager loading
  • 画面に出す列だけ欲しい → Select 射影で1クエリに
  • ループ内で複数回叩く中間処理 → ToList() で先に materialize

困ったら db.Database.Log = Console.Write; で発行 SQL を眺めに行く。結局これがいちばん近道です。ASP.NET MVC 5 で使える ORM 3択 — EF6 / Dapper / ADO.NET の業務SE 視点比較 で書いた「EF6 が薄くなる時の Dapper への逃げ道」も合わせて読むと、ORM 全体での判断軸が立ってきます。

よくある質問

Q1. EF6 で N+1 が起きてるか、開発中に気付く方法はありますか?

SQL Server Profiler か、EF6 の db.Database.Log = Console.Write; で発行 SQL をログに出します。同じ SELECT が顧客数ぶん繰り返し並んでたら、ほぼ N+1 です。開発中にローカルログを眺めるクセを付けると、本番で踏む確率が落ちます。

Q2. Include と Select どちらを優先すべき?

画面に出す列だけ欲しいなら Select 射影が最強です。Include は関連エンティティを丸ごと持ってくるので、列が多いと無駄な転送が増えます。集計だけなら Select、編集画面のように関連ごとに行を保持したいなら Include、という棲み分けですね。

Q3. LINQ の遅延実行で N+1 になる典型パターンは?

IQueryableToList() せずに foreach に渡し、ループ内で .Where() や navigation property を叩くケースです。1ループにつき1 SQL が飛ぶ構造になります。var list = query.ToList(); で先に materialize しておくのが鉄則。

Q4. EF6 の Find と FirstOrDefault、N+1 視点で違いは?

Find は1次キャッシュ (Identity Map) を見るので、同じ PK ならクエリが飛びません。FirstOrDefault は毎回 SQL を発行します。ループ内で PK 引きするなら Find の方が安全。とはいえ、ループ内 PK 引き自体が N+1 の構造なので、本来は最初に Include して全部メモリに乗せる方が筋がいいです。

Q5. .NET Framework 4.7.2 で EF6 のまま N+1 対策できますか?

できます。Include / Select / ToList は EF6 (.NET Fx 4.x) でも全部使えます。EF Core 固有機能 (Split Query 等) は無いので、Include の使い方で潰す形になります。業務系の現場で .NET Fx 4.7.2 から動かない時の現実解です。

関連記事

以上!

同じ罠でハマってる人いたら、どんどんシェア待ってるぜ!!


この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次