Share via


高度なパフォーマンスのトピック

DbContext プール

DbContext は一般に簡易なオブジェクトです。作成および破棄するのにデータベース操作は必要ありません。ほとんどのアプリケーションで、パフォーマンスに大きな影響を与えることなく、それを行うことができます。 しかし、各コンテキスト インスタンスでは、その役割を果たすために必要なさまざまな内部サービスやオブジェクトを設定します。さらに、そのようなことを継続的に実行するときのオーバーヘッドは、ハイパフォーマンスのシナリオで大きくなる可能性があります。 そのような場合、EF Core では、ご利用のコンテキスト インスタンスをプールできます。ご利用のコンテキストを破棄すると、EF Core によってその状態がリセットされ、内部プールにコンテキストが格納されます。新しいインスタンスが次に要求された場合は、プールされたそのインスタンスが返され、新しいものは設定されません。 コンテキスト プーリングを使用すると、コンテキストの設定コストは、継続的にかかるのではなく、プログラムの起動時に 1 回だけかかるようになります。

コンテキスト プーリングは、下位レベルでデータベース ドライバーにより管理されるデータベース接続プーリングと一致しない部分があることに注意してください。

EF Core を使用する ASP.NET Core アプリの一般的なパターンでは、AddDbContext を介してカスタム DbContext 型を依存関係の挿入コンテナ-に登録する必要があります。 次に、その型のインスタンスを、コントローラーまたは Razor Pages のコンストラクター パラメーターを介して取得します。

コンテキスト プーリングを有効にするには、AddDbContextAddDbContextPool に置き換えます。

builder.Services.AddDbContextPool<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));

AddDbContextPoolpoolSize パラメーターでは、プールで保持されるインスタンスの最大数を設定します (既定値は 1024)。 poolSize を超えると、新しいコンテキスト インスタンスはキャッシュされなくなり、EF は、オンデマンドでインスタンスを作成する非プーリング動作にファール バックされます。

ベンチマーク

コンテキスト プーリングを使用する場合と使用しない場合について、同じコンピューター上でローカルに実行されている SQL Server データベースから単一行を取り込む場合のベンチマーク結果を以下に示します。 いつものように、行数、ご利用のデータベース サーバーの待機時間、およびその他の要因によって、結果は変わってきます。 重要なのは、これはシングルスレッド プーリングにおけるパフォーマンスのベンチマークであり、実際の競合シナリオでは異なる結果になる可能性があるということです。決定を下す前に、ご利用のプラットフォーム上でベンチマークを実行してください。 ソース コードはここから入手できます。これは、独自の測定の基盤としてご自由に使用できます。

メソッド NumBlogs 平均 エラー StdDev Gen 0 Gen 1 Gen 2 Allocated
WithoutContextPooling 1 701.6 us 26.62 us 78.48 us 11.7188 - - 50.38 KB
WithContextPooling 1 350.1 us 6.80 us 14.64 us 0.9766 - - 4.63 KB

プールされたコンテキストの状態の管理

コンテキスト プールは、要求間で同じコンテキスト インスタンスを再利用することによって機能します。つまり、これは実質的にシングルトンとして登録され、同じインスタンスが複数の要求 (または DI スコープ) で再利用されます。 そのため、要求間で変更される可能性のある状態がコンテキストに含まれている場合は、特別な注意を払う必要があります。 重要な点として、コンテキストの OnConfiguring は、インスタンス コンテキストが最初に作成されたときに 1 回だけ呼び出されるため、変更する必要がある状態 (テナント ID など) の設定には使用できません。

コンテキストの状態を含む一般的なシナリオとして、マルチテナント ASP.NET Core アプリケーションがあります。この場合、コンテキスト インスタンスには、クエリで考慮されるテナント ID があります (詳細については、「グローバル クエリ フィルター」を参照してください)。 テナント ID は各 Web 要求で変更する必要があるため、コンテキスト プールですべて機能させるために追加の手順を実行する必要があります。

アプリケーションが、テナント ID とその他のテナント関連情報をラップするスコープ付きの ITenant サービスを登録するとします。

// Below is a minimal tenant resolution strategy, which registers a scoped ITenant service in DI.
// In this sample, we simply accept the tenant ID as a request query, which means that a client can impersonate any
// tenant. In a real application, the tenant ID would be set based on secure authentication data.
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenant>(sp =>
{
    var tenantIdString = sp.GetRequiredService<IHttpContextAccessor>().HttpContext.Request.Query["TenantId"];

    return tenantIdString != StringValues.Empty && int.TryParse(tenantIdString, out var tenantId)
        ? new Tenant(tenantId)
        : null;
});

前述のように、テナント ID の取得元に特に注意してください。これは、アプリケーションのセキュリティの重要な側面です。

スコープ付きの ITenant サービスを入手したら、通常どおり、プール コンテキスト ファクトリをシングルトン サービスとして登録します。

builder.Services.AddPooledDbContextFactory<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));

次に、登録したシングルトン ファクトリからプールされたコンテキストを取得し、それが提供するコンテキスト インスタンスにテナント ID を挿入するカスタム コンテキスト ファクトリを記述します。

public class WeatherForecastScopedFactory : IDbContextFactory<WeatherForecastContext>
{
    private const int DefaultTenantId = -1;

    private readonly IDbContextFactory<WeatherForecastContext> _pooledFactory;
    private readonly int _tenantId;

    public WeatherForecastScopedFactory(
        IDbContextFactory<WeatherForecastContext> pooledFactory,
        ITenant tenant)
    {
        _pooledFactory = pooledFactory;
        _tenantId = tenant?.TenantId ?? DefaultTenantId;
    }

    public WeatherForecastContext CreateDbContext()
    {
        var context = _pooledFactory.CreateDbContext();
        context.TenantId = _tenantId;
        return context;
    }
}

カスタム コンテキスト ファクトリを記述したら、それをスコープ付きサービスとして登録します。

builder.Services.AddScoped<WeatherForecastScopedFactory>();

最後に、スコープ付きファクトリからコンテキストが挿入されるように調整します。

builder.Services.AddScoped(
    sp => sp.GetRequiredService<WeatherForecastScopedFactory>().CreateDbContext());

この時点で、コントローラーには、適切なテナント ID を持つコンテキスト インスタンスが自動的に挿入されます。ユーザーがこの処理を認識することは一切ありません。

このサンプルの完全なソースコードについては、こちらを参照してください。

Note

EF Core は、DbContext とその関連サービスの内部状態をリセットしますが、EF の外部にある、基になるデータベース ドライバーの状態は通常リセットしません。 たとえば、DbConnection を手動で開いて使用する場合や、ADO.NET 状態を操作する場合、コンテキスト インスタンスをプールに戻す前に、ユーザーが接続を閉じるなどしてその状態を復元する必要があります。 これを行わないと、関連のない要求間で状態がリークする可能性があります。

コンパイル済みクエリ

EF では、実行のための LINQ クエリ ツリーを受け取ると、最初にそのツリーを "コンパイル" する必要があります。たとえば、それを基に SQL を生成します。 このタスクは複雑なプロセスであるため、EF ではクエリ ツリー シェイプによってクエリをキャッシュします。このため、同じ構造を持つクエリだと、内部的にキャッシュされたコンパイル出力を再利用できます。 このキャッシュを使用すると、パラメーター値が異なる場合でも、同じ LINQ クエリを複数回実行する処理が非常に高速になります。

しかしながら、EF で内部クエリ キャッシュを利用するには、事前に特定のタスクを実行する必要があります。 たとえば、正しいキャッシュされたクエリを見つけるために、使用するクエリの式ツリーを、キャッシュされたクエリの複数の式ツリーと再帰的に比較する必要があります。 この初期処理のオーバーヘッドは、特にクエリ実行に関連する他のコスト (ネットワーク I/O、実際のクエリ処理、データベースでのディスク I/O など) と比較した場合、ほとんどの EF アプリケーションでごくわずかとなります。ただし、特定のハイパフォーマンス シナリオでは、それを排除することが望ましい場合があります。

EF では、コンパイル済みのクエリがサポートされています。これを使用すると、LINQ クエリを .NET デリゲートに明示的にコンパイルできます。 このデリゲートを取得したら、それを直接呼び出してクエリを実行することができ、LINQ 式ツリーを指定する必要はありません。 この手法を使用すると、キャッシュ参照がバイパスされるので、EF Core でクエリを実行するための方法を最大限に最適化できます。 コンパイル済みとコンパイルされていないクエリのパフォーマンスを比較したベンチマークの結果をいくつか以下に示します。決定を下す前に、ご利用のプラットフォーム上でベンチマークを行ってください。 ソース コードはここから入手できます。これは、独自の測定の基盤としてご自由に使用できます。

メソッド NumBlogs 平均 エラー StdDev Gen 0 Allocated
WithCompiledQuery 1 564.2 us 6.75 us 5.99 us 1.9531 9 KB
WithoutCompiledQuery 1 671.6 us 12.72 us 16.54 us 2.9297 13 KB
WithCompiledQuery 10 645.3 us 10.00 us 9.35 us 2.9297 13 KB
WithoutCompiledQuery 10 709.8 us 25.20 us 73.10 us 3.9063 18 KB

コンパイル済みクエリを使用するには、まず次のように EF.CompileAsyncQuery を使用してクエリをコンパイルします (同期クエリには EF.CompileQuery を使用します)。

private static readonly Func<BloggingContext, int, IAsyncEnumerable<Blog>> _compiledQuery
    = EF.CompileAsyncQuery(
        (BloggingContext context, int length) => context.Blogs.Where(b => b.Url.StartsWith("http://") && b.Url.Length == length));

このコード サンプルでは、DbContext インスタンスを受け取るラムダと、クエリに渡す任意のパラメーターを EF に提供しています。 これで、いつでもクエリを実行したいときに、そのデリゲートを呼び出せるようになります。

await foreach (var blog in _compiledQuery(context, 8))
{
    // Do something with the results
}

デリゲートはスレッドセーフであり、さまざまなコンテキスト インスタンス上で同時に呼び出し可能であることに注意してください。

制限事項

  • コンパイル済みクエリは、1 つの EF Core モデルに対してのみ使用できます。 同じ型の別々のコンテキスト インスタンスでそれぞれ異なるモデルを使用するように構成できる場合があります。しかし、このシナリオでコンパイル済みクエリを実行することはサポートされていません。
  • コンパイル済みクエリでパラメーターを使用する場合は、シンプルなスカラー パラメーターを使用してください。 インスタンスに対するメンバーまたはメソッド アクセスなど、より複雑なパラメーター式はサポートされていません。

クエリのキャッシュとパラメーター化

EF では、実行のための LINQ クエリ ツリーを受け取ると、最初にそのツリーを "コンパイル" する必要があります。たとえば、それを基に SQL を生成します。 このタスクは複雑なプロセスであるため、EF ではクエリ ツリー シェイプによってクエリをキャッシュします。このため、同じ構造を持つクエリだと、内部的にキャッシュされたコンパイル出力を再利用できます。 このキャッシュを使用すると、パラメーター値が異なる場合でも、同じ LINQ クエリを複数回実行する処理が非常に高速になります。

次の 2 つのクエリについて考えてみましょう。

var post1 = context.Posts.FirstOrDefault(p => p.Title == "post1");
var post2 = context.Posts.FirstOrDefault(p => p.Title == "post2");

これらの式ツリーにはそれぞれ異なる定数が含まれているため、式ツリーは異なるものとなり、これらのクエリはそれぞれ、EF Core によって個別にコンパイルされます。 さらに、各クエリによって生成される SQL コマンドも若干異なります。

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = N'blog1'

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = N'blog2'

SQL が異なるため、ご利用のデータベース サーバーでは、同じプランを再利用するのではなく、両方のクエリに対してクエリ プランを生成することが必要になる可能性があります。

クエリを少し変更すると、状況が大幅に変わる可能性があります。

var postTitle = "post1";
var post1 = context.Posts.FirstOrDefault(p => p.Title == postTitle);
postTitle = "post2";
var post2 = context.Posts.FirstOrDefault(p => p.Title == postTitle);

これでブログ名がパラメーター化されたので、両方のクエリのツリー シェイプは同じになりました。EF は一度コンパイルする必要があるだけです。 生成される SQL もパラメーター化されるため、データベースで同じクエリ プランを再利用できます。

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] = @__blogName_0

それぞれのクエリをすべてパラメーター化することが必要なわけでないことに注意してください。定数を持つクエリをいくつか作成することは全く問題ありません。実際、データベース (および EF) では、クエリをパラメーター化したときには不可能である、定数に関する特定の最適化を実行できる場合があります。 適切なパラメーター化が重要である例については、クエリの動的な構築に関するセクションを参照してください。

Note

EF Core のイベント カウンターからは、クエリ キャッシュ ヒット率が報告されます。 通常のアプリケーションでは、ほとんどのクエリが少なくとも 1 回実行されると、プログラムの起動後すぐにこのカウンターは 100% に達します。 このカウンターが 100% 未満で安定している場合は、ご利用のアプリケーションでクエリ キャッシュを損なう何かが実行されている可能性があることを示しています。それを調査することをお勧めします。

Note

データベースでキャッシュ クエリ プランを管理する方法は、データベースに依存します。 たとえば、SQL Server では LRU クエリ プラン キャッシュを暗黙的に保持しますが、PostgreSQL ではそれを行いません (ただし、準備されたステートメントによって、非常に類似した最終結果が生成される場合があります)。 詳細については、ご利用のデータベースのドキュメントを参照してください。

クエリの動的な構築

状況によっては、LINQ クエリをソース コードで完全に指定するのではなく、動的に構築することが必要です。 これは、たとえば、クライアントから任意のクエリの詳細を受け取る Web サイトで、オープンエンドなクエリ演算子 (並べ替え、フィルター処理、ページングなど) を使用すると、発生する可能性があります。原則として、正しく実行された場合、動的に構築されたクエリは通常のクエリと同じくらい効率的になる可能性があります (ただし、動的クエリで、コンパイル済みクエリの最適化を使用することはできません)。 しかしながら、実際には、それらはパフォーマンスの問題の原因となることがよくあります。毎回、異なるシェイプを持つ式ツリーが誤って作成されることがよくあるからです。

次の例では、3 つの手法を使用してクエリの Where ラムダ式を作成します。

  1. Expression API と定数: 定数ノードを使用して Expression API で式を動的に作成します。 これは、式ツリーを動的に構築するときによくある間違いであり、異なる定数値で呼び出されるたびに EF によってクエリが再コンパイルされます (通常、データベース サーバーでプラン キャッシュの汚染が発生します)。
  2. Expression API とパラメーター: 定数をパラメーターに置き換えた、より優れたバージョン。 この方法では、指定された値に関係なくクエリが 1 回だけコンパイルされ、同じ (パラメーター化された) SQL が生成されます。
  3. パラメーターを使用したシンプルな方法: Expression API を使用しないバージョン (比較用)。この方法では、上記のメソッドと同じツリーを作成しますが、はるかにシンプルです。 多くの場合、Expression API を使用しなくても式ツリーを動的に構築できますが、これは間違いが発生しやすいです。

指定されたパラメーターが null でない場合にのみ、Where 演算子をクエリに追加します。 この方法は、クエリを動的に作成する上で適切なユース ケースではありませんが、わかりやすくするために使用していることに注意してください。

[Benchmark]
public int ExpressionApiWithConstant()
{
    var url = "blog" + Interlocked.Increment(ref _blogNumber);
    using var context = new BloggingContext();

    IQueryable<Blog> query = context.Blogs;

    if (_addWhereClause)
    {
        var blogParam = Expression.Parameter(typeof(Blog), "b");
        var whereLambda = Expression.Lambda<Func<Blog, bool>>(
            Expression.Equal(
                Expression.MakeMemberAccess(
                    blogParam,
                    typeof(Blog).GetMember(nameof(Blog.Url)).Single()),
                Expression.Constant(url)),
            blogParam);

        query = query.Where(whereLambda);
    }

    return query.Count();
}

この 2 つの手法のベンチマークを実行すると、次の結果が得られます。

メソッド 平均 エラー StdDev Gen0 Gen1 Allocated
ExpressionApiWithConstant 1,665.8 us 56.99 us 163.5 us 15.6250 - 109.92 KB
ExpressionApiWithParameter 757.1 us 35.14 us 103.6 us 12.6953 0.9766 54.95 KB
SimpleWithParameter 760.3 us 37.99 us 112.0 us 12.6953 - 55.03 KB

ミリ秒未満の差が小さく見える場合でも、定数バージョンを使用するとキャッシュは継続的に汚染され、その他のクエリが再コンパイルされて速度が低下します。また、パフォーマンス全体に全般的な悪影響が生じます。 クエリが継続的に再コンパイルされないようにすることを強くお勧めします。

Note

本当に必要な場合を除いて、式ツリー API を使用してクエリを作成することは避けてください。 API の複雑さとは別に、それらを使用した場合は、重大なパフォーマンス上の問題が不注意で非常に容易に発生してしまいます。

コンパイル済みモデル

コンパイル済みモデルを使用すると、大きなモデルが含まれるアプリケーションで、EF Core のスタートアップ時間を短縮できます。 通常、大規模なモデルとは、数百から数千のエンティティ型とリレーションシップを意味します。 ここで、スタートアップ時間とは、アプリケーションで DbContext 型が初めて使用されるときに、その DbContext で最初の操作が実行されるまでの時間のことをいいます。 DbContext のインスタンスを作成するだけでは、EF モデルが初期化されないことに注意してください。 通常は、DbContext.Add の呼び出しまたは最初のクエリの実行が含まれる最初の操作によって、モデルが初期化されます。

コンパイル済みモデルは、dotnet ef コマンド ライン ツールを使用して作成します。 続ける前に、最新バージョンのツールがインストールされていることを確認してください。

コンパイル済みモデルを生成するには、新しい dbcontext optimize コマンドを使用します。 次に例を示します。

dotnet ef dbcontext optimize

オプション --output-dir--namespace を使用して、コンパイル済みモデルの生成先のディレクトリと名前空間を指定できます。 次に例を示します。

PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels> dotnet ef dbcontext optimize --output-dir MyCompiledModels --namespace MyCompiledModels
Build started...
Build succeeded.
Successfully generated a compiled model, to use it call 'options.UseModel(MyCompiledModels.BlogsContextModel.Instance)'. Run this command again when the model is modified.
PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels>

このコマンドを実行したときの出力に含まれるコードをコピーして DbContext の構成に貼り付けると、EF Core でコンパイル済みモデルが使用されるようになります。 次に例を示します。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseModel(MyCompiledModels.BlogsContextModel.Instance)
        .UseSqlite(@"Data Source=test.db");

コンパイル済みモデルのブートストラップ

通常は、生成されたブートストラップ コードを調べる必要はありません。 ただし、モデルまたはその読み込みをカスタマイズすると便利な場合があります。 ブートストラップ コードは次のようになります。

[DbContext(typeof(BlogsContext))]
partial class BlogsContextModel : RuntimeModel
{
    private static BlogsContextModel _instance;
    public static IModel Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new BlogsContextModel();
                _instance.Initialize();
                _instance.Customize();
            }

            return _instance;
        }
    }

    partial void Initialize();

    partial void Customize();
}

これは部分クラスであり、必要に応じてモデルをカスタマイズするために実装できる部分メソッドがあります。

また、ランタイム構成に応じてさまざまなモデルを使用する可能性がある DbContext 型に対しては、複数のコンパイル済みモデルを生成できます。 これらは、上に示したように、別々のフォルダーと名前空間に配置する必要があります。 接続文字列などのランタイム情報を調べることができ、必要に応じて適切なモデルが返されます。 次に例を示します。

public static class RuntimeModelCache
{
    private static readonly ConcurrentDictionary<string, IModel> _runtimeModels
        = new();

    public static IModel GetOrCreateModel(string connectionString)
        => _runtimeModels.GetOrAdd(
            connectionString, cs =>
            {
                if (cs.Contains("X"))
                {
                    return BlogsContextModel1.Instance;
                }

                if (cs.Contains("Y"))
                {
                    return BlogsContextModel2.Instance;
                }

                throw new InvalidOperationException("No appropriate compiled model found.");
            });
}

制限事項

コンパイル済みモデルにはいくつかの制限があります。

これらの制限のため、コンパイル済みモデルは、EF Core のスタートアップ時間が遅すぎる場合にのみ使用する必要があります。 通常、小さいモデルのコンパイルに使用しても効果はありません。

これらの機能のサポートが成功に不可欠な場合は、上記でリンクされている該当するイシューに投票してください。

ランタイム オーバーヘッドの削減

他のレイヤーと同様に、EF Core を使用すると、下位レベルのデータベース API に対して直接コーディングする場合と比較して、ランタイム オーバーヘッドがいくらか大きくなります。 実際のアプリケーションのほとんどは、このランタイム オーバーヘッドから著しい影響を受けることはめったにありません。このパフォーマンス ガイドの他のトピック (クエリ効率、インデックスの使用状況、およびラウンドトリップの最小化など) の方がはるかに重要です。 さらに、高度に最適化されたアプリケーションの場合でも、EF Core 自体の内部で費やされる時間は、通常、ネットワーク遅延とデータベース I/O によって支配されます。 ただし、ビットごとのパフォーマンスが重要である、ハイパフォーマンスで低遅延のアプリケーションの場合は、次の推奨事項を使用して、EF Core のオーバーヘッドを最小限に抑えることができます。

  • DbContext プーリングを有効にする。Microsoft のベンチマークを見れば、この機能がハイパフォーマンスで低遅延のアプリケーションに決定的な影響を与える可能性があることがわかります。
    • maxPoolSize が自分の使用シナリオに対応していることを確認してください。これが低すぎると、DbContext インスタンスの作成および破棄が常に行われ、パフォーマンスの低下を招きます。 設定値が高すぎると、未使用の DbContext インスタンスもプールに保持されるため、メモリを不必要に消費するおそれがあります。
    • パフォーマンスをさらに向上させるには、DI でコンテキスト インスタンスを直接挿入するのではなく、PooledDbContextFactory を使用することを検討してください。 DbContext プーリングの DI 管理には、若干のオーバーヘッドが伴います。
  • ホット クエリにはプリコンパイル済みクエリを使用する。
    • LINQ クエリが複雑になるほど (含まれる演算子が多くなり、結果として得られる式ツリーも大きくなる)、コンパイル済みクエリを使用すれば、より多くの向上が期待できます。
  • コンテキスト構成で EnableThreadSafetyChecks を false に設定して、スレッド セーフ チェックを無効にすることを検討する。
    • 同じ DbContext インスタンスをさまざまなスレッドで同時に使用することはサポートされていません。 EF Core には、多くの場合に (ただし、すべてというわけではない)、このプログラミング バグを検出し、すぐに有益な例外をスローする安全機能が用意されています。 ただし、この安全機能により、ランタイム オーバーヘッドがいくらか発生します。
    • 警告: スレッド セーフ チェックを無効にする場合は、必ず事前にアプリケーションにそのような同時実行のバグが含まれていないことを徹底的にテストしてください。