一、单个查询的性能问题
在针对关系数据库工作时,EF通过将JOIN引入单个查询来加载相关实体。虽然使用SQL时,JOIN是相当标准的,但如果使用不当,可能会引发严重的性能问题。本文将介绍这些性能,并展示了一种可充当临时解决办法的用于加载相关实体的替代方法。
1.1/笛卡尔爆炸
分析下面LINQ查询及其转换后的SQL等效项:
var blogs = ctx.Blogs .Include(b => b.Posts) .Include(b => b.Contributors) .ToList();
转换后等效SQL
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]
上面示例中,由于Posts和Contributors均为Blog的集合导航(且为同一级别)因此关系数据库返回一个叉积:Posts的每一行与Contributors的每一行联接。这意味着,如果给定的博客有 10 篇文章和 10 个贡献者,则数据库将为该博客返回 100 行。 这种现象(有时称为笛卡尔爆炸)可能会导致意外将大量数据传输到客户端,尤其是在将更多同级 JOIN 添加到查询时;这可能会成为数据库应用程序中的主要性能问题。
如果使用两个不为同一个级别时,不会发生笛卡尔爆炸:
var blogs = ctx.Blogs .Include(b => b.Posts) .ThenInclude(p => p.Comments) .ToList();
上面不同级别的JOIN,会产生如下SQL:
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]
在此查询中,Comments
是 Post
的集合导航,与上一查询中的 Contributors
不同,后者是 Blog
的集合导航。 在这种情况下,会为每个博客(通过其文章)的评论返回一行,且不会发生跨产品的情况。
1.2/数据重复
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]
查看投影列,此查询返回的每一行都包含来自 Blogs 和 Posts 表的属性;这意味博客的每篇文章具有相同的博客属性。 这一般是正常的,不会造成问题,但如果 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(或任何其他唯一属性或属性组合进行排序)使排序完全唯一,并避免此问题。注意:关系数据库默认不应用任何排序,即使在主键上也是如此。
三、全局启用拆分查询
还可以将拆分查询配置为应用程序上下文的默认查询:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder .UseSqlServer( @"Server=(localdb)\mssqllocaldb;Database=EFQuerying;Trusted_Connection=True;ConnectRetryCount=0", 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)允许同时使用多个查询的结果,但大多数数据库在任何给定时间点只允许一个查询处于活动状态。 因此,在执行以后的查询之前,必须先在应用程序的内存中缓冲先前查询的所有结果,这将增加内存需求。
- 在包括引用导航和集合导航时,每个拆分查询都将包括引用导航的联接。 这可能会降低性能,尤其是在有许多引用导航的情况下