标签:
对象关系映射框架是一种在面向对象的应用程序中提供数据访问抽象的便捷方式。对于 .NET 应用程序,Microsoft 推荐的 O/RM 是实体框架。但任何抽象都要考虑性能。
本白皮书旨在介绍在使用实体框架开发应用程序时的性能注意事项,使开发人员了解能够影响性能的实体框架内部算法,以及提供有关进行调查及在使用实体框架的应用程序中提高性能的提示。网络上有大量很好的有关性能的主题,我们还尽可能地指出这些资源的链接。
性能是一个很微妙的主题。对于使用实体框架的应用程序,可将本白皮书作为资源来帮助做出与性能相关的决策。我们提供了一些测试指标来演示性能,但这些指标不是在应用程序中看到的性能的绝对指标。
出于实用考虑,本文假设实体框架 4 在 .NET 4.0 下运行,实体框架 5 在 .NET 4.5 下运行。对实体框架 5 进行的许多性能改进存在于 .NET 4.5 附带的核心组件中。
第一次针对给定模型进行任何查询时,实体框架在后台进行了大量工作来加载和验证模型。我们经常将这个第一次查询称为“冷”查询。针对已加载模型的进一步查询称为“热”查询,速度更快。
我们深入了解一下在使用实体框架执行查询时,时间花在了哪里,看看实体框架 5 在哪些方面进行了改进。
首次查询执行 — 冷查询
第二次查询执行 — 热查询
有几种方式可降低冷、热查询的性能成本,后面几节将探讨这些方式。具体讲,我们将介绍通过使用预生成的视图降低冷查询中模型加载的成本,这应有助于缓解在视图生成过程中遇到的性能问题。对于热查询,将介绍查询计划缓存、无跟踪查询和不同的查询执行选项。
要了解什么是视图生成,必须先了解什么是“映射视图”。映射视图是每个实体集和关联的映射中指定的转换的可执行表示。在内部,这些映射视图采用 CQT(规范查询树)的形状。映射视图有两种类型:
根据映射规范计算这些视图的过程即是所谓的视图生成。视图生成可在加载模型时动态进行,也可在生成时通过使用“预生成的视图”进行;后者以实体 SQL 语句的形式序列化为 C# 或 VB 文件。
生成视图时还会对它们进行验证。从性能角度看,视图生成的绝大部分成本实际上是视图验证产生的,视图验证可确保实体之间的连接有意义,并且对于所有支持的操作都有正确的基数。
在执行实体集查询时,查询与相应查询视图相组合,这种组合的结果通过计划编译器运行,以便创建后备存储能够理解的查询表示。对于 SQL Server,编译的最终结果是 T-SQL SELECT 语句。首次对实体集执行更新时,更新视图通过类似过程运行,将其转换成用于目标数据库的 DML 语句。
视图生成步骤的性能不仅取决于模型的大小,还取决于模型的互连方式。如果两个实体通过继承链或关联进行连接,则称它们已连接。同样,如果两个表通过外键进行连接,则它们已连接。随着架构中已连接实体和表数目的增加,视图生成的成本也增加。
在最糟糕的情况下,尽管我们使用一些优化进行改进,用于生成和验证视图的算法仍呈现指数特性。对性能具有最大负面影响的因素有:
简单的小模型,成本小到不值得使用预生成的视图。随着模型大小和复杂性的增加,有多种选择可降低视图生成和验证的成本。
当利用 EDMGen 生成模型时,输出包含一个 Views 文件。这是一个代码文件,包含针对每个实体集的实体 SQL 代码段。要启用预生成的视图,在项目中包含此文件即可。
如果手动编辑模型的架构文件,需要重新生成视图文件。为此,可带 /mode:ViewGeneration 标志运行 EDMGen。
有关进一步参考,请参见 MSDN 主题“如何:预生成视图以改善查询性能”: http://msdn.microsoft.com/library/bb896240.aspx。
另外,还可以使用 EDMGen 来生成 EDMX 文件的视图 — 前面提到的 MSDN 主题介绍如何添加预生成事件来执行此操作 — 但这很复杂,并且有时候不适用。当模型位于 edmx 文件中时,使用 T4 模板生成视图通常更加容易。
ADO.NET 团队博客中有一篇文章介绍如何使用 T4 模板进行视图生成 ( http://blogs.msdn.com/b/adonet/archive/2008/06/20/how-to-use-a-t4-template-for-view-generation.aspx)。这篇文章包括一个模板,您可以下载和添加到项目中。这个模板是为实体框架的第一个版本编写的。要在 Visual Studio 2010 中使用这个模板,需要在 GetConceptualMappingAndStorageReaders 方法中修改 XML 命名空间,以便使用实体框架 5 的命名空间:
XNamespace edmxns = "http://schemas.microsoft.com/ado/2009/11/edmx";
XNamespace csdlns = "http://schemas.microsoft.com/ado/2009/11/edm";
XNamespace mslns = "http://schemas.microsoft.com/ado/2009/11/mapping/cs";
XNamespace ssdlns = "http://schemas.microsoft.com/ado/2009/11/edm/ssdl";
另外,可以在 Code First 项目中使用预生成的视图。实体框架 Power Tools 能够为 Code First 项目生成视图文件。通过在 Visual Studio 库中搜索“实体框架 Power Tools”,可以找到这些增强工具。在编写本文时,预发行版 CTP1 中提供了增强工具。
使用预生成的视图可将视图生成成本从模型加载(运行时)转移到编译时。尽管这会改善运行时的启动性能,但在开发时仍会遇到视图生成问题。有几种其他技巧可帮助在编译时和运行时降低视图生成的成本。
将模型中的关联从独立关联转换为外键关联可极大缩短视图生成所用的时间,这种情况很常见。
为演示这种改进,我们使用 EDMGen 生成了 Navision 模型的两个版本。注意:有关 Navision 模型的说明,请参见附录 C。在这个练习中,Navision 模型非常有趣,它有大量实体,实体之间有大量关系。
这种超大模型的一个版本是使用外键关联生成的,另一个是使用独立关联生成的。然后我们对使用 EDMGen 为每个模型生成视图所用的时间进行了计时。对于使用外键的模型,视图生成所用的时间为 104 分钟。不知道生成使用独立关联的模型会用多长时间。我们让这次测试运行了一个多月,然后在实验室中重新启动计算机,以便安装每月更新。
当使用 EDMGen 或 Visual Studio 中的 Entity Designer 时,在默认情况下会得到外键,它仅用一个复选框或命令行标志在外键与独立关联之间进行切换。
如果有大型 Code First 模型,使用独立关联对视图生成具有相同影响。可通过在依赖对象的类上包含外键属性来避免这种影响,但有些开发人员认为这会污染他们的对象模型。在 http://blog.oneunicorn.com/2011/12/11/whats-the-deal-with-mapping-foreign-keys-using-the-entity-framework/ 中可获得有关该主题的更多信息。
使用的工具 | 进行的操作 |
实体设计器 | 在两个实体之间添加了关联后,确保具有引用约束。引用约束告诉实体框架使用外键,而不是独立关联。有关更多详细信息,请访问 http://blogs.msdn.com/b/efdesign/archive/2009/03/16/foreign-keys-in-the-entity-framework.aspx。 |
EDMGen | 使用 EDMGen 从数据库生成文件时,需要外键,因此外键会添加到模型中。有关 EDMGen 公开的不同选项的更多信息,请访问 http://msdn.microsoft.com/library/bb387165.aspx。 |
Code First | 有关在使用 Code First 时如何包含依赖对象的外键属性的信息,请参见 MSDN 中“Code First 约定”主题的“关系约定”部分 ( http://msdn.microsoft.com/library/hh161541(v=VS.103).aspx)。 |
如果在应用程序的项目中直接包含模型,通过预生成事件或 T4 模板生成视图,则只要重新生成项目,即使没有更改模型,也会进行视图生成和验证。如果将模型移到单独程序集,从应用程序的项目中引用它,则可对应用程序进行其他更改,无需重新生成包含模型的项目。
注意:在将模型移到单独程序集时,记住将模型的连接字符串复制到客户端项目的应用程序配置文件中。
EDMX 模型在编译时进行验证,即使模型未更改也是如此。如果已经验证了模型,则可通过在属性窗口中将“生成时验证”属性设置为 False 来禁用验证。更改映射或模型时,可临时重新启用验证,以验证更改。
如果应用程序仅用于查询方案,则可通过向 XML 映射中的 EntityContainerMapping 元素添加 GenerateUpdateViews 属性,然后将其设置为 False,将模型标记为只读。经验表明,生成更新视图的成本比生成查询视图的成本更高,因此要意识到这一点,避免在不需要时生成更新视图。
实体框架有以下内置缓存形式:
除实体框架提供的随取即用缓存外,还可使用一种特殊类型的 ADO.NET 数据提供程序(称为包装提供程序)来扩展实体框架,使其能够缓存从数据库中检索的结果,这也称为二级缓存。
在默认情况下,当查询结果中返回一个实体时,在 EF 刚对它进行具体化前,ObjectContext 将检查是否已经将具有相同键的实体加载到了其 ObjectStateManager 中。如果已经存在具有相同键的实体,则实体框架会将其包含在查询结果中。尽管 EF 仍将发出对数据库的查询,但此行为可避免多次具体化该实体的大部分成本。
与常规查询不同,DbSet(API 首次包含在 EF 4.1 中)中的 Find 方法将在内存中执行搜索,即使在发出对数据库的查询之前也是如此。注意,两个不同的 ObjectContext 实例将具有两个不同的 ObjectStateManager 实例,这一点非常重要,这意味着它们有单独的对象缓存。
Find 使用主键值尝试查找上下文所跟踪的实体。如果该实体没有在上下文中,则执行和评估对数据库的查询,如果在上下文或数据库中没有发现该实体,则返回 null。注意,Find 还返回已添加到上下文但尚未保存到数据库中的实体。
使用 Find 时,有一项性能注意事项。在默认情况下,对此方法的调用将触发对象缓存的验证,以便检测仍在等待提交到数据库的更改。如果对象缓存中或者要添加到对象缓存的大型对象图中有非常多的对象,则此过程的成本可能会非常高,但也可禁用此过程。在某些情况下,当禁用自动检测更改时,在调用 Find 方法方面可能存在巨大差异。对象实际上位于缓存中与必须从数据库检索对象这两种情况,也存在巨大差异。以下是使用我们的一些微基准进行测量的示例图,单位为毫秒,加载了 5000 个实例:
自动检测更改已禁用的 Find 示例:
context.Configuration.AutoDetectChangesEnabled = false;
var product = context.Products.Find(productId);
...
使用 Find 方法时必须考虑:
此外,请注意 Find 仅返回要查找的实体,它不会自动加载未在对象缓存中的关联实体。如果需要检索关联实体,可通过预先加载使用按键查询。
对象缓存有助于提高实体框架的整体响应能力。但当对象缓存中加载了大量实体时,可能影响某些操作,例如添加、删除、SaveChanges 等。尤其是,极大的对象缓存将对触发对 DetectChanges 的调用的操作产生负面影响。DetectChanges 将对象图与对象状态管理器进行同步,其性能将直接取决于对象图的大小。有关 DetectChanges 的更多信息,请参见 http://msdn.microsoft.com/library/dd456848.aspx。
查询首次执行时,通过内部计划编译器将概念查询转换为存储命令(例如当针对 SQL Server 运行时执行的 T-SQL)。如果启用了查询计划缓存,则在下一次执行此查询时,将直接从查询计划缓存中检索存储命令,以便执行,从而绕开计划编译器。
同一 AppDomain 中的 ObjectContext 实例间共享查询计划缓存。要利用查询计划缓存,不一定只使用一个 ObjectContext 实例。
了解内部算法的工作方式有助于确定何时启用或禁用查询计划缓存。清除算法如下:
在确定逐出哪些条目时会公平对待所有缓存条目。这意味着针对 CompiledQuery 的存储命令与针对实体 SQL 查询的存储命令具有相同的逐出几率。
为演示查询计划缓存对应用程序性能的影响,我们进行了一项测试,在测试中,我们对 Navision 模型执行了大量实体 SQL 查询。有关 Navision 模型的说明以及执行的查询类型,请参见附录。在该测试中,我们首先循环访问查询列表,对每个查询执行一次,将它们添加到缓存中(如果缓存已启用)。此步骤不计时。下一步,再次循环访问列表,执行缓存的查询。
测试 | 缓存已启用? | 结果 |
枚举所有 18723 个查询 | 否 | 所用秒数=238.14 |
是 | 所用秒数=240.31 | |
避免整理(无论复杂性如何,仅前 800 个查询) | 否 | 所用秒数=61.62 |
是 | 所用秒数=0.84 | |
仅 AggregatingSubtotals 查询(共 178 个 - 避免整理) | 否 | 所用秒数=63.22 |
是 | 所用秒数=0.41 |
道理 - 当执行许多不同的查询(例如,动态创建的查询)时,缓存没有帮助,并且最终的缓存刷新会使最能受益于计划缓存的查询实际上无法使用它。
AggregatingSubtotals 查询是我们测试的最复杂的查询。像预计的那样,查询越复杂,越能受益于查询计划缓存。
因为 CompiledQuery 实际上是缓存了计划的 LINQ 查询,所以 CompiledQuery 与等同实体 SQL 查询的比较应具有类似结果。实际上,如果应用程序有许多动态实体 SQL 查询,向缓存中填充查询还会在从缓存中刷新查询时使 CompiledQueries 进行“反编译”。在这种情况下,通过禁用动态查询缓存来确定 CompiledQueries 优先级,可以提高性能。当然,最好将应用程序重新编写为使用参数化查询,而不是动态查询。
我们的测试表明,比起自动编译的 LINQ 查询,使用 CompiledQuery 可以带来 7% 的益处;这意味着从实体框架堆栈执行代码将节省 7% 的时间;这不意味着应用程序的速度将提高 7%。一般而言,与获得的好处相比,在 EF 5.0 中编写和维护 CompiledQuery 对象的成本是不值当的。实际情况可能各有不同,因此如果项目需要额外推动力,则运用这种方法。
有关创建和调用 CompiledQuery 的更多信息,请参见 MSDN 文档中的“已编译查询 (LINQ to Entities)”主题:http://msdn.microsoft.com/library/bb896297.aspx。
使用 CompiledQuery 时有两个注意事项,即,使用静态实例的要求,以及它们具有的可组合性要求。以下是这两个注意事项的深入说明。
由于编译 LINQ 查询是一个非常耗时的过程,我们不想每次从数据库中提取数据时都执行此过程。通过 CompiledQuery 实例,可以编译一次后运行多次,但您必须仔细,每次重用相同 CompiledQuery 实例,而不是一遍一遍地编译它。必须使用静态成员存储 CompiledQuery 实例;否则没有任何用处。
例如,假设页面用以下方法主体处理显示所选类别的产品:
// 警告:这是错误使用 CompiledQuery 的方式
using (NorthwindEntities context = new NorthwindEntities())
{
string selectedCategory = this.categoriesList.SelectedValue;
var productsForCategory = CompiledQuery.Compile<NorthwindEntities, string, IQueryable<Product>>(
(NorthwindEntities nwnd, string category) =>
nwnd.Products.Where(p => p.Category.CategoryName == category)
);
this.productsGrid.DataSource = productsForCategory.Invoke(context, selectedCategory).ToList();
this.productsGrid.DataBind();
}
this.productsGrid.Visible = true;
在这种情况下,每次调用此方法时都会实时创建一个新的 CompiledQuery 实例。每次创建新实例时 CompiledQuery 都会经过计划编译器,而不是通过从查询计划缓存中检索存储命令来获得性能优势。实际上,每次调用此方法时,新的 CompiledQuery 条目都会污染查询计划缓存。
您需要创建已编译查询的静态实例,因此每次调用此方法时都在调用相同的已编译查询。为此,一种方法是添加 CompiledQuery 实例作为对象上下文的成员。然后可通过帮助程序方法访问 CompiledQuery,这样更简单一些:
public partial class NorthwindEntities : ObjectContext
{
private static readonly Func<NorthwindEntities, string, IEnumerable<Product>> productsForCategoryCQ = CompiledQuery.Compile(
(NorthwindEntities context, string categoryName) =>
context.Products.Where(p => p.Category.CategoryName == categoryName)
);
public IEnumerable<Product> GetProductsForCategory(string categoryName)
{
return productsForCategoryCQ.Invoke(this, categoryName).ToList();
}
此帮助程序方法将按照以下方式加以调用:
this.productsGrid.DataSource = context.GetProductsForCategory(selectedCategory);
在任何 LINQ 查询上进行编写的能力非常有用;为此,只需在 IQueryable 后调用一个方法,例如 Skip() 或 Count()。但这样做实际上会返回一个新的 IQueryable 对象。尽管没有什么能够在技术上阻止您在 CompiledQuery 上进行编写,但这样做会生成需要再次通过计划编译器的新 IQueryable 对象。
某些组件将利用所编写的 IQueryable 对象启用高级功能。例如,可通过 SelectMethod 属性将 ASP.NET 的 GridView 数据绑定到 IQueryable 对象。然后 GridView 将在该 IQueryable 对象上进行撰写,以便允许在数据模型上进行排序和分页。可以看到,将 CompiledQuery 用于 GridView 不会命中已编译查询,但会生成新的自动编译查询。
客户顾问团队在他们的“已编译 LINQ 查询重新编译的潜在性能问题”博客文章中探讨了这一方面:http://blogs.msdn.com/b/appfabriccat/archive/2010/08/06/potential-performance-issues-with-compiled-linq-query-re-compiles.aspx。
在将渐进式筛选器添加到查询中时可能会遇到此问题。例如,假设客户页面有针对可选筛选器(例如 Country 和 OrdersCount)的多个下拉列表。可针对 CompiledQuery 的 IQueryable 结果编写这些筛选器,但这样会导致每次执行新查询时,新查询都会经过计划编译器。
using (NorthwindEntities context = new NorthwindEntities())
{
IQueryable<Customer> myCustomers = context.InvokeCustomersForEmployee();
if (this.orderCountFilterList.SelectedItem.Value != defaultFilterText)
{
int orderCount = int.Parse(orderCountFilterList.SelectedValue);
myCustomers = myCustomers.Where(c => c.Orders.Count > orderCount);
}
if (this.countryFilterList.SelectedItem.Value != defaultFilterText)
{
myCustomers = myCustomers.Where(c => c.Address.Country == countryFilterList.SelectedValue);
}
this.customersGrid.DataSource = myCustomers;
this.customersGrid.DataBind();
}
为避免这种重复编译,可重写 CompiledQuery,以便考虑可能的筛选器:
private static readonly Func<NorthwindEntities, int, int?, string, IQueryable<Customer>> customersForEmployeeWithFiltersCQ = CompiledQuery.Compile(
(NorthwindEntities context, int empId, int? countFilter, string countryFilter) =>
context.Customers.Where(c => c.Orders.Any(o => o.EmployeeID == empId))
.Where(c => countFilter.HasValue == false || c.Orders.Count > countFilter)
.Where(c => countryFilter == null || c.Address.Country == countryFilter)
);
这将在 UI 中调用,例如:
using (NorthwindEntities context = new NorthwindEntities())
{
int? countFilter = (this.orderCountFilterList.SelectedIndex == 0) ?
(int?)null :
int.Parse(this.orderCountFilterList.SelectedValue);
string countryFilter = (this.countryFilterList.SelectedIndex == 0) ?
null :
this.countryFilterList.SelectedValue;
IQueryable<Customer> myCustomers = context.InvokeCustomersForEmployeeWithFilters(
countFilter, countryFilter);
this.customersGrid.DataSource = myCustomers;
this.customersGrid.DataBind();
}
此处需要权衡的是,生成的存储命令将始终具有带 null 检查的筛选器,但这些应可使数据库服务器比较简单地进行优化:
...
WHERE ((0 = (CASE WHEN (@p__linq__1 IS NOT NULL) THEN cast(1 as bit) WHEN (@p__linq__1 IS NULL) THEN cast(0 as bit) END)) OR ([Project3].[C2] > @p__linq__2)) AND (@p__linq__3 IS NULL OR [Project3].[Country] = @p__linq__4)
实体框架还支持元数据缓存。这实质上是与同一模型的不同连接之间的类型信息以及类型到数据库映射信息的缓存。元数据缓存每 AppDomain 都是唯一的。
客户顾问团队写了一篇博客文章,介绍如何保留对 ItemCollection 的引用,以便在使用大模型时避免“不推荐使用的情况”:http://blogs.msdn.com/b/appfabriccat/archive/2010/10/22/metadataworkspace-reference-in-wcf-services.aspx。
查询计划缓存实例存在于 MetadataWorkspace 的存储类型 ItemCollection 中。这意味着缓存的存储命令将用于对参照给定 MetadataWorkspace 进行了实例化的任何 ObjectContext 的查询。这还意味着,如果有两个略微不同且在标记之后不匹配的连接字符串,将有不同的查询计划缓存实例。
凭借结果缓存(也称为“二级缓存”),可将查询结果保留在本地缓存中。在发布查询时,首先查看在对存储进行查询前是否可本地获得这些结果。尽管实体框架不直接支持结果缓存,但可通过使用包装提供程序添加二级缓存。CodePlex 上提供了具有二级缓存的包装提供程序示例:http://code.msdn.microsoft.com/EFProviderWrappers-c0b88f32/view/Discussions/2。
当使用实体框架发出数据库查询时,在实际具体化结果之前必须经历一系列步骤;其中一个步骤是查询编译。已知实体 SQL 查询具有很好的性能,因为它们是自动缓存的,因此在第二次或第三次执行同一查询时,可跳过计划编译器,而使用缓存的计划。
实体框架 5 还引入了对 LINQ to Entities 的自动缓存。在实体框架的过去版本中,通过创建 CompiledQuery 来提高性能是一种常见做法,因为这会使 LINQ to Entities 查询可缓存。由于现在缓存是自动进行的,无需使用 CompiledQuery,因此我们将该功能称为“自动编译的查询”。有关查询计划缓存及其机制的更多信息,请参见查询计划缓存。
实体框架检测查询何时需要重新编译,在调用查询时,即使之前已对其进行了编译,也会对其进行重新编译。导致重新编译查询的常见条件是:
其他条件可能阻碍查询使用缓存。常见示例为:
实体框架不缓存调用 IEnumerable<T>.Contains<T>(T value) 的对内存中集合的查询,因为该集合的值不稳定。以下示例查询不缓存,因此将始终由计划编译器加以处理:
int[] ids = new int[10000];
...
using (var context = new MyContext())
{
var query = context.MyEntities
.Where(entity => ids.Contains(entity.Id));
var results = query.ToList();
...
}
此外请注意,执行 Contains 所针对的 IEnumerable 的大小确定已编译查询的速度快慢。当使用上例所示的大型集合时,性能会极大下降。
按照上述同一示例,如果有第二个查询依赖需要重新编译的查询,则整个第二个查询也将重新编译。以下示例说明了这种情况:
int[] ids = new int[10000];
...
using (var context = new MyContext())
{
var firstQuery = from entity in context.MyEntities
where ids.Contains(entity.Id)
select entity;
var secondQuery = from entity in context.MyEntities
where firstQuery.Any(otherEntity => otherEntity.Id == entity.Id)
select entity;
string results = secondQuery.ToList();
...
}
这是个一般示例,但说明了链接到 firstQuery 如何导致 secondQuery 无法缓存。如果 firstQuery 不是需要重新编译的查询,则会缓存 secondQuery。
如果在只读情况中,想要避免将对象加载到 ObjectStateManager 中的开销,则可发出“无跟踪”查询。可在查询层面上禁用更改跟踪。
注意,尽管如此,禁用更改跟踪将有效关闭对象缓存。当查询实体时,我们无法通过从 ObjectStateManager 中拉出先前具体化的查询结果来跳过具体化。如果要在相同上下文中重复查询同一实体,启用更改跟踪实际上可提高性能。
当使用 ObjectContext 进行查询时,ObjectQuery 和 ObjectSet 实例将在 MergeOption 设置后记住它,并且在它们上编写的查询将继承父查询的有效 MergeOption。当使用 DbContext 时,可通过对 DbSet 调用 AsNoTracking() 修饰符禁用跟踪。
通过在查询中链接对 AsNoTracking() 方法的调用,可将查询模式切换到 NoTracking。与 ObjectQuery 不同,DbContext API 中的 DbSet 和 DbQuery 类没有针对 MergeOption 的可变属性。
var productsForCategory = from p in context.Products.AsNoTracking()
where p.Category.CategoryName == selectedCategory
select p;
var productsForCategory = from p in context.Products
where p.Category.CategoryName == selectedCategory
select p;
((ObjectQuery)productsForCategory).MergeOption = MergeOption.NoTracking;
context.Products.MergeOption = MergeOption.NoTracking;
var productsForCategory = from p in context.Products
where p.Category.CategoryName == selectedCategory
select p;
在这个测试中,通过比较针对 Navision 模型的跟踪和无跟踪查询,我们探讨填充 ObjectStateManager 的成本。有关 Navision 模型的说明以及执行的查询类型,请参见附录。在这个测试中,我们循环访问查询列表,对每个查询执行一次。我们进行了两种不同的测试,一次使用 NoTracking 查询,一次使用“AppendOnly”的默认合并选项。每种测试都进行 3 遍,取测试结果的平均值。在这些测试之间,我们清除了 SQL Server 上的查询缓存,并且通过运行以下命令缩小了 tempdb:
测试结果:
测试类型 | 平均结果(3 次) |
NoTracking 查询 | 所用秒数=315.63,工作集=588997973 |
AppendOnly 查询 | 所用秒数=335.43,工作集=629760000 |
在这些测试中,填充 ObjectStateManager 所用时间多出 6%,所占内存多出 6%。
实体框架提供了几种不同查询方式。下面介绍以下选项,比较每个选项的优缺点,研究它们的性能特点:
var q = context.Products.Where(p => p.Category.CategoryName == "Beverages");
优点
Cons
context.Products.MergeOption = MergeOption.NoTracking;
var q = context.Products.Where(p => p.Category.CategoryName == "Beverages");
优点
Cons
ObjectQuery<Product> products = context.Products.Where("it.Category.CategoryName = ‘Beverages‘");
优点
Cons
EntityCommand cmd = eConn.CreateCommand();
cmd.CommandText = "Select p From NorthwindEntities.Products As p Where p.Category.CategoryName = ‘Beverages‘";
using (EntityDataReader reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess))
{
while (reader.Read())
{
// 手动“具体化”产品
}
}
优点
Cons
数据库上的 SqlQuery:
// 使用它获得实体,而不是跟踪实体
var q1 = context.Database.SqlQuery<Product>("select * from products");
DbSet 上的 SqlQuery:
// 使用它来获得实体,而不是跟踪实体
var q2 = context.Products.SqlQuery("select * from products");
ExecyteStoreQuery:
ObjectResult<Product> beverages = context.ExecuteStoreQuery<Product>(
@" SELECT P.ProductID, P.ProductName, P.SupplierID, P.CategoryID, P.QuantityPerUnit, P.UnitPrice, P.UnitsInStock, P.UnitsOnOrder, P.ReorderLevel, P.Discontinued, P.DiscontinuedDate
FROM Products AS P INNER JOIN Categories AS C ON P.CategoryID = C.CategoryID
WHERE (C.CategoryName = ‘Beverages‘)"
);
优点
Cons
private static readonly Func<NorthwindEntities, string, IQueryable<Product>> productsForCategoryCQ = CompiledQuery.Compile(
(NorthwindEntities context, string categoryName) =>
context.Products.Where(p => p.Category.CategoryName == categoryName)
);
…
var q = context.InvokeProductsForCategoryCQ("Beverages");
优点
Cons
为比较不同查询选项的性能,我们创建了 5 个单独的测试类型,我们使用不同的查询选项来选择类别名称为“Beverages”的所有产品。每个迭代均包含创建上下文的成本,以及对所有返回的实体进行具体化的成本。先不计时运行 10 次迭代,然后计算 1000 次计时迭代的总和。所示结果是从每个测试的 5 次运行中获得的中值运行。有关更多信息,请参见附录 B,其中包含该测试的代码。
注意:为求完整,我们包含了在 EntityCommand 上执行实体 SQL 查询的测试类型。但由于没有为这些查询具体化结果,因此不必进行同类比较。该测试包含非常接近的具体化近似值,以便尽量做出更公平的比较。
在测试中还使用了简单的微基准,没有对上下文创建进行计时。我们在受控环境中测量了对一组非缓存实体进行的 5000 次查询。这些数字将加以采用,同时警告:它们不反映应用程序生成的实际数字,但却是非常准确的测量值,它们体现了在对不同查询选项进行同类比较时存在多少性能差异。考虑到实际情况,足够接近的数字可视为相等,始终以毫秒为单位:
在使用实体框架时的另一个性能注意事项是所使用的继承策略。实体框架支持 3 个基本类型的继承及其组合:
如果模型使用 TPT 继承,则生成的查询将比使用其他继承策略生成的查询更加复杂,这可能导致对存储的执行时间更长。在 TPT 模型上生成查询并具体化最终对象一般需要更长的时间。
请参见“在实体框架中使用 TPT(每个类型一张表)继承时的性能注意事项”MSDN 博客文章:http://blogs.msdn.com/b/adonet/archive/2010/08/17/performance-considerations-when-using-tpt-table-per-type-inheritance-in-the-entity-framework.aspx。
当在具有 TPT 架构的现有数据库上创建模型时,没有许多选择。但当使用 Model First 或 Code First 创建应用程序时,由于性能问题,应避免使用 TPT 继承。
当在 Entity Designer 向导中使用 Model First 时,将获得针对模型中任何继承的 TPT。如果要转换到采用 Model First 的 TPH 继承策略,可使用 Visual Studio 库提供的“Entity Designer Database Generation Power Pack”( http://visualstudiogallery.msdn.microsoft.com/df3541c3-d833-4b65-b942-989e7ec74c87/ )。
当使用 Code First 配置具有继承的模型的映射时,在默认情况下实体框架将使用 TPH,即,继承层次结构中的所有实体都映射到同一表。有关更多详细信息,请参见 MSDN 杂志文章“ADO.NET 实体框架 4.1 中的 Code First”的“使用 Fluent API 进行映射”一节 (http://msdn.microsoft.com/magazine/hh126815.aspx )。
EF 5 中实现了对生成模型存储层 (SSDL) 的算法的 SQL Server 特定改进,在安装 Dev10 SP1 时,此改进作为对 EF 4 的更新。以下测试结果演示在生成超大模型(在本例中是 Navision 模型)时的改进。有关更多详细信息,请参见附录 C。
配置 | 持续时间 | 模型生成各阶段的百分比细分 |
Visual Studio 2010。 具有 1005 个实体集和 4227 个关联集的模型。 |
所用秒数=16835.08 (4:40:35) |
SSDL 生成:2 小时 27 分钟 映射生成:<1 分钟 CSDL 生成:<1 分钟 ObjectLayer 生成:<1 分钟 视图生成:2 小时 14 分钟 |
Visual Studio 2010 SP1。 具有 1005 个实体集和 4227 个关联集的模型。 |
所用秒数=6813.18 (1:53:33) |
SSDL 生成:<1 分钟 映射生成:<1 分钟 CSDL 生成:<1 分钟 ObjectLayer 生成:<1 分钟 视图生成:1 小时 53 分钟 |
值得注意的是,当生成 SSDL 时,加载时间几乎完全用在 SQL Server 上,而客户端开发计算机正在空闲地等待从服务器返回结果。这一改进对 DBA 尤其有用。还值得注意的是,实质上模型生成的全部成本现在是在视图生成中产生的。
随着模型大小的增加,设计器图面变得杂乱且难以使用。一般认为具有超过 300 个实体的模型太大,难以有效使用设计器。我们的一位开发组长 Srikanth Mandadi 写了以下博客文章,介绍拆分大模型的几种选择: http://blogs.msdn.com/b/adonet/archive/2008/11/25/working-with-large-models-in-entity-framework-part-2.aspx。
这篇文章是为实体框架的第一个版本所写的,但这些步骤仍适用。
我们已看到了多线程性能和压力测试中的用例情况,在这些情况下,使用 EntityDataSource 控件的 Web 应用程序的性能大幅下降。其根本原因是 EntityDataSource 反复调用 Web 应用程序所引用的程序集上的 MetadataWorkspace.LoadFromAssembly,以便发现将用作实体的类型。
解决方案是将 EntityDataSource 的 ContextTypeName 设置为派生 ObjectContext 类的类型名称。这会关闭扫描所有引用的程序集以查找是否有实体类型的机制。
设置 ContextTypeName 字段还会防止以下功能问题:当 .NET 4.0 中的 EntityDataSource 无法通过反射从程序集中加载类型时,它会引发 ReflectionTypeLoadException。该问题在 .NET 4.5 中已得到修复。
通过实体框架可将自定义数据类与数据模型一同使用,无需对数据类本身进行任何修改。这意味着可以将“纯旧式”CLR 对象 (POCO)(例如,现有的域对象)与数据模型一起使用。这些 POCO 数据类(也称为缺少持续性的对象,映射到在数据模型中定义的实体)支持与实体数据模型工具生成的实体类型相同的大部分查询、插入、更新和删除行为。
实体框架还能够创建从 POCO 类型派生的代理类,如果需要对 POCO 实体启用延迟加载和自动更改跟踪等功能,可以使用这些类。POCO 类必须符合某些要求才可使实体框架使用代理,如这篇文章所述: http://msdn.microsoft.com/library/dd468057.aspx。
每次实体的任何属性值更改时,更改跟踪代理都将通知对象状态管理器,因此实体框架始终知道这些实体的实际状态。这是通过以下方式实现的:将通知事件添加到属性 setter 方法的主体中,让对象状态管理器处理这些事件。注意,实体框架创建了更多事件集,因此创建代理实体一般比创建非代理 POCO 实体成本更高。
当 POCO 实体没有更改跟踪代理时,通过将实体的内容与先前保存的状态的副本进行比较来查找更改。如果上下文中有许多实体,或者实体有大量属性,这种深入比较将变成一个冗长的过程,即使自上次比较以来它们均没有更改也是如此。
总结:在创建更改跟踪代理时性能会下降,但如果实体有许多属性,或者模型中有许多实体,更改跟踪将有助于加快更改检测过程。对于实体数量没有过多增长、有少量属性的实体,更改跟踪代理的优势可能不明显。
实体框架提供了多种不同方式来加载与目标实体相关的实体。例如,当查询产品时,可通过不同方式将相关订单加载到对象状态管理器中。从性能观点来讲,在加载相关实体时要考虑的最大问题将是使用延迟加载还是预先加载。
当使用预先加载时,相关实体连同目标实体集一同加载。在查询中使用 Include 语句来指示要获取哪些相关实体。
当使用延迟加载时,初始查询仅获取目标实体集。但只要访问导航属性,便会发出对存储的另一个查询,以加载相关实体。
已加载了实体后,对该实体的任何进一步查询都会从对象状态管理器直接加载它,无论正在使用延迟加载还是预先加载。
重要的是了解延迟加载与预先加载之间的区别,这样才能做出适合您应用程序的正确选择。这将有助于您对照数据库评估多个请求之间的权衡,而不是评估可能包含较大负载的单个请求。在应用程序的某些部分中使用预先加载而在其他部分中使用延迟加载可能是适当的做法。
举一个有关在后台发生的情况的例子,假设您想要查询住在英国的客户以及他们的订单数。
使用预先加载
using (NorthwindEntities context = new NorthwindEntities())
{
var ukCustomers = context.Customers.Include(c => c.Orders).Where(c => c.Address.Country == "UK");
var chosenCustomer = AskUserToPickCustomer(ukCustomers);
Console.WriteLine("Customer Id: {0} has {1} orders", customer.CustomerID, customer.Orders.Count);
}
使用延迟加载
using (NorthwindEntities context = new NorthwindEntities())
{
context.ContextOptions.LazyLoadingEnabled = true;
//注意在该查询中,Include 方法调用正在丢失
var ukCustomers = context.Customers.Where(c => c.Address.Country == "UK");
var chosenCustomer = AskUserToPickCustomer(ukCustomers);
Console.WriteLine("Customer Id: {0} has {1} orders", customer.CustomerID, customer.Orders.Count);
}
当使用预先加载时,将发出一个返回所有客户和所有订单的查询。存储命令看起来像:
当使用延迟加载时,最初将发出以下查询:
SELECT
[Extent1].[CustomerID] AS [CustomerID],
[Extent1].[CompanyName] AS [CompanyName],
[Extent1].[ContactName] AS [ContactName],
[Extent1].[ContactTitle] AS [ContactTitle],
[Extent1].[Address] AS [Address],
[Extent1].[City] AS [City],
[Extent1].[Region] AS [Region],
[Extent1].[PostalCode] AS [PostalCode],
[Extent1].[Country] AS [Country],
[Extent1].[Phone] AS [Phone],
[Extent1].[Fax] AS [Fax]
FROM [dbo].[Customers] AS [Extent1]
WHERE N‘UK‘ = [Extent1].[Country]
每次访问客户的订单导航属性时,便会对存储发出另一个查询,如下:
exec sp_executesql N‘SELECT
[Extent1].[OrderID] AS [OrderID],
[Extent1].[CustomerID] AS [CustomerID],
[Extent1].[EmployeeID] AS [EmployeeID],
[Extent1].[OrderDate] AS [OrderDate],
[Extent1].[RequiredDate] AS [RequiredDate],
[Extent1].[ShippedDate] AS [ShippedDate],
[Extent1].[ShipVia] AS [ShipVia],
[Extent1].[Freight] AS [Freight],
[Extent1].[ShipName] AS [ShipName],
[Extent1].[ShipAddress] AS [ShipAddress],
[Extent1].[ShipCity] AS [ShipCity],
[Extent1].[ShipRegion] AS [ShipRegion],
[Extent1].[ShipPostalCode] AS [ShipPostalCode],
[Extent1].[ShipCountry] AS [ShipCountry]
FROM [dbo].[Orders] AS [Extent1]
WHERE [Extent1].[CustomerID] = @EntityKeyValue1‘,N‘@EntityKeyValue1 nchar(5)‘,@EntityKeyValue1=N‘AROUT‘
有关更多信息,请参见“加载相关对象”MSDN 文章: http://msdn.microsoft.com/library/bb896272.aspx。
选择预先加载与延迟加载没有一刀切的方法。首先尽量了解这两个策略之间的区别,这样才能做出明智决定;此外考虑代码是否适合下列任何一种情况:
情况 | 建议 | |
是否需要从提取的实体中访问许多导航属性? | 否 | 这两个选择可能都行。但如果查询所产生的负载不太大,则通过使用预先加载可能会提高性能,因为这需要较少的网络往返即可具体化对象。 |
是 | 如果需要从实体访问许多导航属性,最好在采用预先加载的查询中使用多个 include 语句。包含的实体越多,查询将返回的负载就越大。如果查询包含三个或更多实体,考虑转为延迟加载。 | |
是否确切知道在运行时将需要什么数据? | 否 | 更适合采用延迟加载。否则,可能结果是查询不需要的数据。 |
是 | 可能最适合采用预先加载;这有助于更快速地加载整个集。如果查询需要提取大量数据,而速度非常慢,则尝试延迟加载。 | |
代码是否要在远离数据库的位置执行?(更长的网络延迟) | 否 | 当网络延迟不是问题时,使用延迟加载可能会简化代码。请注意,应用程序的拓扑结构可能改变,因此不要认为数据库邻近是理所当然的。 |
是 | 当网络是要考虑的问题时,只有您才能确定适合采用哪种方式。一般预先加载会更好,因为需要的往返次数更少。 |
当我们听说涉及服务器响应时间问题的性能问题时,根源通常是使用多个 Include 语句的查询。尽管在查询中包含相关实体可实现强大功能,还是应该了解实际情况。
包含多个 Include 语句的查询需要相对较长的时间通过内部计划编译器,才能生成存储命令。这些时间中的大部分用于尝试优化最终查询。根据映射,生成的存储命令将包含针对每个 Include 的 Outer Join 或 Union。这类查询将在单个负载中从数据库获取大量已连接的图形,从而恶化所有带宽问题,在负载存在大量冗余时(即,具有能够在一到多方向中遍历关联的多个 Include 级别),更是如此。
通过以下方式可以查看查询是否存在要返回超大量负载的情况:使用 ToTraceString 访问针对查询的基本 TSQL,在 SQL Server Management Studio 中执行存储命令,查看负载的大小。在这种情况下,可尝试减少查询中的 Include 语句数,仅获取所需的数据。也可将查询分成多个更小的子查询序列,例如:
在拆分查询前:
using (NorthwindEntities context = new NorthwindEntities())
{
var customers = from c in context.Customers.Include(c => c.Orders)
where c.LastName.StartsWith(lastNameParameter)
select c;
foreach (Customer customer in customers)
{
...
}
}
在拆分查询后:
using (NorthwindEntities context = new NorthwindEntities())
{
var orders = from o in context.Orders
where o.Customer.LastName.StartsWith(lastNameParameter)
select o;
orders.Load();
var customers = from c in context.Customers
where c.LastName.StartsWith(lastNameParameter)
select c;
foreach (Customer customer in customers)
{
...
}
}
这仅对跟踪查询有效,因为我们要利用上下文的功能自动执行标识解析和关联修复。
至于延迟加载,权衡结果将是用更多查询实现较小负载。还可使用各属性的投影显式地从每个实体中仅选择所需的数据,但是,在这种情况下不会加载实体,也不支持更新。
实体框架当前不支持标量或复杂属性的延迟加载。但是,如果表中包含 BLOB 等大型对象,可以使用表拆分功能将大属性分成单独实体。例如,假设有一个 Product 表,它包含一个变长二进制图片列。如果不需要经常访问查询中的这个属性,则可使用表拆分功能仅获取通常需要的实体部分。如果明确需要表示产品图片的实体时,仅加载该实体。
要说明如何启用表拆分功能,一个很好的资源是 Gil Fink 的博客文章“实体框架中的表拆分”:http://blogs.microsoft.co.il/blogs/gilf/archive/2009/10/13/table-splitting-in-entity-framework.aspx。
如果遇到实体框架的性能问题,可使用探查器(如 Visual Studio 内置探查器)查看应用程序将时间用在了哪里。我们使用这个工具生成了博客文章“探究 ADO.NET 实体框架的性能 - 第 1 部分”( http://blogs.msdn.com/b/adonet/archive/2008/02/04/exploring-the-performance-of-the-ado-net-entity-framework-part-1.aspx) 中的饼图,说明在冷查询和热查询过程中实体框架将时间用在了哪里。
数据与建模客户顾问团队的博客文章“使用 Visual Studio 2010 探查器分析实体框架”提供了一个真实示例,说明他们如何使用这个探查器调查性能问题。http://blogs.msdn.com/b/dmcat/archive/2010/04/30/profiling-entity-framework-using-the-visual-studio-2010-profiler.aspx。这篇文章是针对 Windows 应用程序编写的。如果需要分析 Web 应用程序,则 VSPerfCmd 工具可能比使用 Visual Studio 更有效。
通过工具(如 Visual Studio 内置探查器)可以发现应用程序将时间用在哪里。另外还有一种探查器,可根据需要在生产或预生产中动态分析正在运行的应用程序,以及查找数据库访问的常见缺陷和反模式。
实体框架 Profiler ( http://efprof.com) 和 ORMProfiler (http://ormprofiler.com) 是两个商用探查器。
如果应用程序是使用 Code First 的 MVC 应用程序,则可使用 StackExchange 的 MiniProfiler。Scott Hanselman 在他的博客中介绍了这个工具:http://www.hanselman.com/blog/NuGetPackageOfTheWeek9ASPNETMiniProfilerFromStackExchangeRocksYourWorld.aspx。
有关分析应用程序数据库活动的更多信息,请参见 Julie Lerman 的 MSDN 杂志文章,标题为“分析实体框架中的数据库活动”:http://msdn.microsoft.com/magazine/gg490349.aspx。
这个环境使用了 2 计算机设置,数据库与客户端应用程序不在同一计算机上,而是在单独的计算机上。计算机在同一机架中,因此网络延迟相对较低,但比单机环境更接近实际。
这个环境使用单工作站。客户端应用程序和数据库在同一计算机上。
Navision 数据库是一个用于演示 Microsoft Dynamics – NAV 的大型数据库。生成的概念模型包含 1005 个实体集和 4227 个关联集。在测试中使用的模型是“扁平的”— 尚未添加继承。
在 Navision 模型中使用的查询列表包含 3 个类别的实体 SQL 查询:
无聚合的简单查找查询
<Query complexity="Lookup">
<CommandText>Select value distinct top(4) e.Idle_Time From NavisionFKContext.Session as e</CommandText>
</Query>
具有多个聚合但没有小计的正常 BI 查询(单一查询)
<Query complexity="SingleAggregating">
<CommandText>NavisionFK.MDF_SessionLogin_Time_Max()</CommandText>
</Query>
其中 MDF_SessionLogin_Time_Max() 在模型中定义为:
<Function Name="MDF_SessionLogin_Time_Max" ReturnType="Collection(DateTime)">
<DefiningExpression>SELECT VALUE Edm.Min(E.Login_Time) FROM NavisionFKContext.Session as E</DefiningExpression>
</Function>
具有聚合和小计的 BI 查询(通过 union all)
标签:
原文地址:http://www.cnblogs.com/mschen/p/EntityFramework.html