コンテンツにスキップ

付録 EF Core 5:パフォーマンス

このページは 第8章:データベースアクセスと ORM (Entity Framework Core) の付録です。計測と診断、クエリとモデルの最適化、実行時コストの削減を扱います。

本編を先に読んでから、必要な項目をここで参照してください。

第8章のほかの付録


  1. 計測と診断
  2. クエリとモデルの最適化
  3. 実行時のコストを下げる
  4. 参考ドキュメント

最適化の前に、どのクエリが遅いのかを特定します。EF Core は実行した SQL をログに出力できます。

builder.Services.AddDbContext<BloggingContext>(options =>
options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information));

開発環境では、しきい値を超えたコマンドだけを警告として記録すると、遅いクエリを見つけやすくなります。実運用では Application Insights などの APM (Application Performance Monitoring) ツールでデータベース依存関係を追跡します。

ToQueryString() を使うと、クエリを実行せずに生成される SQL を確認できます。SQL Server 向けのこの例では先頭にパラメーターの DECLARE が付きます。ただし公式 API はデバッグ用途としており、任意の出力をそのまま実行できる保証はありません。SQL Server Management Studio で試す場合も、構文・パラメーター・対象データベースを確認してください。

var url = "dotnet";
var query = context.Blogs.Where(b => b.Url.Contains(url));
Console.WriteLine(query.ToQueryString());

SQL 文字列ではなく DbCommand そのものが欲しい場合は CreateDbCommand() を使います。CommandText に加えて、パラメーターの値・型・サイズ、コマンドのタイムアウトまで取得できます。

using System.Data.Common;
using var command = query.CreateDbCommand();
Console.WriteLine(command.CommandText);
Console.WriteLine(command.CommandTimeout);
foreach (DbParameter p in command.Parameters)
{
Console.WriteLine($"{p.ParameterName} = {p.Value} ({p.DbType}, size={p.Size})");
}

EF Core 10.0.11 の SQL Server プロバイダーで、データベース接続を開かずにコマンドを生成した結果は次のとおりです。ToQueryString() と違って CommandText にはパラメーターの値が埋め込まれず、Parameters コレクションから個別に取得します。

SELECT [b].[Id], [b].[Name], [b].[Url]
FROM [Blogs] AS [b]
WHERE [b].[Url] LIKE @url_contains ESCAPE N'\'
CommandTimeout : 30
@url_contains = %dotnet% (String, size=4000)

LogTo は既定で複数行にわたる読みやすい形式で出力しますが、ログ基盤に流し込むときは 1 行にまとめたい、時刻を UTC で揃えたい、といった要求が出てきます。DbContextLoggerOptions で制御できます。

options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine,
new[] { RelationalEventId.CommandExecuted },
LogLevel.Information,
DbContextLoggerOptions.UtcTime | DbContextLoggerOptions.SingleLine);

次は SQLite で同じ CountAsync() クエリを使い、書式オプションの有無を比較した確認例です(EF Core 10.0.11)。

--- 書式オプション未指定(既定) ---
info: 2026/09/10 09:55:23.500 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM "Blogs" AS "b"
--- UtcTime | SingleLine ---
2026-09-10T00:55:23.5456160Z -> Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']SELECT COUNT(*)FROM "Blogs" AS "b"

UtcTime を付けると ISO 8601 の UTC タイムスタンプが先頭に付き、SingleLine を付けると改行が取り除かれて 1 行になります。

EF Core が公開しているメトリックを見る

Section titled “EF Core が公開しているメトリックを見る”

公式のメトリックガイドでは、EF Core 9 以降に System.Diagnostics.Metrics API で Microsoft.EntityFrameworkCore というメーター(Meter:.NET の計測 API の単位)を公開すると説明しています。アプリケーション全体の傾向を把握するための 7 つの計測器は次のとおりです。

メトリック種類意味
microsoft.entityframeworkcore.active_dbcontextsObservableUpDownCounter現在アクティブな DbContext の数
microsoft.entityframeworkcore.queriesObservableCounter実行されたクエリの累計
microsoft.entityframeworkcore.savechangesObservableCounterSaveChanges の累計
microsoft.entityframeworkcore.compiled_query_cache_hitsObservableCounterクエリキャッシュにヒットした回数
microsoft.entityframeworkcore.compiled_query_cache_missesObservableCounterクエリキャッシュを外した回数
microsoft.entityframeworkcore.execution_strategy_operation_failuresObservableCounter実行戦略が捉えた操作の失敗回数
microsoft.entityframeworkcore.optimistic_concurrency_failuresObservableCounter楽観的同時実行制御の失敗回数

次は MeterListener による確認例です。同じ形のクエリを 3 回、別の形を 1 回、SaveChanges を 1 回実行した条件の結果を示します。

microsoft.entityframeworkcore.active_dbcontexts = 1
microsoft.entityframeworkcore.queries = 4
microsoft.entityframeworkcore.savechanges = 1
microsoft.entityframeworkcore.compiled_query_cache_hits = 2
microsoft.entityframeworkcore.compiled_query_cache_misses = 2
microsoft.entityframeworkcore.execution_strategy_operation_failures = 0
microsoft.entityframeworkcore.optimistic_concurrency_failures = 0

active_dbcontexts が 1 なのは、DbContext を破棄する前に観測したためです。破棄した後に観測すると 0 になります。misses が 2 なのは、クエリの形が 2 種類あったからです。同じ形の 2 回目と 3 回目は hits に入っています。misses の増分を同期間の hits の増分と比較すると、観測したクエリキャッシュの利用状況を調べられます。動的に組み立てた式ツリーや、定数を埋め込んでしまったクエリが原因になりがちです。

using MeterListener meterListener = new();
meterListener.InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == "Microsoft.EntityFrameworkCore")
{
listener.EnableMeasurementEvents(instrument);
}
};
meterListener.SetMeasurementEventCallback<long>((instrument, value, _, _) =>
Console.WriteLine($"{instrument.Name} = {value}"));
meterListener.SetMeasurementEventCallback<int>((instrument, value, _, _) =>
Console.WriteLine($"{instrument.Name} = {value}"));
meterListener.Start();
// ここでクエリを実行する
meterListener.RecordObservableInstruments();

特に重要なのが クエリキャッシュのヒット率 です。EF Core は LINQ 式から SQL への変換結果をキャッシュしており、起動直後を過ぎればヒット率はほぼ 100% になるはずです。compiled_query_cache_misses は累積値なので、増え続けることだけでは効率を判断できません。同期間の hits と misses の増分から比率を調べ、新しい形のクエリが増えていないか確認します。

次は、同じ処理を 50 回ずつ繰り返した条件でのヒット率の確認例です。

書き方ヒット率
同じ形の LINQ クエリを繰り返す98%
EF.Constant() で値をインライン化する98%
条件式を実行時に付け外しして形を変える92%
FromSqlRaw に、値を直接埋め込んだ毎回異なる SQL を渡す0%

active_dbcontexts が想定より多いままなら DbContext が破棄されずに残っている可能性があり、optimistic_concurrency_failures の増加は同時更新の競合が実際に起きていることを示します。これらは OpenTelemetry や Application Insights にそのまま送れます。

クエリタグでログと LINQ を結びつける

Section titled “クエリタグでログと LINQ を結びつける”

ログに大量の SQL が流れると、「この重いクエリはソースコードのどこが出しているのか」が分からなくなります。クエリタグ (Query Tag) を使うと、LINQ クエリに付けた注釈が SQL のコメントとしてそのまま出力されます。

var blogs = await context.Blogs
.TagWith("月次レポート用の集計")
.ToListAsync();

生成される SQL は次のようになります(EF Core 10.0.11 / SQLite、Id と Name を持つ最小モデルで確認)。この出力例を全プロバイダーに共通の書式とはみなさないでください。

-- 月次レポート用の集計
SELECT "b"."Id", "b"."Name"
FROM "Blogs" AS "b"

タグは累積します。共通のクエリを組み立てるヘルパーメソッドの中で TagWith を呼び、呼び出し側でさらに TagWith を呼べば、両方がコメントとして残ります。加えて TagWithCallSite() を使うと、そのクエリを書いたファイル名と行番号が自動で埋め込まれます。

var blogs = await context.Blogs
.TagWith("1つ目")
.TagWith("2つ目")
.TagWithCallSite()
.ToListAsync();

こちらも同じ最小モデルで SQL 生成と実行を確認した例です。TagWithCallSite() のファイルパスと行番号は呼び出し元によって変わります。

-- 1つ目
-- 2つ目
-- File: /path/to/Program.cs:6
SELECT "b"."Id", "b"."Name"
FROM "Blogs" AS "b"

SQL Server のクエリストアや実行計画の分析ツールでは、このコメントごとキャプチャされるため、データベース側で見つけた重いクエリからアプリケーションのコードへ一発でたどり着けます。

パラメーター名が EF Core 10 で変わった

Section titled “パラメーター名が EF Core 10 で変わった”

ログや実行プランに現れる SQL パラメーターの名前は、EF Core 10 で変数名そのものに変わりました。

var city = "London";
var blogs = await context.Blogs.Where(b => b.City == city).ToListAsync(cancellationToken);

SQL Server で実測した SQL は次のとおりです。

-- EF Core 9 まで
@__city_0='London'
WHERE [b].[City] = @__city_0
-- EF Core 10 以降(実測)
@city='London'
WHERE [b].[City] = @city

名前が重複する場合にだけ数字が付きます。読みやすさの改善であり、ほとんどのアプリケーションには影響しませんが、次の 2 つは実害が出ます。

  • SQL の文字列を比較するテスト。 ToQueryString() の結果や、インターセプターで受け取った DbCommand.CommandText を期待値と突き合わせているコードのうち、変更されたパラメーター名を期待値に含めるものは更新が必要です。DbParameter.ParameterName を直接見ているコードも同様です(インターセプターの実装は付録4の「インターセプターによる横断的な処理」を参照)。
  • クエリプランのキャッシュ。 パラメーター名は SQL 文字列の一部なので、変更後の SQL に対してプランの再コンパイルが発生する可能性があります。公式も「大規模なシステムでは、配置直後に一時的なコンパイルのスパイクが起きることを見込んでおくべき」と述べています。負荷の高い時間帯を避けて配置してください。

本番環境でコマンドログを出し続けない

Section titled “本番環境でコマンドログを出し続けない”

公式ドキュメントは「本番環境でコマンドの実行ログを有効にしたままにするのは、たいてい悪い考えだ」と述べています。理由は 2 つあり、ログの出力自体がアプリケーションを遅くすることと、巨大なログファイルが短時間でサーバーのディスクを埋めることです。公式は、データを集めるなら「アプリケーションを注意深く監視しながら短時間だけ有効にする」か「本番前の環境で採取する」ことを勧めています。

以下は公式の注意点に対する条件付きの補足です。まず、SQL Server 2022 にウォームアップ 1 回と主キー検索 2,000 回を実行した場合の、LogTo のファイル出力量を示します。

項目実測値
ウォームアップ 1 回と主キー検索 2,000 回分のログ約 635 KiB(1 KiB = 1,024 バイト)
1 クエリあたり約 330 バイト

この目安を 1 クエリあたり 330 バイトとして、1 秒あたり 100 クエリを 24 時間処理すると、ログ量は 約 2.9 GB になります(330 × 100 × 86,400 = 2,851,200,000 バイト、1 GB = 10 億バイトで計算)。これは処理量からの試算であり、すべてのクエリが同じログ量になるという保証ではありません。ログの保存先を分けず、ローテーションも設定していないサーバーでは、これだけでディスクが埋まります。

次に実行時間です。出力先とデータベースが異なる 2 条件を示します。これらの差をデータベースの応答速度だけに帰属させる比較ではありません。

環境ログなしログあり差
SQL Server 2022(コンテナー、2,000 クエリ)1,431 ms1,392 ms差を検出できず
SQLite インメモリ(5,000 クエリ)243 ms324 ms約 1.33 倍遅い

ログなしの総時間をクエリ数で割ると、SQL Server は約 0.7 ミリ秒、SQLite は約 0.05 ミリ秒です。この SQLite の測定点ではログありの時間が約 33% 長いものの、SQL Server 側との差の理由や統計的な有意差は、この結果だけでは判断できません。


EF Core は、既定ではパラメーター値をログに出力しません。パラメーター名やサイズなどのメタデータは残りますが、値は ? に置き換えられます。次は SQL Server 2022 に対し、city というローカル変数で絞り込む確認例です。

[Parameters=[@city='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
SELECT [b].[Id], [b].[City], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[City] = @city

ただし EF Core は、状況によっては値を SQL に インライン化 します。EF.Constant() が代表例です。EF Core 10 の新機能では、このような本来パラメーターになる値のログ上のリダクションを説明しています。次は SQL Server 形式の模式例です。直接リテラル・EF.Constant()・通常のパラメーターの比較は EF Core 10.0.11 / SQLite で確認できています。

-- EF.Constant(name) を使ったクエリ
-- EF Core 9 まで
... WHERE [b].[Name] = N'Contoso'
-- EF Core 10 以降(既定)
... WHERE [b].[Name] = ?

デバッグのために実際のパラメーター値を見たい場合は EnableSensitiveDataLogging() を有効にします。

builder.Services.AddDbContext<BloggingContext>(options =>
{
options.UseSqlServer(connectionString);
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});

EF Core 10 は接続文字列に Application Name を追加する

Section titled “EF Core 10 は接続文字列に Application Name を追加する”

EF Core 10 の破壊的変更では、未指定の Application Name の自動追加と、EF 以外のアクセスとの接続プールの分離・分散トランザクションへの昇格の可能性を説明しています。次は SQL Server プロバイダーでの接続文字列の確認例です。

[渡した接続文字列]
Server=localhost;Database=Test;Trusted_Connection=True;TrustServerCertificate=True
[EF Core が使う文字列]
Data Source=localhost;Initial Catalog=Test;Integrated Security=True;
Trust Server Certificate=True;Application Name="EFCore/10.0.11 (macOS 26.6.2 Arm64)"

ほとんどの場合は影響しませんが、同じデータベースに EF Core と Dapper や ADO.NET などを併用している場合は注意が必要です。 SqlClient は接続文字列が異なると別の接続プールを使うため、両者が別々のプールに分かれます。同一の TransactionScope 内で別プールの接続を使うと、分散トランザクションへの昇格 が必要になる可能性があります。以下は接続を 2 本開いた条件での観測です。

次は同じ環境で接続文字列と開閉順序を変えた確認例です。表の結果は、あらゆる接続プールの状態で同じ動作を保証するものではありません。

TransactionScope 内での操作昇格するか
同じ接続文字列の接続を 2 本 同時に 開く昇格する
同じ接続文字列で、1 本目を閉じてから 2 本目を開く昇格しない
Application Name だけが違う接続を 2 本同時に開く昇格する
Application Name だけが違う接続を、1 本目を閉じてから 2 本目を開く昇格する

公式の破壊的変更が警告するのは、異なる接続文字列により接続プールが分かれ、以前は不要だった分散トランザクションへの昇格が必要になる可能性です。接続を順に閉じれば昇格しない、という一般契約を表の結果から導くことはできません。

公式の回避策は Application Name の明示です。EF Core 10.0.11 の実装では空文字列やドライバーの既定名も置換対象なので、アプリケーション固有の値を指定します。SQL Server 2022 の program_name の確認例では、未指定は EFCore/10.0.11 (macOS 26.6.2 Arm64)、明示指定は BloggingApi です。置換の細部はこの版の実装に対する補足です。

{
"ConnectionStrings": {
"BloggingDatabase": "Server=...;Database=Blogging;Trusted_Connection=True;Application Name=BloggingApi"
}
}

AddDbContext の競合する設定は後の値が優先される

Section titled “AddDbContext の競合する設定は後の値が優先される”

AddDbContext を同じ DbContext 型に対して複数回呼び出したとき、どちらの構成が使われるかは EF Core 8 で変わりました。競合する設定については、以前は最初の呼び出しが優先されましたが、現在は最後の呼び出しが優先されます。設定全体が置き換わるわけではなく、競合しない設定は組み合わせて適用されます。

services.AddDbContext<BloggingContext>(o => o.UseSqlServer(first));
services.AddDbContext<BloggingContext>(o => o.UseSqlServer(second));
// → second が使われる

公式の構成の優先順位では、登録順に構成を適用し、競合する項目だけ後の値を優先します。EF Core 10.0.11 / SQLite の確認例でも、接続先は後の値、最初の NoTracking は維持です。この版の実装は、TryAdd によるコンテキスト本体の登録と、構成処理の追加を分けています。本体の DI 登録とオプションの合成を混同しないでください。

複数の DbContext を登録するときは DbContextOptions を受け取る

Section titled “複数の DbContext を登録するときは DbContextOptions を受け取る”

DbContext のコンストラクターは、非ジェネリックの DbContextOptions も受け取れます。1 つしか DbContext を登録していないうちは問題なく動くため、そのまま書いてしまいがちです。しかし公式ドキュメントは、ジェネリックの DbContextOptions<TContext> を使うことを求めています。複数の DbContext を登録したときに、その型に対応する正しいオプションが DI から解決されるようにするためです。

// 推奨
public class GoodContext(DbContextOptions<GoodContext> options) : DbContext(options);
// 複数登録すると壊れる
public class BadContext(DbContextOptions options) : DbContext(options);

次は 2 つの DbContext を登録する確認例です。非ジェネリックのオプションを受け取る側では、登録順による結果の違いを確認できています。

登録順GoodContext の解決BadContext の解決
GoodContext → BadContext成功たまたま成功
BadContext → GoodContext成功失敗

この DI 構成では、非ジェネリック版は最後に登録された DbContextOptions を解決します。失敗側の確認結果は次の例外です。特定の登録順で成功しても、非ジェネリック版を使う根拠にはしないでください。

System.InvalidOperationException: The DbContextOptions passed to the BadContext constructor
must be a DbContextOptions<BadContext>. When registering multiple DbContext types, make sure
that the constructor for each context type has a DbContextOptions<TContext> parameter rather
than a non-generic DbContextOptions parameter.

「今は 1 つしかないから」と非ジェネリック版で書くと、2 つ目の DbContext を追加した日に、しかも登録順によっては別の開発者の環境でだけ壊れます。最初からジェネリック版で書いてください。

構成を後から足す(ConfigureDbContext)

Section titled “構成を後から足す(ConfigureDbContext)”

AddDbContext はコンテキスト自体を DI に登録し、同時にプロバイダーや接続文字列などのオプションも構成する呼び出しです。これに対して、コンテキストの登録とは分けてログや診断だけを足したいことがあります。テストで EnableSensitiveDataLogging を付けたい、共通ライブラリでインターセプターを差したい、といった場面です。

その用途には、EF Core 9 以降の ConfigureDbContext を使えます。公式リファレンスは、構成が呼び出し順に適用され、競合する設定は後の構成で上書きされると説明しています。AddDbContext の再呼び出しも、以前の構成全体を消す操作ではありません。 EF Core 10 / SQLite の確認例でも、最初の接続設定を維持したまま、2 回目で追加したログ設定の有効化を確認できています。

ConfigureDbContext 自体はコンテキストを DI に登録しないため、AddDbContext などとの併用が必要です。同 API だけでは解決できないことも確認できています。次は、共通側の診断設定とアプリケーション側の登録を分けた例です。

// ライブラリやテスト側:診断の設定だけを足す
services.ConfigureDbContext<BloggingContext>(options =>
options.LogTo(Console.WriteLine)
.EnableSensitiveDataLogging());
// アプリケーション側:プロバイダーと接続文字列を決める
services.AddDbContext<BloggingContext>(options =>
options.UseSqlServer(connectionString));

この順番で登録した確認例では、プロバイダーは SQL Server のまま、LogTo と EnableSensitiveDataLogging の有効化を確認できています。

プロバイダー: Microsoft.EntityFrameworkCore.SqlServer
Executed DbCommand (36ms) [Parameters=[@name='test' (Size = 4000)], ...]

@name='test' と実際の値が出力されているのが EnableSensitiveDataLogging の効果です。無効なら @name='?' と伏せられます。

公式ドキュメントによれば、ConfigureDbContext と AddDbContext は呼び出した順に適用され、競合する設定は後の呼び出しが勝ちます。競合しない設定(ログ、インターセプターなど)はすべて合成されます。上の例のように設定項目が競合しない場合は、ConfigureDbContext を AddDbContext の前後どちらに置いても構いません。

サーバー側の Blazor は、リクエストごとではなくユーザーの接続(サーキット)単位で状態を保持するアプリケーションフレームワークです。そのため Scoped の DbContext は、そのサーキット内の複数のコンポーネントで共有されます。DbContext はスレッドセーフではなく同時利用を想定していないため、公式ドキュメントは既存のライフタイムがいずれも適さないと説明しています。

ライフタイム公式が挙げる問題
Singletonアプリケーションの全ユーザーで状態が共有され、不適切な同時利用になる
Scoped(既定)同じユーザーのコンポーネント間で同様の問題が起きる
Transient要求ごとに新しいインスタンスになるが、コンポーネントが長寿命になり得るため、意図より長寿命なコンテキストになる

公式のスレッドの問題の回避では、同じ DbContext の並行操作を禁止し、未検出でも安全ではないとしています。次は同じインスタンスの操作を重ねた確認例の例外です。常にこの型で検出されるという保証ではありません。

System.InvalidOperationException: A second operation was started on this context
instance before a previous operation completed. This is usually caused by
different threads concurrently using the same instance of DbContext.

操作ごとに別の IDbContextFactory<T> 由来のインスタンスを使う確認例では、同じ 2 操作の成功を確認できています。これはデータベース上の競合を含むあらゆる例外を防ぐという意味ではありません。

公式ドキュメントが示す指針は次のとおりです。

  • 操作ごとに 1 つのコンテキストを使うことを検討する(DbContext は生成コストが小さくなるよう設計されている)
  • 同時実行を防ぐフラグ(Loading など)を用意する。これはデータベース行のロックが目的ではなく、取得中に UI 操作をさせないためのもの
  • 同じコード部分に複数のスレッドが入る可能性があるなら、ファクトリを注入して操作ごとに新しいインスタンスを作る
  • 変更追跡や同時実行制御を活かす長めの操作では、コンテキストをコンポーネントの寿命に合わせる

DbContext プーリングと接続プーリングは別物

Section titled “DbContext プーリングと接続プーリングは別物”

公式ドキュメントは「コンテキストプーリングはデータベース接続プーリングとは直交する」と明言しています。混同しやすいので整理します。

DbContext プーリング接続プーリング
何をプールするかDbContext インスタンスデータベース接続
誰が管理するかEF Coreデータベースドライバー(Microsoft.Data.SqlClient などの ADO.NET プロバイダー)
目的インスタンスの割り当てと初期化コストの削減接続の確立・切断コストの削減
設定方法AddDbContextPool の poolSize接続文字列(Max Pool Size など、ドライバーのドキュメントに従う)
既定無効(AddDbContext を使うため)通常は有効

EF Core は接続プーリングを下位のドライバーに任せ、通常は操作の直前に接続を開き、直後に閉じて返却します。次は IDbConnectionInterceptor で開閉を確認した例です。同じ DbContext の 3 クエリで 3 回、明示的に開く条件では 1 回の開閉を確認できています。

操作接続の開閉回数
同一 DbContext インスタンスでクエリを 3 回実行opened=3 closed=3
OpenConnectionAsync で明示的に開いてからクエリを 3 回実行opened=1 closed=1

Database.OpenConnectionAsync() で明示的に開いた場合は、CloseConnectionAsync() を呼ぶまで接続が保持されます。ここで注意が必要なのは、EF Core がリセットするのは DbContext とその関連サービスの内部状態だけで、下位のドライバーの状態は元に戻さない 点です。手動で DbConnection を開いたり ADO.NET の状態を操作したりした場合、インスタンスをプールに返す前に元へ戻す責任は利用者側にあります。閉じ忘れると、無関係なリクエストへ状態が漏れる可能性があると公式ドキュメントは警告しています。

サードパーティー製プロバイダーはバージョンを実際に確かめる

Section titled “サードパーティー製プロバイダーはバージョンを実際に確かめる”

公式のプロバイダー一覧は、通常、異なる EF Core メジャーバージョンとの互換性はないと注意しています。サードパーティー製の対応状況は提供元の案内と依存関係も確認してください。次は指定バージョンでの限定した操作の確認例です。復元や操作の成功を、提供元が示す対応範囲の拡張根拠にはしません。

パッケージ今回指定して復元した版EF Core 10 のプロジェクトで確認した範囲
Npgsql.EntityFrameworkCore.PostgreSQL10.0.3EF Core 10.0.11 と PostgreSQL 17.5 の追試で保存・クエリを確認。旧 PostgreSQL 16 の実測は EF Core 10.0.4 の別条件
Pomelo.EntityFrameworkCore.MySql9.0.0NU1608 警告(下記)。ビルドは通るが、この構成の Context を利用すると例外になる
warning NU1608: 依存関係の制約外で検出されたパッケージのバージョン:
Pomelo.EntityFrameworkCore.MySql 9.0.0 では
Microsoft.EntityFrameworkCore.Relational (>= 9.0.0 && <= 9.0.999) が必要ですが、
バージョン Microsoft.EntityFrameworkCore.Relational 10.0.11 は解決されました。

この警告を無視してこの構成の Context を初期化すると、接続の成功以前に次の型読み込みの例外になります。ビルドが通ることは動作の保証になりません。

System.MissingMethodException: Method not found:
'System.String Microsoft.EntityFrameworkCore.Diagnostics.AbstractionsStrings.ArgumentIsEmpty(System.Object)'.

つまり 公式一覧だけでも NuGet だけでも判断せず、両方を確認してください。 サードパーティー製プロバイダーを使うプロジェクトでは、EF Core のバージョンをプロバイダーの対応状況に合わせて決めるのが安全です。

パッチ版の Relational は自分で直接参照する

Section titled “パッチ版の Relational は自分で直接参照する”

公式ドキュメントは、リレーショナルプロバイダーを使うときの注意として次を挙げています。EF Core のパッチリリースには Microsoft.EntityFrameworkCore.Relational の更新が含まれることが多い一方、プロバイダーは EF Core とは独立してリリースされるため、新しいパッチ版に依存するよう更新されているとは限らない、というものです。

NuGet は推移的な依存関係について「条件を満たす最も低いバージョン」を選ぶため、プロバイダーが古いパッチ版を指していると、そのまま古い Relational が使われます。次はその確認例です。

# SqlServer 10.0.0 だけを参照した状態
Microsoft.EntityFrameworkCore.SqlServer 10.0.0 10.0.0
Microsoft.EntityFrameworkCore.Relational 10.0.0 ← 推移的

Relational のパッチ版を直接参照すると、そちらが使われます。

Terminal window
dotnet add package Microsoft.EntityFrameworkCore.Relational --version 10.0.11
Microsoft.EntityFrameworkCore.Relational 10.0.11 10.0.11 ← 直接参照
Microsoft.EntityFrameworkCore.SqlServer 10.0.0 10.0.0

公式は、独立してリリースされるプロバイダーの依存指定が追いつかない場合に備え、Microsoft.EntityFrameworkCore.Relational のパッチ版をアプリケーションの直接の依存関係として追加することを推奨しています。ただし、上の混在例は NuGet の解決結果を示すもので、Microsoft 製パッケージを異なるバージョンで運用する推奨構成ではありません。

Microsoft が提供する Microsoft.EntityFrameworkCore.* パッケージは同じバージョンに揃えてください。 これは公式の NuGet パッケージ案内に明記されています。SqlServer 自体を 10.0.11 とする確認例では、推移的な Relational も 10.0.11 です。Relational だけの更新を、プロバイダー自身の修正まで取り込む方法と取り違えないでください。


プロバイダーの品質は仕様テストの実装状況で見当をつける

Section titled “プロバイダーの品質は仕様テストの実装状況で見当をつける”

「そのプロバイダーが SQL Server 用プロバイダーと同じように動くか」を判断したいとき、手がかりになるのが EF Core の仕様テストスイート です。EF Core は自分のプロバイダー(SQL Server、SQLite、Azure Cosmos DB)の主要な回帰テスト機構として使っているテスト群を、そのまま NuGet パッケージとして公開しています。公式ドキュメントは、すべてのプロバイダーにこれを実装することを推奨しています。

パッケージ反射で数えた属性付きテストメソッド定義数(10.0.11)
Microsoft.EntityFrameworkCore.Specification.Tests7,523
Microsoft.EntityFrameworkCore.Relational.Specification.Tests1,553

合計は 9,076 メソッド定義です。Theory の入力ごとの実行ケース数やテスト成功件数ではありません。ここで重要なのは、公式ドキュメントが「テストスイートはかなり大きいので、すべてを実装する必要はない。特定のテストクラスだけを取り込んで、時間をかけて少しずつカバー範囲を広げていくのでまったく問題ない」と明言していることです。

つまり、仕様テストを採用しているプロバイダーであっても、どこまで通しているかはプロバイダーごとに違います。 サードパーティー製プロバイダーを採用する際は、リポジトリでこのパッケージを参照しているか、どのテストクラスを取り込んでいるかを確認すると、公式プロバイダーとの機能差を推し量る材料になります。

拡張ライブラリの対応バージョンは一覧より新しいことがある

Section titled “拡張ライブラリの対応バージョンは一覧より新しいことがある”

プロバイダーだけでなく、EF Core の拡張ライブラリも公式ドキュメントに一覧があります。各項目には「対応する EF Core: 5-9」のような対応バージョンが併記されていますが、公式ドキュメント自身が「拡張はさまざまな提供元によって構築されており、EF Core プロジェクトの一部として保守されていない。品質・ライセンス・互換性・サポートなどが要件を満たすか評価すること。特に古いバージョン向けに作られた拡張は、最新バージョンで動かすには更新が必要な場合がある」と注意しています。

次は、EF Core 10 のプロジェクトに指定版を復元した確認例です。2 つの拡張の DDL 生成も確認できていますが、これを互換性・サポートの根拠にはしません。掲載時の一覧の表記と、提供元の対応範囲を区別してください。

パッケージ公式一覧の記載実際に復元されたバージョン
EFCore.CheckConstraints5-910.0.0
EFCore.NamingConventions3-910.0.1
EFCore.BulkExtensions2-810.0.1

指定した 3 パッケージの復元と、UseSnakeCaseNamingConvention() / UseEnumCheckConstraints() の併用による次の DDL 生成を確認できています。全機能の対応を保証するものではありません。

-- SQL Server
CREATE TABLE [products] (
[id] int NOT NULL IDENTITY,
[name] nvarchar(max) NOT NULL,
[unit_count] int NOT NULL,
[kind] int NOT NULL,
CONSTRAINT [pk_products] PRIMARY KEY ([id]),
CONSTRAINT [CK_products_kind_Enum] CHECK ([kind] IN (0, 1))
);

採用時は提供元が示す対応バージョン、依存関係、ライセンスとサポートを確認します。アプリケーションでの動作確認は、その対応範囲内で必要な操作を確かめるためのものであり、非対応の構成を対応済みと読み替える根拠にはしません。

Singleton やバックグラウンドサービスから DbContext を使う

Section titled “Singleton やバックグラウンドサービスから DbContext を使う”

Singleton サービスやバックグラウンドサービスから DbContext を使う場合は、IServiceScopeFactory でスコープを作るか、IDbContextFactory<T> を使います。

公式ドキュメントは BackgroundService のようなホステッドサービスについて、スコープ付きの依存関係を直接コンストラクター注入せず、IServiceScopeFactory を注入してスコープを作り、そのスコープから解決するよう案内しています。EF Core 側の公式ドキュメントも、複数のスレッドから使う場合の手段として IServiceScopeFactory によるスコープ作成を挙げています。

public class ReportWorker(
IServiceScopeFactory scopeFactory,
ILogger<ReportWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// スコープを作り、その中から DbContext を解決する
using var scope = scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<BloggingContext>();
logger.LogInformation(
"ブログ件数={Count}",
await context.Blogs.CountAsync(stoppingToken));
}
}

DbContext を直接コンストラクターで受け取る形と、この形の実測結果は次のとおりです。

実装環境結果
BackgroundService(BloggingContext ctx)DevelopmentAggregateException → Cannot consume scoped service 'BloggingContext' from singleton 'Microsoft.Extensions.Hosting.IHostedService'.
同上Production例外なく起動してしまう
IServiceScopeFactory でスコープを作るDevelopment正常に動作し、クエリが実行された
builder.Services.AddDbContextFactory<BloggingContext>(options =>
options.UseSqlServer(connectionString));
public class ReportGenerator(IDbContextFactory<BloggingContext> contextFactory)
{
public async Task<int> CountBlogsAsync(CancellationToken cancellationToken)
{
// ファクトリで作成したインスタンスはアプリケーション側で破棄する
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
return await context.Blogs.CountAsync(cancellationToken);
}
}

DbContext を頻繁に作り直す場合は、プール対応のファクトリから取得できます。AddPooledDbContextFactory が再利用するのはファクトリ自体ではなく Context インスタンスです。仕組みは DbContext プーリングと同じです。

builder.Services.AddPooledDbContextFactory<BloggingContext>(options =>
options.UseSqlServer(connectionString));

公式のプーリングの説明では、返却したインスタンスの状態をリセットし再利用します。EF Core 10.0.11 / SQLite で取得と破棄を 4 回逐次繰り返す確認例では、通常のファクトリは別インスタンス、プール対応は同じインスタンスです。後者の再取得時の追跡数は 0 と確認できています。毎回同じインスタンスが返る保証ではありません。

登録方法実装型4 回取得したときのインスタンス数
AddDbContextFactoryDbContextFactory<T>4
AddPooledDbContextFactoryPooledDbContextFactory<T>1

クエリの絞り込みや並べ替えに使う列にはインデックスを作成します。

// 単一列
modelBuilder.Entity<Post>().HasIndex(p => p.PublishedAt);
// 複合インデックス(列の順序が重要)
modelBuilder.Entity<Post>().HasIndex(p => new { p.BlogId, p.PublishedAt });
// 一意インデックス
modelBuilder.Entity<Blog>().HasIndex(b => b.Url).IsUnique();
// フィルター選択されたインデックス
modelBuilder.Entity<Post>().HasIndex(p => p.Title).HasFilter("[Title] IS NOT NULL");

インデックスが効く条件と効かない条件

Section titled “インデックスが効く条件と効かない条件”

公式のインデックス利用の指針は、複合インデックスの列順や、列に式を適用する条件に注意し、実行プランを調べるよう案内しています。次はその補足として、SQL Server 2022 の 20,000 行・統計更新済みのテーブルで、手書きの SELECT Id を SET SHOWPLAN_ALL ON で確認した結果です。LINQ 欄は条件の対応づけであり、LINQ 生成 SQL の実行結果ではありません。

条件に対応する書き方手書き SQL で調べた条件推定プランの物理演算子
Title.StartsWith("T0001")LIKE N'T0001%'Index Seek
Title.EndsWith("A")LIKE N'%A'Index Scan
複合インデックス (BlogId, Price) を BlogId で絞るBlogId = 7Index Seek
同じインデックスを Price だけで絞るPrice = 7Index Scan
同じインデックスを両方で絞るBlogId = 7 AND Price = 7Index Seek
列に対する式で絞るPrice / 2 = 7Index Scan

公式の指針に沿って、次の点を検討します。

  1. 複合インデックスの列順を検索条件に合わせる。 表のシーク・スキャンはこのデータと SQL の結果です。Index Scan もインデックスを読む演算子で、「未使用」という意味ではありません。
  2. 列に式を適用する場合は計算列や式インデックスを検討する。 公式は、永続化された計算列へのインデックスや、対応するデータベースの式インデックスを挙げています。表の Price / 2 = 7 はスキャンですが、その演算子をすべての環境の固定結果とはしません。

この節と次の付加列のコード例には、本編とは別の IndexQuerySample モデルを使います。旧計算列の推定プラン実測には別データベースの Post2 を使っており、上の手書き SQL の表と同一モデルではありません。以下の db は SQL Server を構成した IndexContext です。Price / HalfPrice を本編の Post へ追加する必要はありません。

using Microsoft.EntityFrameworkCore;
namespace IndexQuerySample;
public class Post
{
public int Id { get; set; }
public int BlogId { get; set; }
public string Title { get; set; } = "";
public decimal Price { get; set; }
public decimal HalfPrice { get; set; }
}
public class IndexContext(DbContextOptions<IndexContext> options) : DbContext(options)
{
public DbSet<Post> Posts => Set<Post>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.Property(p => p.HalfPrice)
.HasComputedColumnSql("[Price] / 2", stored: true);
modelBuilder.Entity<Post>().HasIndex(p => p.HalfPrice);
}
}

別データベースの計算列用テーブル Post2(20,000 行、HalfPrice にインデックスあり)では、WHERE [HalfPrice] = 7 の Index Seek を確認できています。計算列については「計算列」も参照してください。

インデックスに列を含める(付加列)

Section titled “インデックスに列を含める(付加列)”

絞り込みには使わないが取得はする列を、インデックスのキーではない列としてインデックスに持たせられます。公式ドキュメントは「クエリで使うすべての列がキー列か非キー列としてインデックスに含まれていれば、テーブル自体にアクセスする必要がなくなるため、クエリのパフォーマンスが大きく向上する可能性がある」と説明しています。

次の構成は、直前の IndexContext.OnModelCreating の末尾へ追加します。

modelBuilder.Entity<Post>()
.HasIndex(p => p.Price)
.IncludeProperties(p => p.Title);

次は、SQL Server 2022 向けのこのモデルで GenerateCreateScript() により確認できている DDL です。

CREATE INDEX [IX_Posts_Price] ON [Posts] ([Price]) INCLUDE ([Title]);

次は、20,000 行の Posts から Price で絞り込み Title と Price を取得する条件で、INCLUDE の有無を SET SHOWPLAN_ALL ON により比較した確認例です。

var matches = await db.Posts
.Where(p => p.Price == 7m)
.Select(p => new { p.Title, p.Price })
.ToListAsync(cancellationToken);
インデックスの定義実行プランに現れた物理演算子
([Price])Index Seek + Clustered Index Seek + Nested Loops
([Price]) INCLUDE ([Title])Index Seek のみ

この推定プランでは、INCLUDE なしの場合は Title を取得するキー参照があり、付加列に含める場合はインデックスだけで完結します。クライアントとサーバーの通信往復を比較した結果ではありません。

SQL Server 固有のインデックス構成

Section titled “SQL Server 固有のインデックス構成”

インデックスの細かな性質はデータベースごとに異なるため、EF Core はプロバイダー固有の API で構成します。SQL Server プロバイダーではクラスター化とフィル ファクターを指定できます。

modelBuilder.Entity<Blog>().HasIndex(b => b.PublishedOn).IsClustered();
modelBuilder.Entity<Blog>().HasIndex(b => b.PublishedOn).HasFillFactor(80);

次は IsClustered(false) と HasFillFactor(80) を指定したモデルの GenerateCreateScript() の確認結果です。

CREATE NONCLUSTERED INDEX [IX_Posts_Price] ON [Posts] ([Price]) WITH (FILLFACTOR = 80);

オンラインで作成する場合は、HasIndex(...).IsCreatedOnline() を指定します。SQL Server 2022 Developer Edition で、このモデルからの次の DDL 生成と作成成功を確認できています。

-- SQL Server
CREATE INDEX [IX_OnlineItems_Name]
ON [OnlineItems] ([Name]) WITH (ONLINE = ON);

同じインデックス設定に SortInTempDb() と UseDataCompression(DataCompressionType.Page) を追加すると、DDL は次のようになります。SQL Server 2022 Developer Edition で、この組み合わせの作成と読み書きを確認できています。

-- SQL Server
CREATE INDEX [IX_OnlineItems_Name] ON [OnlineItems] ([Name])
WITH (ONLINE = ON, SORT_IN_TEMPDB = ON, DATA_COMPRESSION = PAGE);

DataCompressionType.None / Row / Page の 3 条件で、sys.partitions.data_compression_desc はそれぞれ NONE / ROW / PAGE と確認できています。設定の反映の確認であり、圧縮率や tempdb 使用量、性能改善の測定ではありません。

実行プランはデータの量で変わる

Section titled “実行プランはデータの量で変わる”

公式ドキュメントは、実行プランを分析するときの前提として「データベースは、実際に入っているデータに応じて異なるクエリプランを生成することがある。たとえばテーブルに数行しかなければ、インデックスを使わずにテーブル全体をスキャンすることを選ぶかもしれない。テスト用データベースでプランを分析するなら、本番と似たデータが入っていることを必ず確認すること」と述べています。ベンチマークについても同じ注意が繰り返されています。

次は SQL Server 2022 CU26 で同じクエリ・インデックスを使い、5 行と 100,005 行の推定プランを比較した結果です。Filler は varchar(8000) に 8,000 文字、Rating は 1〜5 の繰り返しで、両条件とも統計は FULLSCAN で更新済みです。列幅や値の分布も関わるため、行数だけで一般化しないでください。経過時間の比較ではありません。

-- SQL Server(Rating には非クラスター化インデックスがある)
SELECT Id, Rating, Filler FROM Blogs WHERE Rating = 3;
テーブルの状態選ばれた物理演算子
5 行Clustered Index Scan
100,005 行(Rating = 3 は 20,001 件)Index Seek + Key Lookup

この条件では、少量側はスキャン、多量側はシークとキー検索です。20% という一致率だけでプランは予測できません。また Clustered Index Scan はインデックス不使用を意味しません。


コレクションのパラメーター化と IN 句の翻訳

Section titled “コレクションのパラメーター化と IN 句の翻訳”

Contains でコレクションを絞り込み条件に使うと、EF Core はそれを IN 句へ変換します。この変換方法は EF Core 10 で既定値が変わりました。

int[] ids = [1, 2, 3];
var blogs = await context.Blogs.Where(b => ids.Contains(b.Id)).ToListAsync();
バージョン既定の翻訳生成される SQL(要点)
EF Core 8・9JSON 配列を 1 つのパラメーターとして送るWHERE [b].[Id] IN (SELECT [i].[value] FROM OPENJSON(@ids) WITH (...) AS [i])
EF Core 10 以降要素ごとに個別のパラメーターを送るWHERE [b].[Id] IN (@ids1, @ids2, @ids3)

以下は EF Core 10.0.11 の SQL Server プロバイダーで、接続を開かずに生成した SQL の比較です。Parameter を指定した形は EF Core 8・9 の既定に相当し、配列全体を 1 つのパラメーターで渡して OPENJSON で展開します。

-- EF Core 10 の既定(MultipleParameters)
DECLARE @ids1 int = 1;
DECLARE @ids2 int = 2;
DECLARE @ids3 int = 3;
SELECT [b].[Id], [b].[Name] FROM [Blogs] AS [b]
WHERE [b].[Id] IN (@ids1, @ids2, @ids3)
-- EF Core 9 までの既定(Parameter)
DECLARE @ids nvarchar(4000) = N'[1,2,3]';
SELECT [b].[Id], [b].[Name] FROM [Blogs] AS [b]
WHERE [b].[Id] IN (
SELECT [i].[value]
FROM OPENJSON(@ids) WITH ([value] int '$') AS [i]
)

公式の EF Core 10 の説明では、個別パラメーター方式は件数をプラン選択に利用できる一方、SQL の種類を抑えるためパラメーター数をパディングします。10.0.11 の確認例では、6・7・8・9 要素はいずれも 10 パラメーターの同じ SQL です。具体的なパディング数を将来の版の保証とはせず、プランと実行結果で方式を比較してください。

翻訳方法は DbContext 全体でも、クエリ単位でも切り替えられます。

// DbContext 全体で切り替える
options.UseSqlServer(connectionString,
o => o.UseParameterizedCollectionMode(ParameterTranslationMode.Parameter));
// クエリ単位で切り替える
// JSON 配列パラメーター 1 つ(EF Core 8・9 の既定と同じ形)
await context.Blogs
.Where(b => EF.Parameter(ids).Contains(b.Id))
.ToListAsync();
// 定数としてインライン化
await context.Blogs
.Where(b => EF.Constant(ids).Contains(b.Id))
.ToListAsync();

生成される SQL は次のとおりです(EF Core 10.0.11 / SQL Server プロバイダーでの生成確認。データベースへの発行ではありません)。

-- 何も指定しない場合(EF Core 10 の既定 = MultipleParameters)
DECLARE @ids1 int = 1; DECLARE @ids2 int = 2; DECLARE @ids3 int = 3;
WHERE [b].[Id] IN (@ids1, @ids2, @ids3)
-- EF.Parameter(ids)
DECLARE @ids nvarchar(4000) = N'[1,2,3]';
WHERE [b].[Id] IN (SELECT [i].[value] FROM OPENJSON(@ids) WITH ([value] int '$') AS [i])
-- EF.Constant(ids)
WHERE [b].[Id] IN (1, 2, 3)
ParameterTranslationMode動作
MultipleParameters要素ごとのパラメーター。EF Core 10 の既定
ParameterJSON 配列パラメーター 1 つ。EF Core 8・9 の既定
Constant値を SQL に直接埋め込む。EF Core 7 までの既定

公式のコンパイル済みクエリの説明では、通常のクエリキャッシュでも必要な式ツリーの比較・検索を、デリゲートの直接呼び出しで省くとしています。多くのアプリケーションではこの前処理の影響は小さく、採用前に自分の環境で計測するよう案内されています。

条件付きの補足として、SQLite の 1 件検索を 3,000 回繰り返した確認例では、1 回あたり通常 0.162 ミリ秒、コンパイル済み 0.124 ミリ秒です。この比率を一般的な効果や採用基準にはせず、実際のボトルネックを確認してください。

public class BlogQueries
{
private static readonly Func<BloggingContext, int, IAsyncEnumerable<Blog>> GetBlogsByRating =
EF.CompileAsyncQuery((BloggingContext context, int rating) =>
context.Blogs.Where(b => b.Rating == rating));
public async Task<List<Blog>> FindAsync(BloggingContext context, int rating)
{
var results = new List<Blog>();
await foreach (var blog in GetBlogsByRating(context, rating))
{
results.Add(blog);
}
return results;
}
}

コンパイル済みクエリは static な読み取り専用フィールドとして保持し、アプリケーションの寿命を通じて再利用します。公式ドキュメントは制限として次の 2 点を挙げています。

  • 使用できるのは 1 つの EF Core モデルに対してのみです。同じ型の DbContext が異なるモデルを使うように構成されている場合、コンパイル済みクエリはサポートされません
  • パラメーターには単純なスカラー値を使います。インスタンスのメンバーアクセスやメソッド呼び出しのような、より複雑なパラメーター式はサポートされません

コンパイル済みクエリの中では EF.Constant と EF.Parameter が使えない

Section titled “コンパイル済みクエリの中では EF.Constant と EF.Parameter が使えない”

公式の EF Core 9 の破壊的変更では、EF.Constant と EF.Parameter はコンパイル済みクエリ内で使用できないとしています。IN 句の翻訳で使う通常クエリとは区別してください。

公式が挙げる理由は、定数値ごとの再コンパイルを避けるために、これらのメソッドの処理をクエリキャッシュより後の段階へ移し、その実装がコンパイル済みクエリと非互換になったことです。これらの指定が必要なら通常のクエリを使います。

値変換に使うメソッドを private にしてはいけない

Section titled “値変換に使うメソッドを private にしてはいけない”

公式の EF Core 9 の破壊的変更では、NativeAOT 対応のため、コンパイル済みモデルが値変換メソッドを直接参照するようになったと説明しています。private では生成コードのコンパイルが失敗するため、public または internal にします。

public sealed class BooleanToCharConverter()
: ValueConverter<bool, char>(v => ConvertToChar(v), v => ConvertToBoolean(v))
{
public static readonly BooleanToCharConverter Default = new();
private static char ConvertToChar(bool value) => value ? 'Y' : 'N'; // private だと失敗する
private static bool ConvertToBoolean(char value) => value == 'Y';
}

このモデルの確認例では、dotnet ef dbcontext optimize は成功し、生成コードを含むビルドで次の失敗を確認できています。

CompiledModels/FlaggedEntityType.cs(64,124): error CS0122:
'BooleanToCharConverter.ConvertToChar(bool)' はアクセスできない保護レベルになっています

この条件の生成コードでは、メソッドが次のように直接参照されています。

string (bool v) => string.Format(CultureInfo.InvariantCulture, "{0}",
((object)(BooleanToCharConverter.ConvertToChar(v)))),

公式の対処に沿って internal に変更した条件では、ビルドの成功を確認できています。

エンティティ数が数百に及ぶ大規模なモデルでは、起動時のモデル構築に時間がかかります。コンパイル済みモデル はモデル構築をビルド時に済ませ、起動時間を短縮します。

Terminal window
dotnet ef dbcontext optimize

生成されたモデルを使うように設定します。

builder.Services.AddDbContext<BloggingContext>(options =>
options.UseSqlServer(connectionString)
.UseModel(BloggingContextModel.Instance));

NativeAOT と事前コンパイル済みクエリ

Section titled “NativeAOT と事前コンパイル済みクエリ”

.NET の NativeAOT は、アプリケーションを事前 (ahead-of-time) にネイティブコードへコンパイルして発行する仕組みです。起動が速く、自己完結した小さなバイナリになります。EF Core もこれに対応するための仕組みを持っていますが、現時点では実験的機能です。

仕組みは クエリの事前コンパイル (query precompilation) です。ソースコードを静的に解析して EF Core の LINQ クエリを見つけ、C# の インターセプター (interceptor) を生成します。次の確認例でも、生成コードに SQL の埋め込みを確認できています。

new RelationalCommand(
materializerLiftableConstantContext.CommandBuilderDependencies,
"SELECT \"b\".\"Id\", \"b\".\"Name\"\nFROM \"Blogs\" AS \"b\"\nWHERE \"b\".\"Name\" <> 'foo'\nORDER BY \"b\".\"Id\"",
...)

有効にするにはプロジェクトファイルに 2 つのプロパティと 1 つのパッケージが要ります。

<PropertyGroup>
<PublishAot>true</PublishAot>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.EntityFrameworkCore.GeneratedInterceptors</InterceptorsNamespaces>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tasks" Version="10.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

Microsoft.EntityFrameworkCore.Tasks は MSBuild タスクを提供するパッケージで、PublishAot が true なら 発行時にコンパイル済みモデルと事前コンパイル済みクエリの生成処理を実行します。確認記録の Optimizing DbContext... は開始ログで、その実行は後続の事前コンパイルで失敗です。開始ログを発行成功と取り違えないでください。生成時期は MSBuild プロパティで制御できます。

MSBuild プロパティ意味
EFScaffoldModelStageコンパイル済みモデルを生成する段階。publish / build / none。既定は publish
EFPrecompileQueriesStage事前コンパイル済みクエリを生成する段階。同上
DbContextName対象の DbContext。省略するとプロジェクト内のすべてが対象
EFTargetNamespace生成されるクラスの名前空間。省略時は $(RootNamespace)
EFOutputDir生成ファイルの出力先。省略時は $(IntermediateOutputPath)

発行せずに生成結果だけを確認したい場合は CLI から実行できます。

Terminal window
dotnet ef dbcontext optimize --precompile-queries --nativeaot

この確認例の出力先は、モデルが CompiledModels/、インターセプターが Generated/Program.EFInterceptors.AppDb.cs です。

最大の制約は「動的クエリが書けない」こと

Section titled “最大の制約は「動的クエリが書けない」こと”

事前コンパイルは静的解析なので、条件によって演算子を組み立てるクエリは扱えません。

// 事前コンパイルできない
IQueryable<Blog> q = db.Blogs.OrderBy(b => b.Id);
if (applyFilter) q = q.Where(b => b.Name != "foo");
return await q.ToListAsync();

このコードの確認例では、dotnet ef dbcontext optimize --precompile-queries で次のエラーを確認できています。公式が示す非対応条件の補足であり、すべての動的クエリで同じエラー形式になる保証ではありません。

Query precompilation failed with errors:
QueryPrecompilationError { SyntaxNode = q.ToListAsync(),
Exception = System.InvalidOperationException: Dynamic LINQ queries are not supported when precompiling queries. }

公式が案内する対処は、動的な組み立てを 複数の静的なクエリに分解する ことです。

IAsyncEnumerable<Blog> GetBlogs(BlogContext context, bool applyFilter)
=> applyFilter
? context.Blogs.OrderBy(b => b.Id).Where(b => b.Name != "foo").AsAsyncEnumerable()
: context.Blogs.OrderBy(b => b.Id).AsAsyncEnumerable();

このほかに、クエリ式構文(from x in ... の書き方)が未対応であること、生成されるコードが大きく生成に時間がかかること、キャプチャした状態を使う値変換器が未対応であることが公式に制約として挙げられています。

NativeAOT なしで事前コンパイルだけ使う

Section titled “NativeAOT なしで事前コンパイルだけ使う”

公式の NativeAOT なしの事前コンパイルでは、通常発行でも対応クエリの事前コンパイルを利用できるとしています。次は PublishAot=false の確認例です。

Terminal window
dotnet ef dbcontext optimize --precompile-queries

公式は、通常発行では動的クエリなども利用しながら、事前コンパイルできるクエリの起動コストを省けると説明しています。静的クエリだけの最小構成でも、生成・通常発行・実行の成功と、実行時の QueryCompilationStarting 0 件を確認できています。この観測は、動的クエリとの併用可否を一般的に制限する根拠ではありません。生成結果と実際の実行経路を確認してください。

DbContext プーリングでインスタンスを使い回す

Section titled “DbContext プーリングでインスタンスを使い回す”

公式の DbContext プーリングでは、AddDbContextPool によりインスタンスと内部サービスの初期化コストを削減できるとしています。一方、DbContext は通常軽量で、多くのアプリケーションでは生成・破棄の影響は小さいと説明しています。採用は実際の処理全体の計測に基づいて判断します。

補足として、取得・Blogs.Local.Count へのアクセス・破棄を 3,000 回繰り返す確認例では、1 回あたり通常 0.141 ミリ秒・約 62 KB、プーリングあり 0.0015 ミリ秒・512 バイトです。データベースへのクエリを含まないため、エンドポイント全体の改善率ではありません。

次は、ASP.NET Core から SQL Server の主キー 1 件を読むエンドポイントで、登録だけを入れ替えた確認例です。同時 100 リクエスト、15 秒ずつの条件を示します。

登録方法スループット(測定が安定したあとの 2 回)
AddDbContext8,186 req/s / 8,482 req/s
AddDbContextPool9,506 req/s / 9,550 req/s

この 2 回の平均では、プーリングありのスループットが約 14% 高い結果です。前のクエリを含まない測定とは指標も処理範囲も異なります。比率や順位をほかの条件へ一般化せず、自分のエンドポイントで評価してください。

builder.Services.AddDbContextPool<BloggingContext>(
options => options.UseSqlServer(connectionString),
poolSize: 1024);

poolSize は保持するインスタンスの最大数で、既定は 1024 です。公式は上限を超えるとキャッシュされず、非プーリングの動作に戻ると説明しています。poolSize: 2 で 5 スコープを同時に保持する確認例でも、上限による待機や例外なしを確認できています。同時実行数を制限する設定ではありません。

EF Core の既定は スナップショット変更追跡 (snapshot change tracking) です。エンティティを追跡し始めるときに全プロパティの値を内部に複製しておき、保存時にその複製と現在値を比較して変更を洗い出します。この比較を行うのが ChangeTracker.DetectChanges で、次のメソッドは結果を正しくするために自動的に呼び出します。

  • SaveChanges / SaveChangesAsync
  • ChangeTracker.Entries() / ChangeTracker.Entries<TEntity>()
  • ChangeTracker.HasChanges()
  • ChangeTracker.CascadeChanges()
  • DbSet<TEntity>.Local

この自動呼び出しは ChangeTracker.AutoDetectChangesEnabled で無効にできますが、安易に触ってはいけません。

エンティティ数が多く、変更検出がボトルネックなら、公式の変更追跡プロキシも選択肢です。Microsoft.EntityFrameworkCore.Proxies と UseChangeTrackingProxies() により、通知インターフェイスを持つ派生型を動的に生成します。この確認例でも、AutoDetectChangesEnabled = false でプロパティを変更した直後に Modified を確認できています。

導入時には、次の制約を確認してください。

変更追跡プロキシは動的な派生型を作るため、エンティティのプロパティを virtual にする、コンストラクターに制約が付くといった条件が付きます。プロキシを使わずに、自分で INotifyPropertyChanged / INotifyPropertyChanging を実装することもできます。

public class Blog : INotifyPropertyChanged, INotifyPropertyChanging
{
private int _id;
private string _name = "";
public int Id
{
get => _id;
set
{
PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(Id)));
_id = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Id)));
}
}
public string Name
{
get => _name;
set
{
PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(Name)));
_name = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
public event PropertyChangingEventHandler? PropertyChanging;
}

Id は規約により主キーになります。公式の通知エンティティの例と同様に、Name だけでなく Id にも変更前後の通知を実装しています。

重要なのは、インターフェイスを実装しただけでは EF Core は通知イベントを購読しないという点です。公式ドキュメントは「EF Core はこれらのインターフェイスが正しく実装されているかを検証できないため、自動的にはイベントを購読しない」と明記しています。モデル側で戦略を指定する必要があります。

protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.HasChangeTrackingStrategy(
ChangeTrackingStrategy.ChangedNotifications);

次は 3 パターンの確認例です。いずれも AutoDetectChangesEnabled = false で、読み込んだエンティティのプロパティを書き換えた直後の状態です。

エンティティ書き換えた直後の状態
通常のエンティティUnchanged(DetectChanges を呼ぶと Modified)
通知エンティティ + 戦略を構成したModified(DetectChanges は不要)
通知エンティティ + 戦略を構成しないUnchanged

3 つ目では通知を利用する戦略を構成していないため、通知イベントによる変更追跡は行われません。ただし既定のスナップショット方式まで無効になるわけではありません。この測定は自動検出も無効にしているので直後は Unchanged ですが、必要な変更検出を行えば状態に反映できます。

戦略は 4 つあり、公式ドキュメントは次のように整理しています。

ChangeTrackingStrategy必要なインターフェイスDetectChanges が必要か元の値をスナップショットするか
Snapshot(既定)なし必要する
ChangedNotificationsINotifyPropertyChanged不要する
ChangingAndChangedNotifications上記 + INotifyPropertyChanging不要しない
ChangingAndChangedNotificationsWithOriginalValues上記 + INotifyPropertyChanging不要する

ChangedNotifications でも元の値は保持されます。この確認例でも、書き換え直後の OriginalValue に元の値を確認できています。スナップショットまで省く ChangingAndChangedNotifications には、INotifyPropertyChanging の実装が必要です。

バッファリングとストリーミング

Section titled “バッファリングとストリーミング”

ToListAsync は結果をすべてメモリに読み込みます(バッファリング)。大量の行を順次処理するだけなら、await foreach によるストリーミングでメモリ使用量を抑えられます。

公式のバッファリングとストリーミングは、結果全体を保持するか順次処理するかの違いを説明しています。SQLite の 200,000 行(Name は 200 文字)で同期の ToList と AsEnumerable を比較した補足例では、GC.GetTotalMemory を約 1 ミリ秒間隔で読むヒープ増加量の最大値は +121 MiB と +6 MiB です。プロセス全体のピークメモリではなく、この比率をほかのデータ量や処理へ適用することもできません。

await foreach (var post in context.Posts.AsNoTracking().AsAsyncEnumerable()
.WithCancellation(cancellationToken))
{
await ProcessAsync(post, cancellationToken);
}

同期版の ToList() や SaveChanges() は、データベースの応答を待つ間スレッドをブロックします。ASP.NET Core ではスレッドプールが枯渇し、スループットが大きく低下する原因になります。原則として非同期版を使い、.Result や .Wait() によるブロッキングを避けてください。公式ドキュメントも、同期と非同期のコードを同じアプリケーションで混在させないよう警告しています。気づかないうちにスレッドプールの枯渇を招きやすいためです。

次は同期・非同期のエンドポイントで同じ SQL Server クエリを使う確認例です。WAITFOR DELAY '00:00:00.100' で 100 ミリ秒の待機を含め、10 コアのマシンで同時リクエスト数を変えています。5 回のウォームアップ後、15 秒間要求を開始し、送信済み要求の完了までを含む総経過時間からスループットを求めた結果です。

同時リクエスト数非同期のスループット同期のスループット非同期の p99同期の p99
1089 req/s89 req/s144 ミリ秒135 ミリ秒
50427 req/s123 req/s141 ミリ秒767 ミリ秒
100948 req/s129 req/s114 ミリ秒1,327 ミリ秒
2001,862 req/s107 req/s118 ミリ秒2,958 ミリ秒

この確認例では、同時 10 のスループットは同程度、同時 50〜200 は非同期版が高い結果です。同時 200 の p99 は非同期 118 ミリ秒、同期 2,958 ミリ秒で、エラーは 0 件です。順位・倍率や差が現れる同時実行数を、他のアプリケーションへ一般化しないでください。

公式ドキュメントはこの理由を「同期 API はデータベースの入出力の間スレッドをブロックするため、必要なスレッド数と、発生するスレッドのコンテキストスイッチの回数が増える」と説明しています。

プロバイダーを替えるとモデルの意味が変わる

Section titled “プロバイダーを替えるとモデルの意味が変わる”

プロバイダーの差し替えは「接続先が変わるだけ」ではありません。Azure Cosmos DB のようなドキュメントデータベースでは、同じ C# のモデルが別の意味に解釈されます。

公式の Cosmos DB モデリングでは、関連する型は既定で所有型として親文書に埋め込み、独立型にする場合は ModelBuilder.Entity で明示すると説明しています。一方、公式の制限事項では、別ドキュメント間の関連グラフの読み込みは非対応です。

次の確認例は、string の Id を持つ最小の Blog / Post と、DbSet<Blog> だけを公開する Context を使います。ブログ・投稿各 1 件を保存し、新しい Context で読む条件です。独立型では b.Entity<Post>() と HasMany(...).WithOne().HasForeignKey("BlogId") を指定しています。本編の接続先だけを変更した結果ではありません。

モデルの構成Post.IsOwned()Post のコンテナーInclude()Include なしで読んだ子
既定(何も書かない)Trueなし(親に埋め込み)成功(そもそも不要)1 件
b.Entity<Post>() と関係を明示FalseDb例外0 件

既定のままなら、Include を書かなくても子は一緒に読み込まれます。1 つのドキュメントとして格納されているためです。

独立型を明示したこの確認例では、別文書の Include で次の例外を確認できています。

InvalidOperationException: Including navigation 'Navigation: Blog.Posts (List<Post>)
Collection ToDependent Post' is not supported as the navigation is not embedded in same resource.

公式ドキュメントは、別ドキュメントから関連エンティティのグラフを読み込むことはサポートしないと説明しています。上の独立型の例外はこの制限に対応します。親と同じドキュメントに埋め込まれた所有型への Include まで一律に失敗するという意味ではありません。


Cosmos DB の接続と保存で注意すること

Section titled “Cosmos DB の接続と保存で注意すること”

以下は Microsoft.EntityFrameworkCore.Cosmos 10.0.11、依存する Cosmos SDK 3.61.0 と、実 Azure の NoSQL アカウントで確認した内容です。SQL Server 向けの接続設定や同時実行制御をそのまま当てはめないでください。

公式の AddCosmos<TContext> APIの 2 つの文字列は、接続文字列とデータベース名です。この確認例でも正しい引数による Scoped の解決・読み取りと、エンドポイントを接続文字列の位置へ渡す誤用の ArgumentException を確認できています。エンドポイントとキーを個別に渡す場合は UseCosmos(endpoint, key, databaseName) を使い、接続値をソースコードへ埋め込まないでください。

公式のCosmos DB プロバイダーの接続オプションには、一覧中のオプションをすべて同時に使う意図ではないと注意があります。

構成実測結果と注意
Gateway に Direct 専用の TCP 設定を指定MaxRequestsPerTcpConnection などの指定で ArgumentException。接続モードに適した設定だけを使う
HttpClientFactory と GatewayModeMaxConnectionLimit を併用明示的な上限指定との併用で ArgumentException。ファクトリを使う場合の上限は HttpClientHandler.MaxConnectionsPerServer で構成する
Direct の IdleTcpConnectionTimeout を 1 分に設定設定値の読み返しは成功しても、初回の入出力で ArgumentOutOfRangeException。公式下限の 10 分に直すと読み取りに成功

Gateway・Direct の両方で実通信を確認できていますが、接続上限までの負荷や回収時刻の測定ではありません。設定値を読み返せることと通信の成功は別です。

コンテナーとパーティションキー

Section titled “コンテナーとパーティションキー”

公式のモデリングガイドでは、HasDefaultContainer は既定コンテナー、ToContainer は型ごとの保存先、ToJsonProperty は JSON 名を指定します。この確認例でも保存先と JSON 名の反映を確認できています。

公式のクエリガイドでは、項目の識別に id とパーティションキーを使い、WithPartitionKey で検索先を限定します。単一キーの確認例でも、同じ id を異なるパーティションへ保存し、両値を渡した FindAsync による識別を確認できています。

階層パーティションキーは、HasPartitionKey(x => new { x.TenantId, x.UserId, x.SessionId }) のように構成します。string / Guid / int の 3 階層・同じ id の 4 文書の確認例では、id と全階層のキーを渡す FindAsync により目的の 1 件のポイント読み取りを確認できています。

公式は、LINQ からパーティションキーの等値条件を取り出す最適化を説明しています。先頭から連続する 1 個・2 個・全 3 個の確認例では、値を実行ログの Partition に確認できています。中間・末尾だけの条件は SQL 側に残る結果であり、先頭キーの有無を無視して同じ検索先に限定できるとは考えないでください。

公式の ETag による楽観的同時実行制御では、シャドウ状態に UseETagConcurrency()、CLR プロパティへのマッピングに IsETagConcurrency() を使います。両方式の確認例でも、別 Context の先行更新後に古い ETag で保存すると、HTTP 412 に基づく DbUpdateConcurrencyException を確認できています。

公式の ID 解決では、追跡済みインスタンスの再利用時にデータベースの値で上書きしません。SDK で文書を更新する確認例でも、既存の追跡値は古いまま、新しい DbContext は更新後の値です。GetCosmosClient() を使うだけで追跡状態も同期されるわけではありません。

有効期限とスループットの構成

Section titled “有効期限とスループットの構成”

公式の TTL (Time to Live)は最終更新からの有効期間を秒数で指定し、-1 は無効化です。コンテナーの既定と項目の ttl を分けます。既定 8 秒・個別 4 秒・個別 -1 の確認例では、11 秒後は前の 2 条件が HTTP 404、-1 は取得可能です。物理削除の完了時刻の測定ではありません。

スループットも、データベース共有とコンテナー専用では構成対象が異なります。新規作成の確認例では、ModelBuilder の HasManualThroughput(400) / HasAutoscaleThroughput(1000) で共有の手動 400 RU/s / 自動最大 1000 RU/s、エンティティ側の HasManualThroughput(500) で専用の手動 500 RU/s を確認できています。構成値の確認であり、負荷に応じたスケールや性能の測定ではありません。

トリガーは全クライアントに強制されない

Section titled “トリガーは全クライアントに強制されない”

公式のデータベーストリガーは、EF Core 10 の HasTrigger による Pre / Post と操作種別の指定を説明しています。この確認例でも、Pre の Create / Replace による書き換えと、Post の Delete での例外に伴うロールバックを確認できています。

Cosmos DB の全文検索とベクトル検索

Section titled “Cosmos DB の全文検索とベクトル検索”

Cosmos DB 用の EF Core 10 は、全文検索、ベクトル検索、および両方の順位を組み合わせるハイブリッド検索をサポートします。SQL Server の Contains / FreeText や SqlVector<T> とは別の API です。ここでは Microsoft.EntityFrameworkCore.Cosmos 10.0.11 と、ベクトル検索を有効にした実 Azure アカウントで確認した例を示します。

モデルに検索ポリシーと索引を設定する

Section titled “モデルに検索ポリシーと索引を設定する”

以下はこの節専用のエンティティと、UseCosmos で接続先を設定したコンテキストの OnModelCreating 内の抜粋です。DistanceFunction と VectorIndexType は Microsoft.Azure.Cosmos 名前空間の型です。

public class Blog
{
public string Id { get; set; } = "";
public string Partition { get; set; } = "p";
public string Contents { get; set; } = "";
public float[] Vector { get; set; } = [];
}
modelBuilder.Entity<Blog>(b =>
{
b.ToContainer("basic");
b.HasPartitionKey(x => x.Partition);
b.HasManualThroughput(400);
b.Property(x => x.Contents).EnableFullTextSearch();
b.HasIndex(x => x.Contents).IsFullTextIndex();
b.Property(x => x.Vector)
.IsVectorProperty(Microsoft.Azure.Cosmos.DistanceFunction.Cosine, dimensions: 3);
b.HasIndex(x => x.Vector)
.IsVectorIndex(Microsoft.Azure.Cosmos.VectorIndexType.Flat);
});

この新規コンテナーの確認例では、本文と 3 次元の float[] を持つ 6 文書の保存・検索・再取得を確認できています。全文検索・ベクトル検索用の設定で、既存コンテナーのポリシーを自動更新する手順ではありません。作成時の認証は付録3のマイグレーションの制限を参照してください。

全文検索と関連度による順位付け

Section titled “全文検索と関連度による順位付け”

全文検索で使う API と、6 文書のデータで確認した用途・結果を整理します。

API用途6 文書での実測例
FullTextContainsキーワードやフレーズで絞り込むdatabase が 3 件に一致
FullTextContainsAll指定した全キーワードを含むdatabase と cosmos が 2 件に一致
FullTextContainsAnyいずれかのキーワードを含むcosmos または bicycle が 4 件に一致
FullTextScoreBM25 による関連度順に並べるOrderBy と Take で取得
string[] keywords = ["database", "cosmos"];
var textResults = await db.Blogs
.OrderBy(x => EF.Functions.FullTextScore(x.Contents, keywords))
.Take(5)
.ToListAsync();

公式の全文検索ガイドは、FullTextScore の用途を並べ替えに限定しています。この確認例でも Select への投影・Where 条件で HTTP 400 を確認できています。通常の数値計算メソッドとは異なります。

既定言語は en-US です。公式 APIの既定言語の設定先は ModelBuilder です。EnableFullTextSearch("de-DE") と modelBuilder.HasDefaultFullTextLanguage("de-DE") の確認例も成功ですが、多言語対応の公式条件の代わりにはなりません。全言語・全リージョンでの利用保証ではありません。

ベクトル検索とハイブリッド検索

Section titled “ベクトル検索とハイブリッド検索”
float[] queryVector = [1, 0, 0];
var vectorResults = await db.Blogs
.OrderBy(x => EF.Functions.VectorDistance(x.Vector, queryVector))
.Take(5)
.ToListAsync();
var hybridResults = await db.Blogs
.OrderBy(x => EF.Functions.Rrf(
new[]
{
EF.Functions.FullTextScore(x.Contents, "database"),
EF.Functions.VectorDistance(x.Vector, queryVector)
},
weights: new double[] { 1, 2 }))
.Take(5)
.ToListAsync();

公式のハイブリッド検索では、RRF (Reciprocal Rank Fusion) で複数の順位を統合します。上の式を Cosmos DB の検索へ変換でき、この確認例でも重みに応じた上位文書の違いを確認できています。


同時実行検出を無効にしてはいけない

Section titled “同時実行検出を無効にしてはいけない”

1 つの DbContext インスタンスで操作が重複すると、EF Core の 同時実行検出 (concurrency detection) によって InvalidOperationException が発生することがあります。未検出なら安全という意味ではありません。この検出は DbContextOptionsBuilder.EnableThreadSafetyChecks(false) で無効にできます。公式ドキュメントは「わずかな性能向上が得られるが、DbContext インスタンスが同時に使われた場合の 動作は未定義になり、プログラムは予測できない形で失敗する可能性がある」と説明し、「性能向上が相当なものであることを確認し、アプリケーションを同時実行のバグについて十分にテストしたうえでのみ無効化すること」と釘を刺しています。

次は SQL Server 2022 で、同じ DbContext から 2 本のクエリを Task.Run で並行実行し、検出の有無を変えて各 3 回確認した結果です。例外欄は先頭行または要旨で、完全なスタックトレースではありません。

同時実行検出発生した例外
有効(既定)3 回とも InvalidOperationException: A second operation was started on this context instance before a previous operation completed.(原因と対処ページへのリンク付き)
無効1 回目: InvalidOperationException(接続が閉じられていない旨)
2 回目: InvalidOperationException(同上、接続状態の表示だけが異なる)
3 回目: InvalidCastException: Unable to cast object of type 'Microsoft.Data.ProviderBase.DbConnectionClosedConnecting' to type 'Microsoft.Data.SqlClient.SqlInternalConnectionTds'.

この 3 回で確認できているのは接続状態や型変換の例外であり、実行ごとに異なる例外が出るという仕様ではありません。公式は同時利用時の動作を未定義としているため、種類や順序に依存せず、原則として検出を既定のままにしてください。