ORM(对象关系映射)框架确实简化了开发人员在所有数据库相关任务中的生活。
然而,通常情况下,这种简化是有代价的。在大多数情况下,代价是灵活性和性能。
在本文中,我将介绍三种最简单的方法来提高你的 .NET Core 应用程序中 Entity Framework Core(EF Core)操作的性能。
我们先从最明显的建议说起,其实在某些情况下,EF Core 使用不当,可以给你最大的性能提升。
所以,想象一个很常见的情况:
EF Core 2.1 引入了一个叫做 "懒加载 "的东西(或者说是 "重新引入",因为这个功能在老式的 Entity Framework 6.x 中是可用的),所以现在你可以简单地用下面的代码来实现所述任务。
var thisYearFirstDay = new DateTime(DateTime.Now.Year, 1, 1);
var thisYearOrders = context.Orders
.Where(o => o.OrderDate > thisYearFirstDay);
foreach (var order in thisYearOrders)
{
Console.WriteLine($"{order.Id} {order.OrderDate} {order.Customer.CompanyName}");
}
很简单,对不对?
错了! 其实很偷懒:),使用这段代码你会得到巨大的性能打击。
为什么这么说呢?很简单,因为 EF Core 会将你的代码转换为 1+N 查询到数据库,其中 N-是 thisYearOrders 结果集中的记录数。
如果你在你的 DbContext 中添加一个记录器,并查看记录消息,你会看到这样一个查询:
SELECT [o].[OrderID], [o].[CustomerID], . . .
FROM [Orders] AS [o]
WHERE [o].[OrderDate] > @__thisYearFirstDay_0
然后 N 次这样的查询:
SELECT [e].[CustomerID], [e].[Address], . . .
FROM [Customers] AS [e]
WHERE [e].[CustomerID] = @__get_Item_0
显然,这样的查询次数会耗费大量的时间,尤其是在连接不畅或数据库庞大的情况下。
为了解决这个问题,我们只需要使用所谓的 "急切加载",并在我们的请求中添加Include
调用:
var thisYearOrders = context.Orders
.Include(o => o.Customer)
.Where(o => o.OrderDate > thisYearFirstDay);
现在,如果我们看一下我们的日志,我们会看到只有一个查询,而不是 1+N:
SELECT [o].[OrderID], [o].[CustomerID], . . . /* all other columns from Orders and Customers */
FROM [Orders] AS [o]
LEFT JOIN [Customers] AS [o.Customer] ON [o].[CustomerID] = [o.Customer].[CustomerID]
WHERE [o].[OrderDate] > @__thisYearFirstDay_0
正如你所想的那样,"急于求成 "的方法会比 "懒惰 "的方法执行得更快。
在实际应用中,这种优势的价值可以是几百甚至上千的百分比!
我们第一个例子中的 "急切 "方法的问题是,它在大多数情况下可能过于 "急切"。
看,我们实际上只需要三个字段(两个来自 Orders 表,一个来自 Customers 表),但我们却从两个表中取了整组列。
解决方法很简单:用 Select 调用只取你需要的那些列:
var thisYearFirstDay = new DateTime(DateTime.Now.Year, 1, 1);
var thisYearOrders = context.Orders
.Where(o => o.OrderDate > thisYearFirstDay)
.Select(o => new { o.Id, o.OrderDate, o.Customer.CompanyName })
foreach (var rec in thisYearOrders)
{
Console.WriteLine($"{rec.Id} {rec.OrderDate} {rec.CompanyName}");
}
由此产生的 SQL 语句将是这样的:
SELECT [o].[OrderID] AS [Id], [o].[OrderDate], [o.Customer].[CompanyName]
FROM [Orders] AS [o]
LEFT JOIN [Customers] AS [o.Customer] ON [o].[CustomerID] = [o.Customer].[CustomerID]
WHERE [o].[OrderDate] > @__thisYearFirstDay_0
它肯定会有更好的性能,因为从 DB 服务器到客户端(在这种情况下,你的.NET 应用程序)传输的数据会减少。
在上面的代码中,有两点需要注意。
首先,我们不再需要 Include 调用,因为 Entity Framework 从 Select 调用中 "理解 "了我们需要一个来自另一个表的字段(Customer.CompanyName),并自动将必要的 JOIN 子句添加到结果 SQL 中。
其次,正如你所看到的,我们实际上用新的{o.Id,...}命令创建了一个动态对象列表(不属于任何特定类的对象)。
为了强调这个事实,我们将 foreach 循环中的变量名从 order 替换成了 rec(来自 "rec 记录")--因为它已经不是一个 "order "了。我们在结果中得到的动态对象只包含 3 个属性。Id、OrderDate 和 CompanyName(这就是为什么我们现在直接访问 "CompanyName "而不是之前的 "order.Customer.CompanyName")。
当你在 DbContext 中的一些实体上运行查询时,返回的对象会被上下文自动跟踪,以允许你修改它们(如果需要),然后用 context.SaveChanges()操作保存更改。
然而,如果这是一个只读查询,而且返回的数据不应该被修改,那么就没有必要让上下文执行一些建立这种跟踪所需的额外工作。AsNoTracking 方法告诉 Entity Framework 停止这种额外的工作,因此,它可以提高应用程序的性能。
所以,从理论上讲,有 AsNoTracking 的查询应该比没有 AsNoTracking 的查询性能更好。问题是:好多少?让我们来弄清楚。
我使用 BenchmarkDotNet 库创建了一个小型测试应用程序。这里有两个被检查的函数:
public void GetAll_WithTracking()
{
var allRecords = _dbContext.OrderDetails;
var list = allRecords.ToList();
}
public void GetAll_NoTracking()
{
var allRecords = _dbContext.OrderDetails.AsNoTracking();
var list = allRecords.ToList();
}
正如我们所期望的那样,"NoTracking "方式表现得更好。一般来说,它似乎比 "WithTracking "快 1.5 倍。
当我们试图将 AsNoTracking 添加到对两个表的查询中时,奇怪的事情就开始了(有 JOIN):
public void GetWithInclude_Tracking()
{
var allRecords = _dbContext.OrderDetails
.Include(od => od.Product);
var list = allRecords.ToList();
}
public void GetWithInclude_NoTracking()
{
var allRecords = _dbContext.OrderDetails
.AsNoTracking()
.Include(od => od.Product);
var list = allRecords.ToList();
}
结果令人困惑:
有跟踪的查询比没有跟踪的查询性能稍好!在 GitHub 上有一个问题,关于这种奇怪的行为(第一眼看上去)。
在 GitHub 上有一个关于这种奇怪(乍一看)行为的问题。
然而,如果我们尝试想象一下 Entity Framework 内部是如何实现的,这种行为可能是相当符合逻辑的。连接两张表的查询需要从两张表中获取记录来进行匹配,如果这些记录已经被上下文跟踪,因此,存储在内存中,显然会发生得更快。
不过,这只是我的猜测。如果你有其他想法,请告诉我。
我希望我在本文中列出的关于性能改进的建议能帮助你写出更高效的代码。
我只想再给你一个一般性的建议。使用源码:)
在使用 EF Core 方面,这意味着:看看你的代码生成的 SQL 语句(你可以在调试时在 VS 的输出面板中看到它们)。
它可以让你了解 "引擎盖下 "发生了什么,在某些情况下,可能会帮助你显著提高你的应用程序的性能。