单个查询与拆分查询

单个查询的性能问题

在针对关系数据库工作时,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 个贡献者,则数据库将为该博客返回 100 行。 这种现象(有时称为笛卡尔爆炸)可能会导致意外将大量数据传输到客户端,尤其是在将更多同级 JOIN 添加到查询时;这可能会成为数据库应用程序中的主要性能问题。

请注意,当两个 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 的集合导航,与上一查询中的 Contributors 不同,后者是 Blog 的集合导航。 在这种情况下,会为每个博客(通过其文章)的评论返回一行,且不会发生跨产品的情况。

数据重复

JOIN 可能会引发建另一类性能问题。 现在仔细查看以下查询,该查询仅加载单个集合导航:

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(或任何其他唯一属性或属性组合进行排序)使排序完全唯一,并避免此问题。 请注意,关系数据库默认不应用任何排序,即使在主键上也是如此。

注意

一对一相关实体始终都是通过 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 和笛卡尔爆炸相关的性能问题,但它也有一些缺点:

  • 虽然大多数数据库对单个查询保证数据一致性,但对多个查询不存在这样的保证。 如果在执行查询时同时更新数据库,生成的数据可能会不一致。 这可以通过将查询包装在可序列化或快照事务中来缓解,尽管这样做本身可能会产生性能问题。 有关详细信息,请参见数据库器文档。
  • 当前,每个查询都意味着对数据库进行一次额外的网络往返。 多次网络往返会降低性能,尤其是在数据库延迟很高的情况下(例如云服务)。
  • 虽然有些数据库(带有 MARS 的 SQL Server、Sqlite)允许同时使用多个查询的结果,但大多数数据库在任何给定时间点只允许一个查询处于活动状态。 因此,在执行以后的查询之前,必须先在应用程序的内存中缓冲先前查询的所有结果,这将增加内存需求。
  • 在包括引用导航和集合导航时,每个拆分查询都将包括引用导航的联接。 这可能会降低性能,尤其是在有许多引用导航的情况下。 如果这是你想要看到的固定内容,请启动 #29182

遗憾的是,没有一种加载相关实体的策略可以适用于所有情况。 请仔细考虑单个查询和拆分查询的优缺点,以便选择能够满足你需求的策略。