有效地查询是一个庞大的主题,涵盖范围广泛的主题,如索引、相关的实体加载策略和其他许多主题。 本部分详细介绍了一些常见套路,帮助您加快查询速度,以及用户通常遇到的陷阱。
正确使用索引
查询是否快速运行的主要决定因素是,它是否会在适当的情况下正确利用索引:数据库通常用于保存大量数据,遍历整个表的查询通常是严重性能问题的源。 索引问题并不容易发现,因为给定查询是否会使用索引并不明显。 例如:
// Matches on start, so uses an index (on SQL Server)
var posts1 = await context.Posts.Where(p => p.Title.StartsWith("A")).ToListAsync();
// Matches on end, so does not use the index
var posts2 = await context.Posts.Where(p => p.Title.EndsWith("A")).ToListAsync();
发现索引问题的一个好方法是先查明慢速查询,然后通过数据库最喜欢的工具检查其查询计划;有关如何执行此作的详细信息,请参阅 性能诊断 页。 查询计划显示查询是遍历整个表还是使用索引。
一般情况下,使用索引或诊断与其相关的性能问题没有任何特殊的 EF 知识;与索引相关的常规数据库知识与不使用 EF 的应用程序一样与 EF 应用程序相关。 下面列出了在使用索引时要记住的一些常规准则:
- 虽然索引加快查询速度,但它们也会降低更新速度,因为它们需要保留 up-to日期。 避免定义不需要的索引,并考虑使用 索引筛选器将索引 限制为行的子集,从而减少此开销。
- 复合索引可以加快对多个列进行筛选的查询,而且可以在不筛选所有索引列的情况下加快查询,具体取决于列的排列顺序。 例如,A 和 B 列的索引可加快按 A 和 B 筛选的查询,以及仅按 A 筛选的查询,但不会加快查询仅对 B 进行筛选的速度。
- 如果查询按表达式筛选列(例如
price / 2
),则无法使用简单索引。 但是,可以为表达式定义 存储的持久列 ,并基于该列创建索引。 某些数据库还支持表达式索引,这些索引可直接用于加速任何表达式的查询筛选。 - 不同的数据库允许以各种方式配置索引,在许多情况下,EF Core 提供程序通过 Fluent API 公开这些索引。 例如,SQL Server 提供程序允许你配置索引是否为 聚集索引,或者设置其 填充因子。 有关详细信息,请参阅提供商的文档。
仅筛选您需要的属性
EF Core 可以轻松地查询实体实例,然后在代码中使用这些实例。 查询实体实例时,往往会从数据库中拉取更多数据。 请考虑以下事项:
await foreach (var blog in context.Blogs.AsAsyncEnumerable())
{
Console.WriteLine("Blog: " + blog.Url);
}
尽管此代码实际上只需要每个 Blog 实体的 Url
属性,但会提取整个 Blog 实体,因此从数据库中获取了不需要的列。
SELECT [b].[BlogId], [b].[CreationDate], [b].[Name], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
这可以通过使用 Select
来告知 EF 哪些列需要投影,从而进行优化:
await foreach (var blogName in context.Blogs.Select(b => b.Url).AsAsyncEnumerable())
{
Console.WriteLine("Blog: " + blogName);
}
生成的 SQL 仅提取所需的列:
SELECT [b].[Url]
FROM [Blogs] AS [b]
如果需要投影多个列,请投影到具有所需属性的 C# 匿名类型。
请注意,此方法对于只读查询非常有用,但如果需要 更新 提取的博客,则情况会更加复杂,因为 EF 的更改跟踪仅适用于实体实例。 可以通过附加经过修改的博客实例并告知 EF 哪些属性已更改,从而在不加载整个实体的情况下执行更新,但这是一种更高级的技术,可能不值得使用。
限制结果集大小
默认情况下,查询返回与其筛选器匹配的所有行:
var blogsAll = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.ToListAsync();
由于返回的行数取决于数据库中的实际数据,因此无法知道从数据库中加载多少数据、结果占用多少内存,以及处理这些结果时会生成多少额外的负载(例如,通过网络将它们发送到用户浏览器)。 关键是,测试数据库经常包含少量数据,以便在测试时一切正常,但当查询开始在实际数据上运行并返回许多行时,性能问题突然出现。
因此,通常值得考虑限制结果数:
var blogs25 = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.Take(25)
.ToListAsync();
UI 至少会显示一条消息,指示数据库中可能存在更多行(并允许以某种其他方式检索它们)。 全面解决方案将实现 分页,其中 UI 一次仅显示一定数量的行,并允许用户根据需要前进到下一页:请参阅下一部分,详细了解如何高效实现此内容。
高效分页
分页是指在页面中检索结果,而不是一次性检索结果;这通常是针对大型结果集完成的,其中显示了允许用户导航到结果的下一页或上一页的用户界面。 使用数据库实现分页的一种常见方法是使用 Skip
和 Take
运算符(OFFSET
在 LIMIT
SQL 中);虽然这是一种直观的实现,但它也相当低效。 对于允许一次移动一页(而不是跳转到任意页面)的分页,请考虑改用 键集分页 。
有关更多详细信息,请参阅分页的文档页面。
在加载关联实体时避免笛卡尔爆炸
在关系数据库中,通过引入单个查询中的 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 可以在一次往返中提取所有必需的数据。 筛选的 include 功能还允许限制要加载哪些相关实体,同时使加载过程保持急切,因此可以在单个往返中实现:
using (var context = new BloggingContext())
{
var filteredBlogs = await context.Blogs
.Include(
blog => blog.Posts
.Where(post => post.BlogId == 1)
.OrderByDescending(post => post.Title)
.Take(5))
.ToListAsync();
}
在其他方案中,在获取其主体实体之前,我们可能不知道我们需要哪些相关实体。 例如,加载某些博客时,我们可能需要咨询一些其他数据源(可能是 Web 服务)才能知道我们是否对该博客的文章感兴趣。 在这些情况下,可以使用显式加载或延迟加载来单独获取相关实体,并填充博客的文章导航。 请注意,由于这些方法不是主动的,因此它们需要额外与数据库进行多次交互,这会导致速度变慢。根据你的具体情况,始终获取所有帖子的效率可能更高,而不是进行额外的交互并选择性地仅获取你所需的帖子。
小心延迟加载
延迟加载 通常似乎是编写数据库逻辑的一种非常有用的方法,因为 EF Core 会在代码访问相关实体时自动从数据库中加载相关实体。 这可以避免加载不需要的相关实体(如 显式加载),并且似乎使程序员无需完全处理相关实体。 但是,延迟加载尤其容易产生不需要的额外往返,这可能会减缓应用程序的速度。
请考虑以下事项:
foreach (var blog in await context.Blogs.ToListAsync())
{
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
这是怎么回事? 为何为上述简单循环发送所有这些查询? 延迟加载时,只有在访问博客的文章属性时才会加载文章;因此,内部的每次 foreach 迭代都会独立触发一次往返的数据库查询。 因此,在初始查询加载所有博客后,我们 每个博客再有另一个查询,加载其所有文章:这有时称为 N+1 问题,这可能会导致非常严重的性能问题。
假设我们需要所有博客的文章,那么在这里改用预先加载是合理的。 我们可以使用 Include 运算符执行加载,但由于我们只需要博客的 URL(我们只应 加载所需的 URL)。 因此,我们将改用投影:
await foreach (var blog in context.Blogs.Select(b => new { b.Url, b.Posts }).AsAsyncEnumerable())
{
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 = await context.Posts.Where(p => p.Title.StartsWith("A")).ToListAsync();
var blogsArray = await context.Posts.Where(p => p.Title.StartsWith("A")).ToArrayAsync();
// Foreach streams, processing one row at a time:
await foreach (var blog in context.Posts.Where(p => p.Title.StartsWith("A")).AsAsyncEnumerable())
{
// ...
}
// AsAsyncEnumerable 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
.AsAsyncEnumerable()
.Where(p => SomeDotNetMethod(p)); // Executed at the client on all database results
如果查询只返回几个结果,则你可能不必担心这一点。 但是,如果查询可能返回大量行,则值得考虑流式处理而不是缓冲。
注释
避免使用 ToList 或 ToArray 如果你打算对结果使用另一个 LINQ 运算符——这样做会不必要地将所有结果缓冲到内存中。 请改用 AsEnumerable。
EF 的内部缓冲
在某些情况下,无论您如何评估查询,EF 都会在内部缓冲结果集。 发生这种情况的两种情况是:
- 当设置了重试执行策略时。 这样做是为了确保在以后重试查询时返回相同的结果。
- 使用 拆分查询 时,将缓冲除最后一个查询在内的所有查询的结果集 -除非在 SQL Server 上启用了 MARS(多个活动结果集)。 这是因为通常不可能同时激活多个查询结果集。
请注意,除了您通过 LINQ 操作符所引发的缓冲之外,还会进行内部缓冲。 例如,如果在查询上使用 ToList 并且有重试执行策略到位,结果集将被加载到内存中两次:一次由 EF 内部加载,另一次由 ToList 加载。
跟踪、无跟踪和身份解析
建议先阅读 有关跟踪和无跟踪的专用页面 ,然后再继续阅读本部分。
EF 默认跟踪实体实例,以便在调用时检测和保留这些实例上的 SaveChanges 更改。 跟踪查询的另一个效果是 EF 检测到是否已为数据加载实例,并且会自动返回该跟踪的实例,而不是返回新实例;这称为 标识解析。 从性能的角度来看,更改跟踪意味着以下内容:
- EF 在内部维护一个已跟踪实例的字典。 加载新数据时,EF 会检查字典,以查看是否已跟踪该实体的密钥(标识解析)。。 加载查询结果时,字典维护和查找需要一些时间。
- 在将已加载的实例交给应用程序之前,EF 会为该实例拍摄快照,并在内部保留快照。 调用时 SaveChanges ,应用程序实例与快照进行比较,以发现要保留的更改。 快照占用更多内存,快照进程本身需要时间;有时可以通过 值比较器指定不同的、可能更高效的快照行为,或使用更改跟踪代理完全绕过快照过程(尽管这附带了自己的一组缺点)。
在未将更改保存回数据库的只读方案中,可以使用 无跟踪查询来避免上述开销。 但是,由于无跟踪查询不执行标识解析,因此由多个其他加载行引用的数据库行将具体化为不同的实例。
为了说明,假设我们正在从数据库加载大量帖子,以及每个文章引用的博客。 如果 100 篇文章恰好引用了同一博客,跟踪查询会通过身份解析来检测到这一点,并且所有文章实例将引用同一个去重后的博客实例。 相比之下,无跟踪查询复制同一博客 100 次,并且必须相应地编写应用程序代码。
以下是对比跟踪与无跟踪行为的基准测试结果,查询加载了 10 个博客且每个博客包含 20 篇帖子。 此处提供了源代码,请根据需要将它用作自己的度量的基础。
方法 | NumBlogs | NumPostsPerBlog(博客每篇帖子数量) | 平均值 | 错误 | StdDev | 中线 | 比率 | RatioSD | 第 0 代 | 第 1 代 | 第 2 代 | 已分配 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
AsTracking | 10 | 20 | 1,414.7 us | 27.20 我们 | 45.44 us | 1,405.5 us | 1.00 | 0.00 | 60.5469 | 13.6719 | - | 380.11 KB |
无跟踪模式 | 10 | 20 | 993.3 我们 | 24.04 us | 65.40 us | 966.2 我们 | 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 返回完整的结果集(这些结果集称为表值函数(TVF),还允许映射到
DbSet
函数,使其看起来就像另一个表一样。 - 在查询中定义数据库视图并从中进行查询。 请注意,与函数不同,视图不能接受参数。
注释
原始 SQL 通常应用作最后手段,在确保 EF 无法生成所需的 SQL 之后,当性能对于给定查询而言足够重要以证明其合理性之后。 使用原始 SQL 会带来相当大的维护缺点。
异步编程
一般情况下,为了使您的应用程序具有可扩展性,请务必始终使用异步的 API,而不是同步的 API(例如使用 SaveChangesAsync ,而不是使用 SaveChanges)。 同步 API 会在线程进行数据库 I/O 时阻塞,从而增加线程数量需求以及必须进行的线程上下文切换次数。
有关详细信息,请参阅 异步编程的页面。
警告
避免在同一应用程序中混合同步和异步代码 - 很容易无意中触发微妙的线程池饥饿问题。
警告
遗憾的是,Microsoft.Data.SqlClient 的异步实现存在一些已知问题(例如 #593、#601等)。 如果看到意外的性能问题,请尝试改用同步命令执行,尤其是在处理大型文本或二进制值时。