効率的なクエリ

効率的なクエリの実行は、非常に広い主題であり、そこには、インデックスや関連するエンティティの読み込み方法を始めとした、多数の広範囲にわたる主題が含まれています。 このセクションでは、クエリを高速化するためのいくつかの一般的なテーマと、ユーザーが一般的に遭遇する落とし穴について詳しく説明します。

インデックスを正しく使用する

クエリが高速に実行されるかどうかに関する主な決定要因は、インデックスが適切な場所で正しく使用されるかどうかです。データベースは一般に、大量のデータを保持するために使用され、テーブル全体を走査するクエリは、一般に、深刻なパフォーマンス上の問題の原因となります。 インデックス作成の問題は、特定のクエリでインデックスが使用されるかどうかが一見して明らかではないため、見つけるのは容易ではありません。 次に例を示します。

// Matches on start, so uses an index (on SQL Server)
var posts1 = context.Posts.Where(p => p.Title.StartsWith("A")).ToList();
// Matches on end, so does not use the index
var posts2 = context.Posts.Where(p => p.Title.EndsWith("A")).ToList();

インデックス作成の問題を見つける良い方法は、最初に低速なクエリを特定してから、お気に入りのデータベース ツールを使用してクエリ プランを調べることです。その実行方法の詳細については、パフォーマンス診断に関するページを参照してください。 クエリ プランには、そのクエリによってテーブル全体が走査されるか、インデックスが使用されるかが表示されます。

一般的な規則として、インデックスを使用したり、それに関連するパフォーマンスの問題を診断したりするための特別な EF の知識は一切存在しません。インデックスに関連する一般的なデータベースの知識は、EF を使用しないアプリケーションの場合と同様に EF アプリケーションにも関連性があります。 以下に、インデックスの使用時に留意すべき一般的なガイドラインをいくつか示します。

  • インデックスによってクエリは高速になりますが、それらを最新の状態に保つ必要があるため、更新速度の低下も発生します。 必要でないインデックスの定義は避けてください。インデックス フィルターを使用してインデックスを行のサブセットに限定し、それによってこのオーバーヘッドを低減することを検討してください。
  • 複合インデックスを使用すると、複数の列でフィルター処理を行うクエリを高速化できますが、順序によっては、インデックスの列すべてに対してフィルター処理を行わないクエリも高速化できます。 たとえば、A 列と B 列にインデックスがあると、A と B によるクエリのフィルター処理と、A だけでフィルター処理を行うクエリが高速化されます。しかし、B に対してのみフィルター処理を行うクエリは高速化されません。
  • クエリで、列に対する式によってフィルター処理を行う場合 (例: price / 2)、単純なインデックスは使用できません。 ただし、式に対して永続化され、格納される列を定義し、それに対してインデックスを作成することができます。 一部のデータベースでは、式のインデックスもサポートされており、直接それを使用して、任意の式によってクエリのフィルター処理を高速化することができます。
  • 異なるデータベースを使用すると、インデックスをさまざまな方法で構成することができ、これらは多くの場合、Fluent API を介して、EF Core プロバイダーによって公開されています。 たとえば、SQL Server プロバイダーの使用時には、インデックスをクラスター化するかどうかの構成や、その FILL FACTOR の設定を行うことができます。 詳細については、お使いのプロバイダーのドキュメントを調べてください。

必要なプロパティのみのプロジェクションを行う

EF Core を使用すると、エンティティ インスタンスに対してクエリを実行し、後でそれらのインスタンスをコード内で使用することが、非常に簡単になります。 ただし、エンティティ インスタンスに対するクエリにより、頻繁に、必要以上に多くのデータがデータベースからプルされる可能性があります。 次の点について検討してください。

foreach (var blog in context.Blogs)
{
    Console.WriteLine("Blog: " + blog.Url);
}

このコードで必要なのは、実際には各ブログの Url プロパティだけですが、Blog エンティティ全体がフェッチされて、データベースからは不必要な列が転送されます。

SELECT [b].[BlogId], [b].[CreationDate], [b].[Name], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]

これは、Select を使用して、どの列のプロジェクションによる除外を行うかを EF に指示することで最適化できます。

foreach (var blogName in context.Blogs.Select(b => b.Url))
{
    Console.WriteLine("Blog: " + blogName);
}

結果として得られる SQL では、必要とされている列のみがプルされます。

SELECT [b].[Url]
FROM [Blogs] AS [b]

プロジェクションで複数の列を除外する必要がある場合は、必要なプロパティを持つ C# 匿名型へのプロジェクションによって除外します。

この手法は、読み取り専用クエリには非常に便利ですが、EF による変更の追跡はエンティティ インスタンスでのみ機能するため、フェッチされたブログを更新する必要がある場合は、やり方がより複雑になります。 変更された Blog インスタンスをアタッチし、EF にどのプロパティが変更されたかを指示することで、エンティティ全体を読み込まずに更新を実行できますが、これはする価値がない場合もある、より高度な手法です。

結果セットのサイズを制限する

既定では、クエリにより、フィルターに一致するすべての行が返されます。

var blogsAll = context.Posts
    .Where(p => p.Title.StartsWith("A"))
    .ToList();

返される行の数はデータベース内の実際のデータによって異なるため、データベースから読み込まれるデータの量、結果によって消費されるメモリの量、これらの結果を処理するときに (例: ネットワーク経由で結果をユーザー ブラウザーに送信することで) 生成される追加の負荷の量を知ることはできません。 非常に重要なのは、テスト用のデータベースに含まれるデータは少量の場合が多いため、テスト時にはすべてが適切に機能するのに、クエリの実行が実環境のデータに対して開始されると突然パフォーマンスの問題が出現し、多くの行が返されるという点です。

結果として、通常は、結果の数を制限することを検討する価値があります。

var blogs25 = context.Posts
    .Where(p => p.Title.StartsWith("A"))
    .Take(25)
    .ToList();

少なくとも、UI に、データベースにはより多くの行が存在する可能性があることを示すメッセージを 表示できます (そして、他の何らかの方法でそれらを取得できます)。 本格的なソリューションでは "改ページ" を実装します。これを使用すると、UI は一度に一定数の行のみを表示し、必要に応じてユーザーが次のページに進めることができるようにします。これを効率的に実装する方法の詳細については、次のセクションを参照してください。

効率的な改ページ

改ページとは、結果を一度にすべてではなく、ページ単位で取得することを指します。これは通常、大きな結果セットに対して行われます。これを使用すると、結果の次または前のページにユーザーが移動できるようにするユーザー インターフェイスが表示されます。 データベースで改ページを実装する一般的な方法は、SkipTake 演算子 (SQL では OFFSETLIMIT) を使用することです。これは直感的な実装ですが、非常に非効率でもあります。 (任意のページにジャンプするのではなく) 一度に 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 では、"分割クエリ" の使用によってこの影響を回避できます。この場合、別個のクエリを介して関連エンティティが読み込まれます。 詳細については、分割クエリと単一クエリに関するドキュメントを参照してください。

Note

分割クエリの現在の実装では、それぞれのクエリに対してラウンドトリップが実行されます。 これは将来改善されて、すべてのクエリが 1 回のラウンドトリップで実行される予定です。

このセクションに進む前に、関連エンティティに関する専用のページに目を通すことをお勧めします。

関連エンティティを扱うときには、通常、何を読み込む必要があるかを事前に知っています。典型的な例として、特定の一連の Blogs と、それらの Posts すべてを読み込むことが挙げられます。 これらのシナリオでは、常に一括読み込みを使用することをお勧めします。そうすれば EF では、必要なすべてのデータを 1 回のラウンドトリップでフェッチできます。 フィルター処理されたインクルード機能を使用すると、読み込む関連エンティティを制限する一方で、読み込みプロセスを一括型に保ち、それによって 1 回のラウンドトリップで実行可できるようにもできます。

using (var context = new BloggingContext())
{
    var filteredBlogs = context.Blogs
        .Include(
            blog => blog.Posts
                .Where(post => post.BlogId == 1)
                .OrderByDescending(post => post.Title)
                .Take(5))
        .ToList();
}

その他のシナリオでは、プリンシパル エンティティを取得する前は、どの関連エンティティが必要になるか不明な場合があります。 たとえば、ある Blog の読み込み時に、そのブログの投稿に関心があるかどうかを知るため、他の何らかのデータソース (おそらく Web サービス) を参照することが必要になる場合があります。 これらの場合には、明示的読み込みまたは遅延読み込みを使用して関連エンティティを個別に取得し、ブログの投稿のナビゲーションを指定することができます。 これらの方法は一括型ではないため、データベースへの追加のラウンドトリップが必要であることに注意してください。これは、速度低下の要因です。具体的なシナリオによっては、追加のラウンドトリップを実行して必要な Posts だけを選択的に取得するよりも、常にすべての Posts を単純に読み込む方が、より効率的な場合があります。

遅延読み込みに注意する

EF Core ではコードによってアクセスされるときにデータベースから関連エンティティが自動的に読み込まれるため、遅延読み込みは、多くの場合、データベース ロジックを記述する非常に便利な方法のように思われます。 これにより、必要とされていない関連エンティティの読み込みが (明示的な読み込みのように) 回避されるので、プログラマはおそらく、関連エンティティをまとめて処理する必要がなくなります。 ただし遅延読み込みは、特に、アプリケーションの処理速度を招く可能性がある不要な追加のラウンドトリップを生成する傾向があります。

次の点について検討してください。

foreach (var blog in context.Blogs.ToList())
{
    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

どうなっているのでしょうか? 上記の単純なループのために、これらのすべてのクエリが送信されるのはなぜでしょうか。 遅延読み込みを使用すると、Blogs の Posts は、その Posts プロパティがアクセスされたときにのみ読み込まれます。その結果、内側の foreach の各反復処理では、それ独自のラウンドトリップにおいて追加のデータベース クエリがトリガーされます。 結果として、最初のクエリによってすべてのブログが読み込まれた後、"ブログごと" に別のクエリが作成され、その投稿がすべて読み込まれます。これは、N+1 の問題と呼ばれることもあり、非常に重大なパフォーマンスの問題を引き起こす可能性があります。

ブログの投稿すべてが必要になるという前提であれば、代わりに一括読み込みを使用するのが理にかなっています。 Include 演算子を使用して読み込みを実行できますが、必要なのは Blog の URL だけです (必要なものだけを読み込む必要がある)。 そのため、代わりにプロジェクションを使用します。

foreach (var blog in context.Blogs.Select(b => new { b.Url, b.Posts }).ToList())
{
    foreach (var post in blog.Posts)
    {
        Console.WriteLine($"Blog {blog.Url}, Post: {post.Title}");
    }
}

これによって EF Core は、すべてのブログを 1 つのクエリで、それらの Posts と一緒にフェッチするようになります。 場合によっては、分割クエリを使用してデカルト爆発の影響を回避すると便利なこともあります。

警告

遅延読み込みを使用すると、誤って N+1 問題をトリガーすることが極めて容易になるため、その使用は避けることをお勧めします。 一括読み込みや明示的な読み込を使用すると、ソース コード内で、データベースのラウンドトリップがいつ発生するかが非常に明確になります。

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

バッファーリングとは、すべてのクエリ結果をメモリに読み込むことを指します。一方ストリーミングは、EF からアプリケーションには毎回 1 つの結果が渡されて、メモリ内に結果セット全体が格納されることは決してないことを意味します。 原則として、ストリーミング クエリのメモリ要件は固定されます。要件は、クエリから 1 行返されても 1000 行返されても同一です。一方、バッファーリング クエリでは、より多くのメモリが必要になり、より多くの行が返されます。 大きな結果セットを得る結果となるクエリの場合、これが重要なパフォーマンス要因になることがあります。

クエリでバッファー処理を行うかストリーミングを行うかは、クエリがどのように評価されるかによって異なります。

// ToList and ToArray cause the entire resultset to be buffered:
var blogsList = context.Posts.Where(p => p.Title.StartsWith("A")).ToList();
var blogsArray = context.Posts.Where(p => p.Title.StartsWith("A")).ToArray();

// Foreach streams, processing one row at a time:
foreach (var blog in context.Posts.Where(p => p.Title.StartsWith("A")))
{
    // ...
}

// AsEnumerable 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
    .AsEnumerable()
    .Where(p => SomeDotNetMethod(p)); // Executed at the client on all database results

クエリから返される結果がわずかであれば、おそらく、これについて心配する必要はありません。 ただし、クエリから大量の行が返される可能性がある場合は、バッファー処理ではなくストリーミングを検討する価値があります。

Note

結果に対して別の LINQ 演算子を使用する意図がある場合、ToListToArray の使用は避けてください。これは、すべての結果を不必要にメモリにバッファー処理します。 AsEnumerable を代わりに使用します。

EF による内部バッファーリング

特定の状況では、クエリの評価方法とは無関係に、EF 自体によって内部的に結果セットがバッファー処理されます。 以下に、これが発生する 2 つの場合を示します。

  • 再試行実行戦略が導入されている場合。 これが行われるのは、後でクエリが再試行された場合に同じ結果が返されるようにするためです。
  • 分割クエリを使用すると、SQL Server で MARS (複数のアクティブな結果セット) が有効になっていない限り、最後のクエリ以外のすべての結果セットがバッファー処理されます。 これは、複数のクエリの結果セットを同時にアクティブにすることは、通常は不可能であるためです。

この内部バッファーリングは、LINQ 演算子によって発生するどのバッファーリングに対しても追加で発生することに注意してください。 たとえば、クエリに対して ToList を使用していて、再試行実行戦略が導入されている場合、内部的に EF によって 1 回、ToList によって 1 回の "2 回"、結果セットがメモリに読み込まれます。

追跡、追跡なし、ID 解決

このセクションに進む前に、追跡ありと追跡なしに関する専用のページに目を通すことをお勧めします。

EF は既定でエンティティ インスタンスを追跡するので、それらに対する変更が SaveChanges の呼び出し時に検出され、永続化されます。 クエリを追跡することの別の影響として、EF では、データのためにインスタンスが既に読み込まれているかどうかが検出され、新しいインスタンスを返さずに、追跡されたインスタンスが自動的に返されることが挙げられます。これは、"ID 解決" と呼ばれます。 パフォーマンスの観点からは、変更の追跡は以下のことを意味します。

  • EF では、追跡されたインスタンスのディクショナリが内部的に管理されます。 新しいデータが読み込まれると、EF によるディクショナリのチェックで、そのエンティティのキーについてインスタンスが既に追跡されているかどうかが確認されます (ID 解決)。 クエリの結果を読み込むときには、ディクショナリのメンテナンスと参照に一定の時間がかかります。
  • 読み込まれたインスタンスをアプリケーションに渡す前に、EF ではそのインスタンスを "スナップショット化" して、スナップショットを内部的に保持します。 SaveChanges が呼び出されると、アプリケーションのインスタンスがスナップショットと比較されて、永続化する変更を検出します。 スナップショットはより多くのメモリを消費し、スナップショット作成のプロセス自体にも時間がかかります。場合によっては、値比較子によって、より効率的な場合もある異なるスナップショット作成動作を指定したり、変更追跡プロキシを使用して、スナップショット作成プロセス全体をバイパスしたりする (ただし固有の一連の短所も伴います) ことができます。

変更がデータベースに戻されて保存されない読み取り専用のシナリオでは、追跡なしクエリを使用することで上記のオーバーヘッドを回避できます。 ただし、追跡なしクエリでは ID 解決が実行されないので、読み込まれた他の複数行で参照されているデータベース行は、異なるインスタンスとして具体化されます。

説明のために、データベースから大量の Posts を読み込み、各 Post によって参照されている Blog も読み込もうとしていると想定します。 100 個の Post がたまたま同じ Blog を参照している場合、追跡クエリではこれが ID 解決によって検出され、すべての Post インスタンスが、重複除去された同じ Blog インスタンスを参照することになります。 追跡なしクエリでは、対照的に、同じ Blog が 100 回複製されます。そして、それに応じてアプリケーション コードを記述する必要があります。

以下に、10 個の Blog を、それぞれ 20 個の Post と一緒に読み込むクエリについて、追跡ありの動作と追跡なしの動作を比較したベンチマークの結果を示します。 ソース コードはここから入手できます。これは、独自の測定の基盤としてご自由に使用できます。

メソッド NumBlogs NumPostsPerBlog 平均 エラー StdDev Median 比率 RatioSD Gen 0 Gen 1 Gen 2 Allocated
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 クエリを使用する

場合によっては、クエリに対して、EF では生成されない、より最適化された SQL が存在しています。 こうなる可能性があるのは、その SQL 構成体が、サポートされていないデータベース固有の拡張機能である場合や、単純に EF による変換がまだ行われていないことが理由です。 これらの場合、手作業で SQL を記述し、大幅なパフォーマンス向上を実現できます。EF では、これを行うための方法がいくつかサポートされています。

  • SQL クエリを使用して、たとえば FromSqlRaw 経由でクエリ内で直接クエリを実行します。 EF では、通常の LINQ クエリを使用して SQL を作成することもできます。それにより、SQL 内でクエリの一部のみを表現できるようにします。 これは、SQL を使用する必要があるのが、コードベース内の 1 つのクエリのみであるときに便利な手法です。
  • ユーザー定義関数 (UDF) を定義してから、それをクエリから呼び出します。 EF は、完全な結果セットを UDF が返すことができるようにし (これらはテーブル値関数 (TVF) と呼ばれます)、DbSet を関数にマップして、もう 1 つのテーブルのように見えるようにもします。
  • データベース ビューを定義し、クエリ内でそのビューからクエリを実行します。 関数とは異なり、ビューにはパラメーターを渡せないことに注意してください。

Note

生 SQL は、通常、EF では必要な SQL を生成できないことを確認した後に、そのクエリでは十分正当化できるほどパフォーマンスが重要な場合に、最後の手段として使用する必要があります。 生 SQL を使用すると、メンテナンス面でかなりの短所が生じます。

非同期プログラミング

一般的な規則として、アプリケーションをスケーラブルにするためには、同期 API ではなく、必ず非同期 API (例: SaveChanges ではなく SaveChangesAsync) を使用することが重要です。 同期 API を使用すると、データベース I/O が続く間、スレッドがブロックされてスレッドの必要性が高まり、発生する必要があるスレッド コンテキストの切り替えの数が増加します。

詳細については、非同期プログラミングに関するページを参照してください。

警告

同期コードと非同期コードを同じアプリケーション内に混在させないでください。難解なスレッド プールの枯渇の問題を誤って発生させるのは、とても簡単です。

警告

Microsoft.Data.SqlClient の非同期実装には、残念ながらいくつかの既知の問題があります (例: #593#601、その他)。 予期しないパフォーマンスの問題が発生する場合、特に大きなテキストまたはバイナリ値を処理する場合は、代わりに同期コマンドの実行を使用してみてください。

その他のリソース