Entity Framework Core:2026年のパフォーマンス最適化とベストプラクティス

EF Core 10のパフォーマンス最適化を解説。AsNoTracking、コンパイル済みクエリ、バッチ操作、スプリットクエリ、LeftJoin演算子など、.NET 10アプリケーション向けの実践的なC#コード例を紹介します。

Entity Framework Coreのパフォーマンス最適化図 - データベースクエリと.NET 10

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<T>へのすべての呼び出しは、デフォルトで返されたエンティティを変更トラッカーにアタッチします。トラッカーは元のプロパティ値のスナップショットを保持し、SaveChanges時に差分を計算し、ナビゲーション間のID競合を解決します。データがAPIレスポンスや読み取り専用のビューモデルに直接流れる場合、このオーバーヘッドは不要です。

ProductRepository.cscsharp
public async Task<List<ProductDto>> 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%削減されるのが一般的です。

データを変更しないコンテキストの場合、登録時にデフォルトを設定できます:

Program.cscsharp
builder.Services.AddDbContext<CatalogContext>(options =>
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

スプリットクエリによるデカルト積の回避

複数のコレクションナビゲーションを持つエンティティをIncludeで読み込むと、JOINを含む単一のSQLステートメントが生成されます。2つ以上のコレクションを同時に読み込むと、結果セットがコレクションのデカルト積として増大し、親行データがすべての組み合わせにわたって重複します。

OrderRepository.cscsharp
public async Task<Order?> 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で導入されたExecuteUpdateAsyncExecuteDeleteAsyncは、操作を単一のSQLコマンドにプッシュします。EF Core 10では、式ツリーの代わりにプレーンなラムダを受け入れることで、APIがさらに簡素化されています。

PromotionService.cscsharp
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行の更新では、実行時間がトラッキングアプローチの数秒からミリ秒に短縮されます。

同じパターンが削除にも適用できます:

CleanupService.cscsharp
public async Task PurgeExpiredSessionsAsync(CancellationToken ct)
{
    await _context.Sessions
        .Where(s => s.ExpiresAt < DateTime.UtcNow)
        .ExecuteDeleteAsync(ct);
}

.NETの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

EF Core 10のLeftJoin演算子

EF Core 10以前では、LEFT JOINを実行するにはGroupJoinSelectManyDefaultIfEmptyを特定のパターンで組み合わせる必要があり、ほとんどの開発者が毎回調べなければなりませんでした。EF Core 10では、LeftJoinがファーストクラスのLINQ演算子として追加されました。

ReportService.cscsharp
public async Task<List<EmployeeReportDto>> 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では、名前付きクエリフィルターが導入されました。

AppDbContext.cscsharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Invoice>()
        .HasQueryFilter("SoftDelete", i => !i.IsDeleted)
        .HasQueryFilter("Tenant", i => i.TenantId == _tenantId);
}

管理エンドポイントでは、テナント分離を維持しながら論理削除フィルターを無効化できます:

InvoiceRepository.cscsharp
public async Task<List<Invoice>> GetAllIncludingDeletedAsync(CancellationToken ct)
{
    return await _context.Invoices
        .IgnoreQueryFilters(["SoftDelete"])  // tenant filter still applied
        .ToListAsync(ct);
}

これにより、フィルター条件を手動で追加するカスタムIQueryable拡張メソッドが不要になります。

接続の回復性とプーリング設定

一時的なデータベース障害(ネットワークの瞬断、Azure SQLのフェイルオーバー、接続プールの枯渇)は、リクエストパイプラインをクラッシュさせる例外を引き起こします。EF Coreにはリトライロジックが組み込まれていますが、デフォルトでは明示的な設定が必要です。

Program.cscsharp
builder.Services.AddDbContext<AppDbContext>(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
    }));

接続文字列のプーリングパラメータも重要です:

text
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のマイグレーションではインデックスを宣言的に定義できますが、どのカラムにインデックスを作成するかはクエリパターンの理解が必要です。

AppDbContext.cs - OnModelCreatingcsharp
modelBuilder.Entity<Order>(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を分析するには、開発環境でログを有効にします:

Program.cs (development only)csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging());

ログに記録されたSQLをSQL Server Management Studioにコピーし、SET STATISTICS IO ONで実行して論理読み取りを確認するか、PostgreSQLではEXPLAIN ANALYZEを使用します。クエリプランからのインデックス不足の提案は、最もインパクトのある最適化の機会を明らかにすることが多いです。

ホットパス向けのコンパイル済みクエリ

LINQ式ツリーは、実行のたびにSQLに解析・変換されます。毎分数千回実行されるクエリ(認証ルックアップ、セッション検証など)では、この変換コストが蓄積されます。コンパイル済みクエリは、アプリケーション起動時に変換をキャッシュします。

CompiledQueries.cscsharp
public static class UserQueries
{
    // compiled once, reused on every call
    public static readonly Func<AppDbContext, string, CancellationToken, Task<UserSession?>>
        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()を適用し、デカルト積を回避する
  • バルク操作では、トラッキング付きのロード・変更・保存パターンをExecuteUpdateAsyncExecuteDeleteAsyncに置き換える
  • EF Core 10のLeftJoin演算子を採用し、冗長なGroupJoin/SelectMany/DefaultIfEmptyチェーンを置き換える
  • 名前付きクエリフィルターを設定して、論理削除とマルチテナンシーを独立して管理する
  • 本番環境の回復性のためにEnableRetryOnFailureを設定し、接続プールサイズを調整する
  • 推測ではなく実際のクエリパターンに基づいて複合インデックスとフィルター付きインデックスを定義する
  • 毎分1,000回の実行を超える真のホットパスにのみコンパイル済みクエリを使用する
  • EF Core 10にパラメータ化コレクションの変換をデフォルトで処理させ、ベンチマークで正当化される場合のみオーバーライドする

参考資料:EF Core Advancedモジュールでは、これらのパターンを面接の文脈で扱っています。また、.NETのクリーンアーキテクチャガイドでは、これらのクエリをラップするリポジトリとサービスレイヤーの構成方法を説明しています。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#dotnet
#entity framework core
#csharp
#performance
#ef core 10

共有

関連記事