# Entity Framework Core:2026年のパフォーマンス最適化とベストプラクティス > EF Core 10のパフォーマンス最適化を解説。AsNoTracking、コンパイル済みクエリ、バッチ操作、スプリットクエリ、LeftJoin演算子など、.NET 10アプリケーション向けの実践的なC#コード例を紹介します。 - Published: 2026-04-15 - Updated: 2026-04-15 - Author: SharpSkill - Tags: dotnet, entity framework core, csharp, performance, ef core 10 - Reading time: 12 min --- Entity Framework Core 10は、2025年11月に.NET 10 LTSとともにリリースされ、LeftJoin演算子、ベクトル検索、名前付きクエリフィルター、SQL変換の大幅な改善を導入しました。本記事では、本番環境におけるクエリ速度、メモリ割り当て、スケーラビリティに直接影響するEF Coreのベストプラクティスを解説します。 > **EF Core 10は.NET 10専用です** > > EF Core 10は長期サポート(LTS)リリースであり、2028年11月までサポートされます。.NET 10のSDKとランタイムが必要です。.NET 8を使用しているアプリケーションは、移行が完了するまでEF Core 8(LTS)をターゲットにすることが推奨されます。 ## クエリトラッキング:無効化すべきタイミングとその理由 `DbSet`へのすべての呼び出しは、デフォルトで返されたエンティティを変更トラッカーにアタッチします。トラッカーは元のプロパティ値のスナップショットを保持し、`SaveChanges`時に差分を計算し、ナビゲーション間のID競合を解決します。データがAPIレスポンスや読み取り専用のビューモデルに直接流れる場合、このオーバーヘッドは不要です。 ```csharp // ProductRepository.cs public async Task> GetActiveByCategoryAsync( int categoryId, CancellationToken ct) { return await _context.Products .AsNoTracking() // skip change tracker entirely .Where(p => p.CategoryId == categoryId && p.IsActive) .OrderBy(p => p.Name) .Select(p => new ProductDto // project to DTO at the database level { Id = p.Id, Name = p.Name, Price = p.Price }) .ToListAsync(ct); } ``` `AsNoTracking`は、スナップショット作成とID解決のエンティティごとのオーバーヘッドを排除します。`Select`と組み合わせることで、必要なカラムのみがネットワークを通過します。50,000行のテーブルでは、このパターンにより、トラッキング付きのフルエンティティクエリと比較してメモリ割り当てが40〜60%削減されるのが一般的です。 データを変更しないコンテキストの場合、登録時にデフォルトを設定できます: ```csharp // Program.cs builder.Services.AddDbContext(options => options.UseSqlServer(connectionString) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)); ``` ## スプリットクエリによるデカルト積の回避 複数のコレクションナビゲーションを持つエンティティを`Include`で読み込むと、JOINを含む単一のSQLステートメントが生成されます。2つ以上のコレクションを同時に読み込むと、結果セットがコレクションのデカルト積として増大し、親行データがすべての組み合わせにわたって重複します。 ```csharp // OrderRepository.cs public async Task GetWithDetailsAsync(int orderId, CancellationToken ct) { return await _context.Orders .AsSplitQuery() // one SQL per Include .Include(o => o.Items) .ThenInclude(i => i.Product) .Include(o => o.Payments) .Include(o => o.ShippingEvents) .FirstOrDefaultAsync(o => o.Id == orderId, ct); } ``` `AsSplitQuery`は、ナビゲーションごとに個別のSQLステートメントに分割します。トレードオフとして、1回のラウンドトリップの代わりに複数回のラウンドトリップが発生しますが、各結果セットは小さく保たれ、行の重複問題を回避できます。EF Core 10では、スプリットクエリにおける長年の順序付けの不整合も修正され、サブクエリの順序がプライマリクエリと一致するようになりました。 > **シングルクエリが適している場合** > > シングルクエリは、1つのコレクションナビゲーションを読み込む場合や、ラウンドトリップのレイテンシが高い場合(リージョン間のデータベース呼び出し)に適しています。コミットする前に、特定のアクセスパターンについて両方のモードをベンチマークすることが重要です。 ## ExecuteUpdateとExecuteDeleteによるバッチ操作 従来のEFワークフローでは、エンティティを読み込み、プロパティを変更してから`SaveChanges`を呼び出します。数千行に影響するバルク操作では、数千のトラッキングインスタンスと個別のUPDATEステートメントが生成されます。EF Core 7で導入された`ExecuteUpdateAsync`と`ExecuteDeleteAsync`は、操作を単一のSQLコマンドにプッシュします。EF Core 10では、式ツリーの代わりにプレーンなラムダを受け入れることで、APIがさらに簡素化されています。 ```csharp // PromotionService.cs public async Task ApplySeasonalDiscountAsync( int categoryId, decimal discountPercent, CancellationToken ct) { var affected = await _context.Products .Where(p => p.CategoryId == categoryId && p.IsActive) .ExecuteUpdateAsync(s => { s.SetProperty(p => p.Price, p => p.Price * (1 - discountPercent / 100)); s.SetProperty(p => p.LastModified, DateTime.UtcNow); }, ct); // affected = number of rows updated } ``` これは単一の`UPDATE ... SET ... WHERE`ステートメントに変換されます。メモリにエンティティが読み込まれることはありません。10,000行の更新では、実行時間がトラッキングアプローチの数秒からミリ秒に短縮されます。 同じパターンが削除にも適用できます: ```csharp // CleanupService.cs public async Task PurgeExpiredSessionsAsync(CancellationToken ct) { await _context.Sessions .Where(s => s.ExpiresAt < DateTime.UtcNow) .ExecuteDeleteAsync(ct); } ``` ## EF Core 10のLeftJoin演算子 EF Core 10以前では、LEFT JOINを実行するには`GroupJoin`、`SelectMany`、`DefaultIfEmpty`を特定のパターンで組み合わせる必要があり、ほとんどの開発者が毎回調べなければなりませんでした。EF Core 10では、`LeftJoin`がファーストクラスのLINQ演算子として追加されました。 ```csharp // ReportService.cs public async Task> GetEmployeeDepartmentReportAsync( CancellationToken ct) { return await _context.Employees .LeftJoin( _context.Departments, employee => employee.DepartmentId, department => department.Id, (employee, department) => new EmployeeReportDto { FullName = employee.FirstName + " " + employee.LastName, Department = department.Name ?? "Unassigned", HiredAt = employee.HiredAt }) .OrderBy(r => r.FullName) .ToListAsync(ct); } ``` 生成されるSQLは標準的な`LEFT JOIN`句を使用します。`RightJoin`演算子も利用可能です。これらの演算子により、以前必要だった冗長な3メソッドチェーンが不要になりました。 ## マルチコンサーン・フィルタリングのための名前付きクエリフィルター グローバルクエリフィルターは、EF Core 2.0以降、エンティティタイプごとに1つのフィルターに制限されてきました。論理削除とマルチテナンシーの両方を実装するアプリケーションでは、条件を1つの式に結合する必要があり、選択的に無効化することができませんでした。EF Core 10では、名前付きクエリフィルターが導入されました。 ```csharp // AppDbContext.cs protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() .HasQueryFilter("SoftDelete", i => !i.IsDeleted) .HasQueryFilter("Tenant", i => i.TenantId == _tenantId); } ``` 管理エンドポイントでは、テナント分離を維持しながら論理削除フィルターを無効化できます: ```csharp // InvoiceRepository.cs public async Task> GetAllIncludingDeletedAsync(CancellationToken ct) { return await _context.Invoices .IgnoreQueryFilters(["SoftDelete"]) // tenant filter still applied .ToListAsync(ct); } ``` これにより、フィルター条件を手動で追加するカスタム`IQueryable`拡張メソッドが不要になります。 ## 接続の回復性とプーリング設定 一時的なデータベース障害(ネットワークの瞬断、Azure SQLのフェイルオーバー、接続プールの枯渇)は、リクエストパイプラインをクラッシュさせる例外を引き起こします。EF Coreにはリトライロジックが組み込まれていますが、デフォルトでは明示的な設定が必要です。 ```csharp // Program.cs builder.Services.AddDbContext(options => options.UseSqlServer(connectionString, sqlOptions => { sqlOptions.EnableRetryOnFailure( maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null); // retry on all transient errors sqlOptions.CommandTimeout(30); // 30-second command timeout })); ``` 接続文字列のプーリングパラメータも重要です: ``` Server=db.example.com;Database=AppDb;Min Pool Size=5;Max Pool Size=100;Connection Timeout=15; ``` `Min Pool Size`は、バーストトラフィックに対応するためのウォームな接続を維持します。`Max Pool Size`は、データベースの過負荷を防ぐために、開いている接続の総数を制限します。デフォルトの100はほとんどのWebアプリケーションに適していますが、高スループットのサービスでは実際の同時クエリ量に基づいた調整が必要になる場合があります。 > **リトライロジックとトランザクション** > > 自動リトライは、ユーザーが開始したトランザクション内では機能しません。複数の操作にわたる一時的な障害を処理するには、`context.Database.CreateExecutionStrategy().ExecuteAsync(...)`を使用して手動のリトライ戦略でトランザクション全体をラップする必要があります。 ## インデックス戦略とクエリ分析 EF Coreのマイグレーションではインデックスを宣言的に定義できますが、どのカラムにインデックスを作成するかはクエリパターンの理解が必要です。 ```csharp // AppDbContext.cs - OnModelCreating modelBuilder.Entity(entity => { // composite index for frequent query pattern entity.HasIndex(o => new { o.CustomerId, o.Status, o.CreatedAt }) .HasDatabaseName("IX_Order_Customer_Status_Date"); // filtered index for active orders only entity.HasIndex(o => o.Status) .HasFilter("[Status] <> 'Cancelled'") .HasDatabaseName("IX_Order_ActiveStatus"); }); ``` フィルター付きインデックスは、クエリが対象としない行を除外することでインデックスサイズを削減します。キャンセル済みの注文が80%を占めるテーブルでは、アクティブなステータスのフィルター付きインデックスは5倍小さく、スキャンも高速になります。 生成されるSQLを分析するには、開発環境でログを有効にします: ```csharp // Program.cs (development only) builder.Services.AddDbContext(options => options.UseSqlServer(connectionString) .LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging()); ``` ログに記録されたSQLをSQL Server Management Studioにコピーし、`SET STATISTICS IO ON`で実行して論理読み取りを確認するか、PostgreSQLでは`EXPLAIN ANALYZE`を使用します。クエリプランからのインデックス不足の提案は、最もインパクトのある最適化の機会を明らかにすることが多いです。 ## ホットパス向けのコンパイル済みクエリ LINQ式ツリーは、実行のたびにSQLに解析・変換されます。毎分数千回実行されるクエリ(認証ルックアップ、セッション検証など)では、この変換コストが蓄積されます。コンパイル済みクエリは、アプリケーション起動時に変換をキャッシュします。 ```csharp // CompiledQueries.cs public static class UserQueries { // compiled once, reused on every call public static readonly Func> GetActiveSession = EF.CompileAsyncQuery( (AppDbContext ctx, string token, CancellationToken ct) => ctx.UserSessions .AsNoTracking() .FirstOrDefault(s => s.Token == token && s.ExpiresAt > DateTime.UtcNow)); } // Usage in middleware var session = await UserQueries.GetActiveSession(dbContext, bearerToken, ct); ``` コンパイル済みクエリは、式ツリーの解析フェーズを完全にスキップします。パフォーマンスの差は、高頻度のパス(毎分1,000回以上の呼び出し)でのみ測定可能です。標準的なCRUDエンドポイントでは、EF Coreの組み込みクエリキャッシュが既に変換の再利用を処理しています。 ## EF Core 10におけるパラメータ化コレクションの変換 IDのリストによるフィルタリングクエリは、データアクセスにおける最も一般的なパターンの1つです。EF Core 10では、パラメータ化コレクションのデフォルトの変換戦略が変更されました。リストをJSON配列としてエンコードする(EF Core 8-9)方式や、定数をインライン化する(EF Core 7以前)方式の代わりに、EF Core 10では各値を個別のSQLパラメータとして変換します。 ```csharp // Before (EF Core 8-9): JSON array parameter // @__ids_0='[1,2,3]' // SELECT ... WHERE Id IN (SELECT value FROM OPENJSON(@__ids_0)) // EF Core 10: individual parameters with padding // SELECT ... WHERE Id IN (@ids1, @ids2, @ids3) var orderIds = new[] { 101, 205, 389 }; var orders = await _context.Orders .Where(o => orderIds.Contains(o.Id)) .ToListAsync(ct); ``` 新しいアプローチにより、パラメータパディングによるプランキャッシュの肥大化を回避しながら、データベースクエリプランナーにカーディナリティ情報を提供できます。JSONアプローチの方がパフォーマンスが優れている場合(非常に大きなコレクション)には、クエリごとに動作をオーバーライドできます: ```csharp var orders = await _context.Orders .Where(o => EF.Parameter(orderIds).Contains(o.Id)) // force JSON mode .ToListAsync(ct); ``` ## まとめ - すべての読み取り専用クエリで`AsNoTracking()`と`Select`プロジェクションを使用し、変更トラッカーのオーバーヘッドを排除する - 複数のコレクションナビゲーションを読み込む際に`AsSplitQuery()`を適用し、デカルト積を回避する - バルク操作では、トラッキング付きのロード・変更・保存パターンを`ExecuteUpdateAsync`と`ExecuteDeleteAsync`に置き換える - EF Core 10の`LeftJoin`演算子を採用し、冗長な`GroupJoin`/`SelectMany`/`DefaultIfEmpty`チェーンを置き換える - 名前付きクエリフィルターを設定して、論理削除とマルチテナンシーを独立して管理する - 本番環境の回復性のために`EnableRetryOnFailure`を設定し、接続プールサイズを調整する - 推測ではなく実際のクエリパターンに基づいて複合インデックスとフィルター付きインデックスを定義する - 毎分1,000回の実行を超える真のホットパスにのみコンパイル済みクエリを使用する - EF Core 10にパラメータ化コレクションの変換をデフォルトで処理させ、ベンチマークで正当化される場合のみオーバーライドする 参考資料:[EF Core Advancedモジュール](/technologies/dotnet/interview-questions/ef-core-advanced)では、これらのパターンを面接の文脈で扱っています。また、[.NETのクリーンアーキテクチャガイド](/blog/dotnet/clean-architecture-dotnet-practical-guide)では、これらのクエリをラップするリポジトリとサービスレイヤーの構成方法を説明しています。 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ja/blog/dotnet/ef-core-performance-best-practices