高效查询
高效查询是一个庞大的主题,涵盖的主题就像索引、相关实体加载策略以及许多其他主题一样广泛。 本部分详细介绍了一些用于更快地进行查询的常见主题以及用户通常会遇到的隐患。
正确使用索引
查询能否快速运行的主要决定因素是它是否在恰当的位置使用索引:数据库通常用于保存大量数据,而遍历整个表的查询往往是严重性能问题的根源。 索引问题不容易发现,因为给定的查询是否会使用索引并不是显而易见的。 例如:
// 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
),则不能使用简单索引。 但是,你可以为表达式定义存储的持久化列,并对该列创建索引。 一些数据库还支持表达式索引,可以直接使用这些索引加快按任何表达式筛选的查询。 - 不同数据库允许以各种不同的方式配置索引,在许多情况下,EF Core 提供程序都通过 Fluent API 公开这些索引。 例如,你可以通过 SQL Server 提供程序配置索引是否为聚集索引,或设置其填充因子。 参阅提供程序文档了解详细信息。
只投影需要的属性
EF Core 能非常轻松地查询出实体实例,然后将它们用于代码中。 但是,查询实体实例可能会频繁从数据库中拉取回超出所需的数据。 考虑以下情况:
foreach (var blog in context.Blogs)
{
Console.WriteLine("Blog: " + blog.Url);
}
尽管此代码实际上只需要每个博客的 Url
属性,但它提取了整个博客实体,并且从数据库传输了不需要的列:
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 的更改跟踪仅适用于实体实例。 可以在不加载整个实体的情况下执行更新,方法是:附加一个修改后的博客实例,并告诉 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 一次仅显示一定数量的行,并允许用户根据需要前进到下一页;请参阅下一部分,详细了解如何高效实现此功能。
高效分页
分页是指在页面中检索结果,而不是一次性检索结果;这通常是针对大型结果集完成的,会显示允许用户导航到结果的下一页或上一页的用户界面。 使用数据库实现分页的一种常见方法是使用 Skip
和 Take
运算符(SQL 中的 OFFSET
和 LIMIT
);虽然这是一个直观的实现,但它也相当低效。 对于允许一次移动一页(而不是跳转到任意页面)的分页,请考虑改用键集分页。
有关详细信息,请参阅分页的文档页。
在加载相关实体时避免笛卡尔爆炸
在关系数据库中,所有相关实体通过在单个查询中引入 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,可通过使用“拆分查询”来避免这种影响,这种查询通过单独的查询加载相关实体。 有关详细信息,请阅读有关拆分查询和单个查询的文档。
注意
拆分查询的当前实现为每个查询执行一次往返。 我们计划在将来改进这一点,在一次往返中执行所有查询。
尽可能预先加载相关实体
建议在继续本部分之前,先阅读相关实体专用页面。
处理相关实体时,我们通常会提前知晓需要加载什么:典型的示例是加载一组特定的博客以及它们的所有文章。 在这些情况下,最好的做法始终是使用预先加载,这样 EF 可以在一次往返中提取所有必需的数据。 经过筛选的包含功能让你能限制要加载的相关实体,同时使加载过程保持为预先加载,从而可在一次往返中实现:
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();
}
在其他情况下,在获得相关实体的主体实体之前,我们可能不知道需要哪些相关实体。 例如,加载某个博客时,我们可能需要参考另外一个数据源(可能是某个 Web 服务),以便了解我们是否对该博客文章感兴趣。 在这些情况下,可以使用显式或延迟加载单独提取相关实体,并填充博客文章导航。 请注意,这些方法都不是预先的,因此需要对数据库执行额外的往返,这是速度减缓的根源;根据具体的场景,比起执行额外的往返并有选择性地只获取需要的文章,始终只加载所有文章可能更高效。
注意延迟加载
延迟加载看上去像是一种非常有用的数据库逻辑编写方法,因为 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
这是怎么回事? 为什么为上面的简单循环发送所有这些查询? 使用延迟加载时,仅在访问博客文章的 Posts 属性时(延迟)加载这些文章;于是,内部 foreach 中的每个迭代都在其自身的往返中触发额外的数据库查询。 因此,在初始查询加载所有博客后,我们会在每个博客中使用另一个查询加载其中的所有文章。这有时被称为 N+1 问题,可能会导致重大性能问题。
假设我们需要所有博客文章,可以在此改为使用预先加载。 可以使用 Include 运算符来执行加载,但由于我们只需要博客的 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 在一个查询中提取所有博客及其文章。 在有的情况下,使用拆分查询可能有助于避免笛卡尔爆炸效果。
警告
延迟加载非常容易在无意中触发 N+1 问题,因此建议避免使用这种加载方式。 预先加载或显式加载使源代码中发生数据库往返的时间非常清楚。
缓冲和流式处理
缓冲指将所有查询结果加载到内存中,而流式处理意味着 EF 每次向应用程序提供一个结果,绝不让内存中包含整个结果集。 原则上,流式处理查询的内存要求是固定的:无论查询返回 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
如果查询只返回几个结果,可能无需担心这一点。 但是,如果查询可能返回的行数非常多,则有必要考虑使用流式处理而不是缓冲。
注意
如果要对结果使用另一个 LINQ 运算符,请避免使用 ToList 或 ToArray,因为这样会不必要地将所有结果缓冲到内存中。 请改用 AsEnumerable。
EF 执行的内部缓冲
在某些情况下,无论如何计算查询,EF 都会在内部自行缓冲结果集。 出现这种情况的两个场景是:
- 重试执行策略已准备就绪时。 这样做是为了确保在以后重试查询时返回相同的结果。
- 使用拆分查询时,会缓冲除最后一个查询外的所有查询的结果集,除非在 SQL Server 上启用了 MARS(多重活动结果集)。 这是因为通常无法同时激活多个查询结果集。
请注意,除通过 LINQ 运算符引发的任何缓冲外,还会发生这种内部缓冲。 例如,如果你对查询使用了 ToList 并且重试执行策略就绪,则结果集会被加载到内存中两次:一次由 EF 在内部加载,一次由 ToList 加载。
跟踪、非跟踪和标识解析
建议在继续本部分之前,先阅读关于跟踪和非跟踪的专用页面。
EF 默认跟踪实体实例,因此在调用 SaveChanges 时,会检测并保存对实体实例所做的更改。 跟踪查询的另一个作用是:EF 检测是否已为你的数据加载了实例,并将自动返回跟踪的实例,而不是返回新实例。我们将这种做法称为标识解析。 从性能的角度来看,更改跟踪意味着:
- EF 在内部维护跟踪实例的字典。 加载新数据时,EF 会查阅字典,以了解是否已为该实体的键跟踪了实例(标识解析)。 加载查询结果时,字典维护和查找会花费一些时间。
- 在将加载的实例交给应用程序之前,EF 截取该实例的快照,并在内部保存该快照。 调用 SaveChanges 时,会将应用程序的实例与快照做比较,以发现要保存的更改。 快照占用更多内存,截取快照的过程本身需要时间;有时可以通过值比较器指定不同的、可能更高效的快照截取行为,或使用更改跟踪代理完全绕过快照截取过程(虽然这种做法本身也有一些缺点)。
在不将更改保存回数据库的只读场景中,可通过使用非跟踪查询来避免上述开销。 但非跟踪查询不执行标识解析,所以由多个其他已加载的行引用的数据库行将被具体化为不同的实例。
为了说明这一点,假设我们要从数据库中加载大量文章以及每篇文章引用的博客。 如果碰巧有 100 篇文章引用了同一个博客,则跟踪查询通过标识解析来检测这种情况,并且所有文章实例都将引用同一个删除了重复数据的博客实例。 而无跟踪查询会将相同的博客重复 100 次,我们必须相应地编写应用程序代码。
下面是比较加载 10 个博客(各有 20 篇文章)的查询的跟踪行为与非跟踪行为的基准检验的结果。 此处提供了源代码,请根据需要将它用作自己的度量的基础。
方法 | NumBlogs | NumPostsPerBlog | 平均值 | 错误 | 标准偏差 | 中值 | 比率 | 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。 如果 SQL 构造是特定于不受支持的数据库的扩展,或者 EF 不转换为该构造,可能会发生这种情况。 在这些情况下,手动编写 SQL 可以显著提高性能,而 EF 支持通过多种方法来实现此目的。
- 直接在查询中使用 SQL 查询,例如通过 FromSqlRaw。 EF 让你甚至可以通过常规 LINQ 查询基于 SQL 进行撰写,从而能够在 SQL 中仅表达查询的一部分。 只需要将 SQL 用于代码库中的一个查询时,这种方法很不错。
- 定义用户定义的函数 (UDF),然后从查询中调用它。 请注意,EF 允许 UDF 返回完整的结果集,这些 UDF 被称为表值函数 (TVF),它还允许将
DbSet
映射到函数,使其看起来像另一个表。 - 在查询中定义一个数据库视图并从中进行查询。 请注意,与函数不同,视图不能接受参数。
注意
在确定 EF 无法生成所需的 SQL 后,并且当性能问题大到给定查询能判断时,通常可以将原始 SQL 作为最后的方法。 使用原始 SQL 在维护方面的缺点相当大。
异步编程
一般来说,为了使应用程序可缩放,务必始终使用异步 API,而不是同步 API(例如使用 SaveChangesAsync,而不是 SaveChanges)。 同步 API 在数据库输入/输出 (I/O) 期间阻止线程,增加了对线程的需要和必须发生的线程上下文切换的次数。
有关详细信息,请参阅异步编程页面。
警告
避免在同一个应用程序中混合同步和异步代码,因为这样很容易无意中触发不易察觉的线程池不足问题。
警告
遗憾的是,Microsoft.Data.SqlClient 的异步实现存在一些已知问题(例如 #593、#601 等)。 如果遇到意外的性能问题,请尝试改用同步命令执行,尤其是在处理大型文本或二进制值时。