単一クエリと分割クエリ

単一クエリでのパフォーマンスの問題

リレーショナル データベースに対して動作するとき、EF は JOIN を単一クエリに導入することによって関連エンティティを読み込みます。 SQL を使うときは非常に標準的な JOIN ですが、不適切に使うとパフォーマンスに重大な問題が発生する可能性があります。 このページでは、これらのパフォーマンスの問題について説明し、それを回避して関連エンティティを読み込む代わりの方法を示します。

デカルト爆発

次の LINQ クエリと、それに相当する変換後の SQL を調べてみましょう。

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToList();
SELECT [b].[Id], [b].[Name], [p].[Id], [p].[BlogId], [p].[Title], [c].[Id], [c].[BlogId], [c].[FirstName], [c].[LastName]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Contributors] AS [c] ON [b].[Id] = [c].[BlogId]
ORDER BY [b].[Id], [p].[Id]

この例では、PostsContributors はどちらも Blog のコレクション ナビゲーションであるため (どちらも同じレベル)、リレーショナル データベースからは、Posts の各行が Contributors の各行と結合された "クロス積" が返されます。 つまり、特定のブログに 10 件の投稿と 10 人の共同作成者が含まれる場合、データベースからはその 1 つのブログに対して 100 行が返されます。 "デカルト爆発" と呼ばれることもあるこの現象により、大量のデータが意図せずにクライアントに転送されることがあります (特に、多くの兄弟 JOIN がクエリに追加される場合)。これは、データベース アプリケーションでのパフォーマンスに関する大きな問題になる可能性があります。

2 つの JOIN が同じレベルでない場合は、デカルト爆発は発生しないことに注意してください。

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ThenInclude(p => p.Comments)
    .ToList();
SELECT [b].[Id], [b].[Name], [t].[Id], [t].[BlogId], [t].[Title], [t].[Id0], [t].[Content], [t].[PostId]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Comment] AS [c] ON [p].[Id] = [c].[PostId]
ORDER BY [b].[Id], [t].[Id]

このクエリの CommentsPost のコレクション ナビゲーションであり、これは Blog のコレクション ナビゲーションであった前のクエリの Contributors とは異なります。 この場合は、ブログに (投稿を通じて) 含まれるコメントごとに 1 つの行が返され、クロス積は発生しません。

データの重複

JOIN によって、別の種類のパフォーマンスの問題が発生する可能性があります。 コレクション ナビゲーションを 1 つだけ読み込む次のクエリを調べてみましょう。

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ToList();
SELECT [b].[Id], [b].[Name], [b].[HugeColumn], [p].[Id], [p].[BlogId], [p].[Title]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
ORDER BY [b].[Id]

射影された列を調べると、このクエリによって返される各行に、BlogsPosts 両方のテーブルのプロパティが含まれます。つまり、ブログに含まれる投稿ごとに、ブログのプロパティが重複しています。 これは通常であれば普通のことであり、問題になりませんが、Blogs テーブルに非常に大きな列 (バイナリ データや巨大なテキストなど) が含まれていた場合は、その列が重複して存在するようになり、クライアントに何回も送り返されます。 これにより、ネットワーク トラフィックが大幅に増加し、アプリケーションのパフォーマンスに悪影響を及ぼす可能性があります。

巨大な列が実際には必要ない場合は、そのクエリを実行しないだけで簡単に済みます。

var blogs = ctx.Blogs
    .Select(b => new
    {
        b.Id,
        b.Name,
        b.Posts
    })
    .ToList();

プロジェクションを使って必要な列を明示的に選ぶことで、大きな列を省略し、パフォーマンスを向上させることができます。これはデータの重複に関係なく良いアイデアなので、コレクション ナビゲーションを読み込まない場合でも、そのようにすることを検討してください。 ただし、これによってブログは匿名型に射影されるため、ブログは EF によって追跡されず、通常のように変更を保存することはできません。

大事なこととして、デカルト爆発とは異なり、通常、重複するデータ サイズはごくわずかであるため、JOIN によって発生するデータの重複が大きな影響を与えることはありません。通常、このことを心配する必要があるのは、プリンシパル テーブルに大きな列がある場合だけです。

分割クエリ

上で説明したパフォーマンスの問題に対する回避策として、EF では、特定の LINQ クエリを複数の SQL クエリに "分割する" 必要があることを指定できます。 分割クエリを使用すると、JOIN ではなく、含まれているコレクション ナビゲーションごとに追加の SQL クエリが生成されます。

using (var context = new BloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSplitQuery()
        .ToList();
}

これにより、次の SQL が生成されます。

SELECT [b].[BlogId], [b].[OwnerId], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
ORDER BY [b].[BlogId]

SELECT [p].[PostId], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title], [b].[BlogId]
FROM [Blogs] AS [b]
INNER JOIN [Posts] AS [p] ON [b].[BlogId] = [p].[BlogId]
ORDER BY [b].[BlogId]

警告

Skip/Take で分割クエリを使用する場合は、クエリの並べ替えが完全に一意になるように特に注意してください。これを行わないと、正しくないデータが返されるおそれがあります。 たとえば、結果が日付のみによって並べ替えられるが、同じ日付の結果が複数存在する可能性がある場合、それぞれの分割クエリごとにデータベースから異なる結果が得られるおそれがあります。 日付と ID (またはその他の一意のプロパティ、またはプロパティの組み合わせ) の両方で並べ替えると、順序が完全に一意になるため、この問題を回避できます。 リレーショナル データベースでは、主キーであっても、既定で並べ替えは適用されないことに注意してください。

Note

一対一で関連するエンティティは、パフォーマンスへの影響がないため、常に同じクエリ内で JOIN によって読み込まれます。

分割クエリをグローバルに有効にする

分割クエリは、アプリケーションのコンテキストで既定値として構成することもできます。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(
            @"Server=(localdb)\mssqllocaldb;Database=EFQuerying;Trusted_Connection=True",
            o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}

分割クエリが既定値として構成されている場合でも、特定のクエリを単一のクエリとして実行するように構成することができます。

using (var context = new SplitQueriesBloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSingleQuery()
        .ToList();
}

どのような構成も存在しない場合は、単一クエリ モードが EF Core によって既定で使用されます。 パフォーマンスの問題が発生する可能性があるため、次の条件が満たされるときは常に EF Core によって警告が生成されます。

  • クエリで複数のコレクションが読み込まれることが EF Core によって検出された。
  • ユーザーがクエリ分割モードをグローバルに構成していない。
  • ユーザーがクエリで AsSingleQuery/AsSplitQuery 演算子を使用していない。

警告をオフにするには、クエリ分割モードをグローバルに、またはクエリ レベルで、適切な値に構成します。

分割クエリの特性

分割クエリによって JOIN とデカルト爆発に関連するパフォーマンス上の問題が回避されますが、いくつかの欠点もあります。

  • ほとんどのデータベースでは単一クエリに対してデータの整合性が保証されますが、複数クエリに対してこのような保証は存在しません。 クエリの実行と同時にデータベースが更新された場合、生成されるデータの整合性が失われる可能性があります。 これはシリアル化可能なトランザクションまたはスナップショット トランザクションでクエリをラップすることで軽減できますが、これにより、それ自体のパフォーマンス上の問題が発生する可能性があります。 詳細については、ご利用のデータベースのドキュメントを参照してください。
  • 現在、各クエリは、データベースに対する追加のネットワーク ラウンドトリップを意味します。 特にデータベースの待機時間が長い場合 (クラウド サービスなど)、複数のネットワーク ラウンドトリップによってパフォーマンスが低下する可能性があります。
  • 一部のデータベースでは、複数のクエリの結果を同時に使用することが許可されていますが (SQL Server と MARS、Sqlite)、ほとんどの場合、特定の時点でアクティブにできるクエリは 1 つだけです。 そのため、後のクエリを実行する前に、前のクエリの結果をすべてアプリケーションのメモリにバッファーする必要があります。これにより、メモリ要件が大きくなります。
  • 参照ナビゲーションとコレクション ナビゲーションの両方を含めると、分割クエリのそれぞれに参照ナビゲーションへの結合が含まれます。 これにより、パフォーマンスが低下する可能性があり、参照ナビゲーションが多数存在する場合は特にそうです。 これを修正したい場合は、#29182 に投票してください。

残念ながら、すべてのシナリオに適合する関連エンティティの読み込み方法はありません。 単一クエリと分割クエリの長所と短所を慎重に検討し、ニーズに合うものを選択してください。