効率的なクエリは膨大な対象であり、インデックス、関連エンティティの読み込み戦略など、幅広い対象を対象とします。 このセクションでは、クエリを高速化するための一般的なテーマと、ユーザーが通常遭遇する落とし穴について詳しく説明します。
インデックスを適切に使用する
クエリが高速に実行されるかどうかの主な決定要因は、必要に応じてインデックスを適切に使用するかどうかです。通常、データベースは大量のデータを保持するために使用され、テーブル全体を走査するクエリは、通常、重大なパフォーマンスの問題の原因となります。 インデックス作成の問題は、特定のクエリでインデックスが使用されるかどうかがすぐには明らかではないため、見つけにくいです。 例えば次が挙げられます。
// Matches on start, so uses an index (on SQL Server)
var posts1 = await context.Posts.Where(p => p.Title.StartsWith("A")).ToListAsync();
// Matches on end, so does not use the index
var posts2 = await context.Posts.Where(p => p.Title.EndsWith("A")).ToListAsync();
インデックス作成の問題を特定する良い方法は、まず低速なクエリを特定してから、データベースのお気に入りのツールを使用してそのクエリ プランを調べることです。その方法の詳細については、 パフォーマンス診断 のページを参照してください。 クエリ プランには、クエリがテーブル全体を走査するか、インデックスを使用するかを表示します。
一般に、インデックスの使用やそれらに関連するパフォーマンスの問題の診断に関する特別な EF 知識はありません。インデックスに関連する一般的なデータベース知識は、EF を使用しないアプリケーションと同じように EF アプリケーションに関連します。 インデックスを使用する際に注意すべき一般的なガイドラインを次に示します。
- インデックスはクエリを高速化しますが、-date up-to保持する必要があるため、更新も遅くなります。 必要のないインデックスを定義しないようにし、 インデックス フィルター を使用してインデックスを行のサブセットに制限することを検討して、このオーバーヘッドを軽減します。
- 複合インデックスを使用すると、複数の列でフィルター処理するクエリを高速化できますが、順序に応じて、すべてのインデックスの列でフィルター処理しないクエリを高速化することもできます。 たとえば、A 列と B 列のインデックスを使用すると、A と B によるクエリのフィルター処理と、A によるクエリのフィルター処理が高速化されますが、B に対するクエリのフィルター処理は高速化されません。
- クエリが列 (例:
price / 2
) に対して式によってフィルター処理する場合、単純なインデックスは使用できません。 ただし、式の 保存された永続化列 を定義し、その上にインデックスを作成できます。 一部のデータベースでは式インデックスもサポートされています。これは、任意の式によるクエリのフィルター処理を高速化するために直接使用できます。 - データベースが異なると、インデックスをさまざまな方法で構成でき、多くの場合、EF Core プロバイダーは Fluent API を介してこれらを公開します。 たとえば、SQL Server プロバイダーを使用すると、インデックスを クラスター化するか、その フィル ファクターを設定するかを構成できます。 詳細については、プロバイダーのドキュメントを参照してください。
必要なプロパティのみを選択する
EF Core を使用すると、エンティティ インスタンスのクエリを実行し、そのインスタンスをコードで使用することが非常に簡単になります。 ただし、エンティティ インスタンスのクエリを実行すると、データベースから必要以上に多くのデータがプルバックされる場合があります。 次の点を考慮してください。
await foreach (var blog in context.Blogs.AsAsyncEnumerable())
{
Console.WriteLine("Blog: " + blog.Url);
}
このコードでは実際には各ブログの Url
プロパティのみが必要ですが、Blog エンティティ全体がフェッチされ、不要な列がデータベースから転送されます。
SELECT [b].[BlogId], [b].[CreationDate], [b].[Name], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
これを最適化するには、 Select
を使用して、どの列を射出するかを EF に指示します。
await foreach (var blogName in context.Blogs.Select(b => b.Url).AsAsyncEnumerable())
{
Console.WriteLine("Blog: " + blogName);
}
結果の SQL では、必要な列のみがプルバックされます。
SELECT [b].[Url]
FROM [Blogs] AS [b]
複数の列を射影する必要がある場合は、必要なプロパティを含む C# 匿名型に射影します。
この手法は読み取り専用クエリに非常に役立ちますが、EF の変更追跡はエンティティ インスタンスでのみ機能するため、フェッチされたブログを 更新 する必要がある場合は複雑になります。 変更された Blog インスタンスをアタッチし、変更されたプロパティを EF に伝えることで、エンティティ全体を読み込まずに更新を実行できますが、これは価値のないより高度な手法です。
結果セットのサイズを制限する
既定では、クエリはフィルターに一致するすべての行を返します。
var blogsAll = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.ToListAsync();
返される行数はデータベース内の実際のデータによって異なるため、データベースから読み込まれるデータの量、結果によって占有されるメモリの量、およびこれらの結果を処理するときに生成される追加の負荷を把握することはできません (たとえば、ネットワーク経由でユーザー ブラウザーに送信する)。 重要なのは、テストデータベースにはしばしばデータがほとんど含まれていないため、テスト中はすべてが正常に動作しますが、実際のデータでクエリが開始され、多くの行が返されると、パフォーマンスに問題が突然発生します。
その結果、通常は結果の数を制限することを考える価値があります。
var blogs25 = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.Take(25)
.ToListAsync();
少なくとも、UI には、データベースにさらに多くの行が存在する可能性があることを示すメッセージが表示される場合があります (また、他の方法で取得できます)。 本格的なソリューションでは 改ページが実装され、UI では一度に一定数の行のみが表示され、ユーザーは必要に応じて次のページに進めることができます。これを効率的に実装する方法の詳細については、次のセクションを参照してください。
効率的なページ付け
改ページとは、一度にすべてではなく、ページで結果を取得することを指します。これは通常、大きな結果セットに対して行われます。この場合、ユーザー インターフェイスが表示され、ユーザーは結果の次のページまたは前のページに移動できます。 データベースで改ページ位置付けを実装する一般的な方法は、 Skip
演算子と Take
演算子 (SQL ではOFFSET
と LIMIT
) を使用することです。これは直感的な実装ですが、非常に非効率的でもあります。 (任意のページにジャンプするのではなく) 一度に 1 ページずつ移動できる改ページの場合は、代わりに キーセットの改ページを 使用することを検討してください。
詳細については、 改ページのドキュメント ページを参照してください。
関連エンティティを読み込むときにデカルト爆発を回避する
リレーショナル データベースでは、関連するすべてのエンティティが、1 つのクエリに JOIN を導入することによって読み込まれます。
SELECT [b].[BlogId], [b].[OwnerId], [b].[Rating], [b].[Url], [p].[PostId], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title]
FROM [Blogs] AS [b]
LEFT JOIN [Post] AS [p] ON [b].[BlogId] = [p].[BlogId]
ORDER BY [b].[BlogId], [p].[PostId]
一般的なブログに複数の関連投稿がある場合、これらの投稿の行はブログの情報を複製します。 この重複は、いわゆる"デカルト爆発"の問題につながります。 複数の一対多リレーションシップが読み込まれると、重複するデータの量が増加し、アプリケーションのパフォーマンスに悪影響を及ぼす可能性があります。
EF では、"分割クエリ" を使用してこの影響を回避できます。これにより、関連するエンティティが個別のクエリを介して読み込まれます。 詳細については、 分割クエリと単一クエリに関するドキュメントを参照してください。
注
分割クエリの現在の実装では、クエリごとにラウンドトリップが実行されます。 今後、これを改善し、すべてのクエリを 1 回のラウンドトリップで実行する予定です。
可能な場合は関連エンティティを積極的に読み込む
このセクションに進む前 に、関連するエンティティに関する専用ページ を読んでおくことをお勧めします。
関連エンティティを扱う場合、通常、読み込む必要がある内容が事前にわかっています。一般的な例は、特定のブログセットとそのすべての投稿を読み込む場合です。 これらのシナリオでは、EF が必要なすべてのデータを 1 回のラウンドトリップでフェッチできるように、 一括読み込みを常に使用することをお勧めします。 フィルター処理されたインクルード機能を使用すると、読み込む関連エンティティを制限しながら、読み込みプロセスを一括して 1 回のラウンドトリップで実行することもできます。
using (var context = new BloggingContext())
{
var filteredBlogs = await context.Blogs
.Include(
blog => blog.Posts
.Where(post => post.BlogId == 1)
.OrderByDescending(post => post.Title)
.Take(5))
.ToListAsync();
}
その他のシナリオでは、プリンシパル エンティティを取得する前に必要になる関連エンティティがわからない場合があります。 たとえば、ブログを読み込むときに、そのブログの投稿に興味があるかどうかを知るために、他のデータ ソース (Web サービスなど) を調べる必要がある場合があります。 このような場合は、明示的または遅延読み込みを使用して、関連エンティティを個別に取得し、ブログの投稿のナビゲーションを充実させることができます。 これらのメソッドは熱心でないため、データベースへの追加のラウンドトリップが必要です。これは速度低下の原因です。特定のシナリオによっては、追加のラウンドトリップを実行して必要な投稿のみを選択的に取得するのではなく、常にすべての投稿を読み込む方が効率的な場合があります。
遅延読み込みに注意してください
EF Core では、関連エンティティがコードからアクセスされると自動的にデータベースから読み込まれるため、遅延読み込みはデータベース ロジックを記述する非常に便利な方法と思われます。 これにより、不要な関連エンティティ (明示的な読み込みなど) の 読み込みが回避され、プログラマは関連エンティティを完全に処理する必要がなくなります。 ただし、遅延読み込みは特に不要な余分なラウンドトリップを生成する傾向があり、アプリケーションが遅くなる可能性があります。
次の点を考慮してください。
foreach (var blog in await context.Blogs.ToListAsync())
{
foreach (var post in blog.Posts)
{
Console.WriteLine($"Blog {blog.Url}, Post: {post.Title}");
}
}
この一見無実のコードは、すべてのブログとその投稿を反復処理して印刷します。EF Core の ステートメント ログを 有効にすると、次の内容が表示されます。
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [b].[BlogId], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (5ms) [Parameters=[@__p_0='1'], CommandType='Text', CommandTimeout='30']
SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
FROM [Post] AS [p]
WHERE [p].[BlogId] = @__p_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__p_0='2'], CommandType='Text', CommandTimeout='30']
SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
FROM [Post] AS [p]
WHERE [p].[BlogId] = @__p_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__p_0='3'], CommandType='Text', CommandTimeout='30']
SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[Title]
FROM [Post] AS [p]
WHERE [p].[BlogId] = @__p_0
... and so on
どうなっているのでしょうか? 上記の単純なループに対してこれらすべてのクエリが送信されるのはなぜですか? 遅延読み込みでは、ブログの投稿は、その Posts プロパティにアクセスしたときにのみ (遅延) 読み込まれます。その結果、内部 foreach の各イテレーションは、独自のラウンドトリップで追加のデータベース クエリをトリガーします。 その結果、最初のクエリですべてのブログを読み込んだ後、 ブログごとに別のクエリが作成され、すべての投稿が読み込まれます。これは N+1 の問題とも呼ばれ、非常に大きなパフォーマンスの問題を引き起こす可能性があります。
ブログのすべての投稿が必要になると仮定すると、代わりにここで一括読み込みを使用するのが理にかなっています。 Include 演算子を使用して読み込みを実行できますが、Blogs の URL のみが必要であるためです (必要なものだけを読み込む必要があります)。 そのため、代わりにプロジェクションを使用します。
await foreach (var blog in context.Blogs.Select(b => new { b.Url, b.Posts }).AsAsyncEnumerable())
{
foreach (var post in blog.Posts)
{
Console.WriteLine($"Blog {blog.Url}, Post: {post.Title}");
}
}
これにより、EF Core では、すべてのブログとその投稿が 1 つのクエリでフェッチされます。 場合によっては、 分割クエリを使用してデカルト爆発の影響を回避すると便利な場合もあります。
Warnung
遅延読み込みにより、誤って N+1 の問題をトリガーすることが非常に簡単になるため、回避することをお勧めします。 一括読み込みや明示的読み込みによって、データベースラウンドトリップが発生するタイミングがソースコードで非常に明確に示されます。
バッファリングとストリーミング
バッファリングとは、すべてのクエリ結果をメモリに読み込むのに対し、ストリーミングとは、EF が毎回アプリケーションに 1 つの結果を提供し、結果セット全体をメモリに含めないことを意味します。 原則として、ストリーミング クエリのメモリ要件は固定されています。これは、クエリが 1 行または 1000 を返すかどうかに関係なく同じです。一方、バッファリング クエリでは、より多くの行が返されるほど多くのメモリが必要です。 結果セットが大きくなるクエリの場合、これは重要なパフォーマンス要因になる可能性があります。
クエリがバッファーするかストリームするかは、その評価方法によって決まります。
// ToList and ToArray cause the entire resultset to be buffered:
var blogsList = await context.Posts.Where(p => p.Title.StartsWith("A")).ToListAsync();
var blogsArray = await context.Posts.Where(p => p.Title.StartsWith("A")).ToArrayAsync();
// Foreach streams, processing one row at a time:
await foreach (var blog in context.Posts.Where(p => p.Title.StartsWith("A")).AsAsyncEnumerable())
{
// ...
}
// AsAsyncEnumerable also streams, allowing you to execute LINQ operators on the client-side:
var doubleFilteredBlogs = context.Posts
.Where(p => p.Title.StartsWith("A")) // Translated to SQL and executed in the database
.AsAsyncEnumerable()
.Where(p => SomeDotNetMethod(p)); // Executed at the client on all database results
クエリから返される結果が少ししかない場合は、おそらくこのことを心配する必要はありません。 ただし、クエリが多数の行を返す可能性がある場合は、バッファリングではなくストリーミングを検討する価値があります。
注
結果に対して別の LINQ 演算子を使用する場合は、 ToList または ToArray を使用しないでください。これにより、すべての結果がメモリに不必要にバッファーされます。 AsEnumerable を代わりに使用します。
EF による内部バッファリング
特定の状況では、クエリの評価方法に関係なく、EF 自体が結果セットを内部的にバッファー処理します。 これが発生する 2 つのケースは次のとおりです。
- 再試行実行戦略が実施されている場合。 これは、クエリが後で再試行された場合に同じ結果が返されるようにするために行われます。
- 分割クエリを使用すると、SQL Server で MARS (複数のアクティブな結果セット) が有効になっていない限り、最後のクエリ以外のすべての結果セットがバッファーに格納されます。 これは、通常、複数のクエリ結果セットを同時にアクティブにすることは不可能であるためです。
この内部バッファリングは、LINQ 演算子を使用して発生するバッファリングに加えて発生することに注意してください。 たとえば、クエリで ToList を使用し、再試行実行戦略が設定されている場合、結果セットは 2 回 (EF によって内部的に 1 回、 ToListごとに) メモリに読み込まれます。
追跡、非追跡、身元解決
このセクションに進む前 に、追跡と追跡なしに関する専用ページ を読んでおくことをお勧めします。
EF は既定でエンティティ インスタンスを追跡するため、 SaveChanges が呼び出されたときに変更が検出され、永続化されます。 クエリの追跡のもう 1 つの効果は、データに対してインスタンスが既に読み込まれているかどうかを EF が検出し、新しいインスタンスを返すのではなく、追跡対象のインスタンスを自動的に返すということです。これは ID 解決と呼ばれます。 パフォーマンスの観点から見ると、変更の追跡は次のことを意味します。
- EF は、追跡対象インスタンスのディクショナリを内部的に保持します。 新しいデータが読み込まれると、EF はディクショナリをチェックして、インスタンスがそのエンティティのキー (ID 解決) を既に追跡しているかどうかを確認します。 クエリの結果を読み込むときに、ディクショナリのメンテナンスと検索に時間がかかります。
- 読み込まれたインスタンスをアプリケーションに渡す前に、EF はそのインスタンス をスナップショット化 し、スナップショットを内部的に保持します。 SaveChangesが呼び出されると、アプリケーションのインスタンスがスナップショットと比較され、永続化する変更が検出されます。 スナップショットはより多くのメモリを占有し、スナップショット処理自体には時間がかかります。 値の比較子を使用して異なる、より効率的なスナップショット処理動作を指定したり、変更追跡プロキシを使用してスナップショット処理を完全にバイパスしたりすることが可能な場合があります (ただし、独自の欠点があります)。
変更がデータベースに保存されない読み取り専用のシナリオでは、 追跡なしのクエリを使用することで、上記のオーバーヘッドを回避できます。 ただし、追跡なしのクエリでは ID 解決が実行されないため、他の複数の読み込まれた行によって参照されるデータベース行は、異なるインスタンスとして具体化されます。
具体的には、データベースから多数の投稿と、各投稿によって参照されるブログを読み込んでいるものとします。 100 件の投稿が同じブログを参照する場合、追跡クエリは ID 解決によってこれを検出し、すべての Post インスタンスが同じ重複除去されたブログ インスタンスを参照します。 これに対し、追跡なしのクエリは同じブログを 100 回複製し、それに応じてアプリケーション コードを記述する必要があります。
10 件のブログを読み込み、それぞれ 20 件の投稿を含むクエリの追跡動作と追跡なしの動作を比較したベンチマークの結果を次に示します。 ソース コードはここから入手できます。これは、独自の測定の基盤としてご自由に使用できます。
メソッド | NumBlogs | ブログごとの投稿数 | Mean (平均値) | エラー | StdDev | 中央値 | 比率 | RatioSD | Gen 0 | Gen 1 | Gen 2 | 割り当て られた |
---|---|---|---|---|---|---|---|---|---|---|---|---|
AsTracking | 10 | 20 | 1,414.7 us | 27.20 us | 45.44 us | 1,405.5 us | 1.00 | 0.00 | 60.5469 | 13.6719 | - | 380.11 KB |
AsNoTracking | 10 | 20 | 993.3 us | 24.04 us | 65.40 us | 966.2 us | 0.71 | 0.05 | 37.1094 | 6.8359 | - | 232.89 KB |
最後に、変更追跡のオーバーヘッドなしで更新を実行できます。これは、追跡なしのクエリを利用し、返されたインスタンスをコンテキストにアタッチして、どの変更を行うのかを指定することで実現できます。 これにより、変更追跡の負担が EF からユーザーに移されます。変更追跡のオーバーヘッドがプロファイリングまたはベンチマークによって許容できないと示されている場合にのみ試行する必要があります。
SQL クエリの使用
場合によっては、クエリ用により最適化された SQL が存在し、EF では生成されません。 これは、SQL コンストラクトがサポートされていないデータベースに固有の拡張機能である場合、または EF がまだ変換されていないために発生する可能性があります。 このような場合、SQL を手動で記述するとパフォーマンスが大幅に向上する可能性があり、EF ではこれを行ういくつかの方法がサポートされています。
- SQL クエリをクエリ内で直接使用します。例えば、FromSqlRaw 経由で。 EF では、通常の LINQ クエリを使用して SQL を構成することもできます。これにより、クエリの一部のみを SQL で表現できます。 これは、コードベースの 1 つのクエリでのみ SQL を使用する必要がある場合に適した手法です。
-
ユーザー定義関数 (UDF) を定義し、クエリから呼び出します。 EF では、UDF が完全な結果セット (テーブル値関数 (TVF) と呼ばれます) を返すことができます。また、
DbSet
を関数にマッピングして、別のテーブルと同じように表示することもできます。 - クエリでデータベースビューを定義し、そこからクエリを実行します。 関数とは異なり、ビューはパラメーターを受け入れることができないことに注意してください。
注
通常、RAW SQL は最後の手段として使用する必要があります。EF が必要な SQL を生成できないことを確認した後で、特定のクエリで十分なパフォーマンスが重要な場合は、それを正当化します。 生 SQL を使用すると、メンテナンス上の欠点が大きいものになります。
非同期プログラミング
一般的なルールとして、アプリケーションをスケーラブルにするには、同期 API ではなく非同期 API を常に使用することが重要です (たとえば、SaveChangesAsyncではなくSaveChanges)。 同期 API は、データベース I/O の間スレッドをブロックし、スレッドの必要性と発生する必要があるスレッド コンテキスト スイッチの数を増やします。
詳細については、 非同期プログラミングに関するページを参照してください。
Warnung
同期コードと非同期コードを同じアプリケーションで混在させないようにします。スレッド プールの不足に関する微妙な問題を誤ってトリガーするのは非常に簡単です。
Warnung
Microsoft.Data.SqlClient の非同期実装には、残念ながらいくつかの既知の問題があります (例: #593、#601など)。 予期しないパフォーマンスの問題が発生する場合は、特に大きなテキストまたはバイナリ値を処理する場合は、代わりに同期コマンドの実行を使用してみてください。
その他のリソース
- 効率的なクエリに関連するその他のトピックについては、パフォーマンスに関する 高度なトピックのページ を参照してください。
- null 許容値を比較する際のベスト プラクティスについては、null 比較ドキュメント ページの パフォーマンスセクション を参照してください。
.NET