EF 4、5 和 6 的性能注意事项

作者:David Obando、Eric Dettinger 等

发布日期:2012 年 4 月

上次更新时间:2014 年 5 月


1.简介

对象关系映射框架是一种用于在面向对象的应用程序中为数据访问提供抽象的简便方法。 对于 .NET 应用程序,Microsoft 建议使用的 O/RM 为实体框架。 但对于任何抽象,性能都是需要关注的问题。

本白皮书旨在展示使用实体框架开发应用程序时应注意的性能问题,让开发人员了解可能会影响性能的实体框架内部算法,并提供有关调查和提高使用实体框架的应用程序性能的提示。 Web 上已经有许多关于性能的优秀话题,我们也尝试对此话题展开论述。

性能是一个棘手的话题。 本白皮书旨在作为一种资源,帮助你为使用实体框架的应用程序做出与性能相关的决策。 我们包含了一些测试指标来演示性能,但这些指标并非应用程序所体现出的性能的绝对指标。

出于实用目的,本文档假定实体框架 4 在 .NET 4.0 下运行,而实体框架 5 和 6 在 .NET 4.5 下运行。 .NET 4.5 附带的核心组件中包含许多实体框架 5 的性能提升。

实体框架 6 是带外版本,不依赖于 .NET 附带的实体框架组件。 实体框架 6 适用于 .NET 4.0 和 .NET 4.5,并且可以为尚未从 .NET 4.0 升级而又希望在其应用程序中使用最新实体框架位的用户提供巨大的性能优势。 本文档中提到的实体框架 6 是指撰写本文时可用的最新版本:6.1.0 版。

2. 执行冷搜索与暖查询

首次针对给定模型进行任何查询时,实体框架会在后台执行大量工作来加载和验证模型。 通常,我们将这一首次查询称为“冷”搜索。  针对已加载模型的进一步查询称为“暖”查询,并且速度要快得多。

我们来简单了解一下使用实体框架执行查询时花费的时间,并了解实体框架 6 在哪些方面有所改进。

首次查询执行 - 冷搜索

代码用户编写 操作 EF4 性能影响 EF5 性能影响 EF6 性能影响
using(var db = new MyContext())
{
上下文创建
var q1 =
from c in db.Customers
where c.Id == id1
select c;
查询表达式创建
var c1 = q1.First(); LINQ 查询执行 - 元数据加载:高,但已缓存
- 视图生成:可能较高,但已缓存
- 参数计算:中
- 查询转换:中
- Materializer 生成:中,但已缓存
- 数据库查询执行:可能较高
+ Connection.Open
+ Command.ExecuteReader
+ DataReader.Read
对象具体化:中
- 标识查找:中
- 元数据加载:高,但已缓存
- 视图生成:可能较高,但已缓存
- 参数计算:低
- 查询转换:中,但已缓存
- Materializer 生成:中,但已缓存
- 数据库查询执行:可能较高(某些情况下查询效果更佳)
+ Connection.Open
+ Command.ExecuteReader
+ DataReader.Read
对象具体化:中
- 标识查找:中
- 元数据加载:高,但已缓存
- 视图生成:中等但已缓存
- 参数计算:低
- 查询转换:中,但已缓存
- Materializer 生成:中,但已缓存
- 数据库查询执行:可能较高(某些情况下查询效果更佳)
+ Connection.Open
+ Command.ExecuteReader
+ DataReader.Read
对象具体化:中(比 EF5 快)
- 标识查找:中
} Connection.Close

第二次查询执行操作 - 暖查询

代码用户编写 操作 EF4 性能影响 EF5 性能影响 EF6 性能影响
using(var db = new MyContext())
{
上下文创建
var q1 =
from c in db.Customers
where c.Id == id1
select c;
查询表达式创建
var c1 = q1.First(); LINQ 查询执行 - 元数据加载查找:高,但已缓存
- 视图生成查找:可能较高,但已缓存
- 参数计算:中
- 查询转换查找:中
- Materializer 生成查找:中,但已缓存
- 数据库查询执行:可能较高
+ Connection.Open
+ Command.ExecuteReader
+ DataReader.Read
对象具体化:中
- 标识查找:中
- 元数据加载查找:高,但已缓存
- 视图生成查找:可能较高,但已缓存
- 参数计算:低
- 查询转换查找:中,但已缓存
- Materializer 生成查找:中,但已缓存
- 数据库查询执行:可能较高(某些情况下查询效果更佳)
+ Connection.Open
+ Command.ExecuteReader
+ DataReader.Read
对象具体化:中
- 标识查找:中
- 元数据加载查找:高,但已缓存
- 视图生成查找:中,但已缓存
- 参数计算:低
- 查询转换查找:中,但已缓存
- Materializer 生成查找:中,但已缓存
- 数据库查询执行:可能较高(某些情况下查询效果更佳)
+ Connection.Open
+ Command.ExecuteReader
+ DataReader.Read
对象具体化:中(比 EF5 快)
- 标识查找:中
} Connection.Close

多种方法可用于降低冷搜索和暖查询的性能成本,我们将在下一部分中介绍这些方法。 具体而言,我们将使用预生成的视图来降低冷搜索中模型加载的成本,这将有助于缓解在生成视图期间遇到的性能问题。 对于热查询,我们将介绍查询计划缓存、无跟踪查询和不同的查询执行选项。

2.1 什么是视图生成?

为了解什么是视图生成,必须先了解什么是“映射视图”。 映射视图是每个实体集和关联的映射中所指定转换的可执行表示形式。 这些映射视图在内部采用 CQT(规范查询树)的形式。 映射视图具有两种类型:

  • 查询视图:这些视图表示从数据库架构到概念模型必须进行的转化。
  • 更新视图:这些视图表示从概念模型到数据库架构必须进行的转化。

请记住,概念模型可能在很多方面与数据库架构不同。 例如,单个表可能用于存储两种不同实体类型的数据。 继承和重要映射是造成映射视图复杂性的重要因素。

根据映射规范计算这些视图的过程,我们称之为视图生成。 视图生成可以在加载模型时动态进行,也可以在生成时使用“预生成视图”进行;后者以实体 SQL 语句的形式序列化为 C# 或 VB 文件。

生成视图时,还会对这些视图进行验证。 从性能角度出发,视图生成的大部分成本实际上源自对视图的验证,验证可确保实体之间的连接有意义,且所有支持的操作都具有正确的基数。

对实体集执行查询时,查询会与相应的查询视图相结合,这种组合的结果通过计划编译器运行,以创建后备存储可理解的查询表示形式。 对于 SQL Server,此编译的最终结果将是 T-SQL SELECT 语句。 首次对实体集执行更新时,更新视图通过类似的过程运行,将其转换为目标数据库的 DML 语句。

2.2 影响视图生成性能的因素

视图生成步骤的性能不仅取决于模型的大小,还取决于模型的互连程度。 如果两个实体通过继承链或关联进行连接,则认为它们已连接。 同样,如果两个表通过外键连接,则表示它们已连接。 随着架构中连接的实体和表的数量不断增加,视图生成成本也会增加。

在最坏的情况下,用于生成和验证视图的算法将呈指数级,不过我们会进行一些优化来改进这一点。 对性能产生负面影响的最大因素似乎是:

  • 模型大小,指实体的数量以及这些实体之间的关联程度。
  • 模型复杂性,特别是涉及大量类型的继承。
  • 使用独立关联,而不是外键关联。

对于简单的小型模型,由于成本可能非常低,因而无需费心使用预生成的视图。 随着模型大小和复杂性的增加,有多种选项可用于降低视图生成和验证的成本。

2.3 使用预生成的视图缩短模型加载时间

若要详细了解如何在实体框架 6 上使用预生成的视图,请访问预生成的映射视图

2.3.1 使用实体框架 Power Tools Community Edition 的预生成视图

可以使用实体框架 6 Power Tools Community Edition 来生成 EDMX 和 Code First 模型的视图,方法是右键单击模型类文件并通过“实体框架”菜单选择“生成视图”。 实体框架 Power Tools Community Edition 仅适用于 DbContext 派生的上下文。

2.3.2 如何通过 EDMGen 创建的模型使用预生成视图

EDMGen 是 .NET 附带且适用于实体框架 4 和 5 的实用工具,但不适用于实体框架 6。 EDMGen 使你能够从命令行生成模型文件、对象层和视图。 其中一个输出将是所选语言(VB 或 C#)的视图文件。 这是一个代码文件,其中包含每个实体集的实体 SQL 片段。 若要启用预生成的视图,只需将此文件包含在项目中。

如果手动编辑模型的架构文件,则需要重新生成视图文件。 为此,可以使用 /mode:ViewGeneration 标志运行 EDMGen

2.3.3 如何通过 EDMX 文件使用预生成视图

还可以使用 EDMGen 生成 EDMX 文件的视图(前面引用的 MSDN 主题介绍了如何添加预生成事件来执行此操作),但这样做很复杂,且在某些情况下无法实现。 如果模型位于 edmx 文件中,通常使用 T4 模板生成视图会更容易。

ADO.NET 团队博客有一篇文章介绍如何使用 T4 模板生成视图 (<https://learn.microsoft.com/archive/blogs/adonet/how-to-use-a-t4-template-for-view-generation>)。 这篇文章包含可下载并添加到项目的模板。 此模板针对第一版实体框架编写,因此不能保证适用于最新版本的实体框架。 但是,可以从 Visual Studio 库中下载一组更新的、适用于实体框架 4 和 5 的视图生成模板:

  • VB.NET:<http://visualstudiogallery.msdn.microsoft.com/118b44f2-1b91-4de2-a584-7a680418941d>
  • C#:<http://visualstudiogallery.msdn.microsoft.com/ae7730ce-ddab-470f-8456-1b313cd2c44d>

如果使用的是实体框架 6,则可以从 Visual Studio 库(位于 <http://visualstudiogallery.msdn.microsoft.com/18a7db90-6705-4d19-9dd1-0a6c23d0751f>)中获取视图生成 T4 模板。

2.4 降低视图生成成本

使用预生成的视图可将视图生成的成本从模型加载(运行时)转移到设计时。 虽然这提高了运行时的启动性能,但在开发时,仍然会体验到视图生成所带来的不便。 多项额外技巧有助于减少视图生成的成本(无论是在编译时还是在运行时)。

2.4.1 使用外键关联降低视图生成成本

我们已经在许多案例中了解到,将模型中的关联从“独立关联”切换到“外键关联”,可显著缩短视图生成所花费的时间。

为了演示此项改进,我们使用 EDMGen 生成了两种版本的 Navision 模型。 注意:有关 Navision 模型的说明,请参阅附录 C。 由于 Navision 模型有非常多的实体以及实体之间高度关联,因此对于本练习很有参考意义。

这一超大模型的其中一个版本使用外键关联生成,而另一个版本则是使用独立关联生成。 随后,我们计算了为每个模型生成视图所需的时间。 实体框架 5 测试使用 EntityViewGenerator 类中的 GenerateViews() 方法生成视图,而实体框架 6 测试使用 StorageMappingItemCollection 类中的 GenerateViews() 方法。 这是因为实体框架 6 代码库中发生了代码重组。

使用实体框架 5,在实验室计算机上为使用外键的模型生成视图需要 65 分钟。 未知内容是,为使用独立关联的模型生成视图需要多长时间。 在实验室中重启计算机以安装每月更新之前,我们让测试运行了一个多月。

在同一实验室计算机中,使用实体框架 6 为使用外键的模型生成视图需要 28 秒。 为使用独立关联的模型生成视图需要 58 秒。 对实体框架 6 中的视图生成代码所做的改进意味着,许多项目不需要预生成的视图来获得更快的启动时间。

需要注意的是,可以使用 EDMGen 或实体框架 Power Tools 来完成实体框架 4 和 5 中的预生成视图。 对于实体框架 6,视图生成可以通过实体框架 Power Tools 或以编程方式完成,如预生成的映射视图中所述。

2.4.1.1 如何使用外键关联(而不是独立关联)

在 Visual Studio 中使用 EDMGen 或 Entity Designer 时,将默认获取 FK,并且只需一个复选框或命令行标志即可在 FK 和 IA 之间切换。

如果具有大型 Code First 模型,则使用独立关联对视图生成的影响相同。 可以通过在依赖对象的类中包含外键属性来避免这种影响,不过一些开发人员认为这样做会污染其对象模型。 可以在 <http://blog.oneunicorn.com/2011/12/11/whats-the-deal-with-mapping-foreign-keys-using-the-entity-framework/> 中找到有关此主题的详细信息。

使用工具 执行此操作
实体设计器 在两个实体之间添加关联后,请确保具有引用约束。 引用约束会告诉实体框架使用外键关联,而非独立关联。 有关其他详细信息,请参阅 <https://learn.microsoft.com/archive/blogs/efdesign/foreign-keys-in-the-entity-framework>。
EDMGen 使用 EDMGen 从数据库生成文件时,将遵守外键并将其添加到模型中。 有关 EDMGen 公开的不同选项的详细信息,请访问 http://msdn.microsoft.com/library/bb387165.aspx
Code First 请参阅 Code First 约定主题的“关系约定”部分,了解使用 Code First 时如何在相关对象上包含外键属性的相关信息。

2.4.2 将模型移动到单独的程序集

如果模型直接包含在应用程序的项目中,并且通过预生成事件或 T4 模板来生成视图,只要重新生成项目,都会生成视图并进行验证,即使模型未更改也是如此。 如果将模型移动到单独的程序集并从应用程序的项目中引用它,则可以对应用程序进行其他更改,而无需重新生成包含模型的项目。

注意:将模型移动到单独的程序集时,请记住将模型的连接字符串复制到客户端项目的应用程序配置文件中

2.4.3 禁用基于 edmx 的模型验证

EDMX 模型在编译时进行验证,即使模型保持不变。 如果模型已经过验证,可以通过在属性窗口中将“生成时验证”属性设置为 false 来取消在编译时验证。 更改映射或模型时,可以暂时重新启用验证来验证更改。

请注意,实体框架 6 的实体框架设计器进行了性能改进,“生成时验证”的成本远低于早期版本的设计器。

3 实体框架中的缓存

实体框架内置了以下形式的缓存:

  1. 对象缓存 - 内置在 ObjectContext 实例中的 ObjectStateManager 会在内存中跟踪使用该实例检索到的对象。 这也称为一级缓存。
  2. 查询计划缓存 - 多次执行查询时,重复使用生成的 store 命令。
  3. 元数据缓存 - 在同一模型的不同连接之间共享模型的元数据。

除了 EF 提供的现成缓存之外,一种特殊的 ADO.NET 数据提供程序(包装提供程序)也可用于扩展实体框架,为从数据库检索的结果提供缓存,也称为二级缓存。

3.1 对象缓存

默认情况下,从查询结果中返回实体时,ObjectContext 会在 EF 具体化实体之前,检查具有相同键的实体是否已加载到其 ObjectStateManager 中。 如果已存在具有相同键的实体,EF 会将其包含在查询结果中。 尽管 EF 仍会针对数据库发出查询,但此行为可以免除多次具体化实体的大量成本。

3.1.1 使用 DbContext Find 从对象缓存中获取实体

与常规查询不同,DbSet 中的 Find 方法(EF 4.1 中首次包含的 API)甚至会在针对数据库发出查询之前,在内存中执行搜索。 非常需要注意的是,两个不同的 ObjectContext 实例将具有两个不同的 ObjectStateManager 实例,意味着它们具有单独的对象缓存。

Find 方法使用主键值来尝试查找由上下文跟踪的实体。 如果实体不在上下文中,则将执行查询并针对数据库进行评估,如果在上下文或数据库中未找到实体,则返回 NULL. 请注意,Find 也会返回已添加到上下文而尚未保存到数据库的实体。

使用 Find 时需要考虑性能。 默认情况下,调用此方法将触发对象缓存的验证,以检测仍待提交到数据库的更改。 如果对象缓存中或添加到对象缓存的大型对象图中有大量对象,则此过程可能会非常昂贵,但也可以禁用此过程。 在某些情况下,如果禁用自动检测更改,在调用 Find 方法时可能会发现一种数量级的差异。 然而,当对象实际位于缓存中,与必须从数据库中检索对象时相比,可以发现第二个数量级的差异。 下面是一个示例图,其中包含使用一些微基准(以毫秒为单位,负载为 5000 个实体)进行的测量:

.NET 4.5 logarithmic scale

Find 示例,其中禁用了自动检测更改:

    context.Configuration.AutoDetectChangesEnabled = false;
    var product = context.Products.Find(productId);
    context.Configuration.AutoDetectChangesEnabled = true;
    ...

使用 Find 方法时,必须考虑:

  1. 如果对象不在缓存中,Find 的优势就不再存在,但语法仍比按键查询简单。
  2. 如果启用了自动检测更改,则 Find 方法的成本可能会增加一个数量级甚至更多,具体取决于模型的复杂性和对象缓存中的实体数量。

此外,请记住,Find 仅返回要查找的实体,并且如果关联实体尚未在对象缓存中,则不会自动加载这些实体。 如果需要检索关联的实体,可以通过预先加载按键进行查询。 有关详细信息,请参阅“8.1 延迟加载与预先加载”

3.1.2 对象缓存具有多个实体时的性能问题

对象缓存有助于提高实体框架的整体响应能力。 但是,如果对象缓存加载了大量实体,则可能会影响某些操作,例如 Add、Remove、Find、Entry、SaveChanges 等。 具体而言,大量的对象缓存会对触发 DetectChanges 调用的操作产生负面影响。 DetectChanges 将对象图与对象状态管理器同步,其性能将由对象图的大小直接决定。 有关 DetectChanges 的详细信息,请参阅跟踪 POCO 实体中的更改

使用实体框架 6 时,开发人员可以直接在 DbSet 上调用 AddRange 和 RemoveRange,无需循环访问集合并为每个实例调用一次 Add。 使用 range 方法的优点在于,只需针对整个实体集支付一次 DetectChanges 费用,而无需针对每个添加的实体支付一次。

3.2 查询计划缓存

首次执行查询时,它会通过内部计划编译器将概念查询转换为 store 命令(例如,针对 SQL Server 运行时会执行的 T-SQL)。  如果启用了查询计划缓存,则下次执行查询时,将绕过计划编译器,直接从查询计划缓存中检索 store 命令来执行。

查询计划缓存在同一 AppDomain 中的 ObjectContext 实例之间共享。 无需保留 ObjectContext 实例即可从查询计划缓存中受益。

3.2.1 关于查询计划缓存的一些备注

  • 查询计划缓存针对所有查询类型共享:实体 SQL、LINQ to Entities 和 CompiledQuery 对象。
  • 默认情况下,为实体 SQL 查询启用查询计划缓存(无论是通过 EntityCommand 还是通过 ObjectQuery 执行)。 在 .NET 4.5 上的实体框架和实体框架 6 中,也默认为 LINQ to Entities 查询启用查询计划缓存
    • 可以通过将 EnablePlanCaching 属性(在 EntityCommand 或 ObjectQuery 上)设置为 false 来禁用查询计划缓存。 例如:
                    var query = from customer in context.Customer
                                where customer.CustomerId == id
                                select new
                                {
                                    customer.CustomerId,
                                    customer.Name
                                };
                    ObjectQuery oQuery = query as ObjectQuery;
                    oQuery.EnablePlanCaching = false;
  • 对于参数化查询,更改参数的值仍会命中缓存的查询。 但是更改参数的 facet(例如大小、精准率或规模)将命中缓存中的不同条目。
  • 使用实体 SQL 时,查询字符串是键的一部分。 完全更改查询会导致产生不同的缓存条目,即使查询在功能上等效。 这种更改包括对大小写或空格的更改。
  • 使用 LINQ 时,会处理查询以生成部分键。 因此,更改 LINQ 表达式将生成不同的键。
  • 其他技术限制可能适用;有关更多详细信息,请参阅“自动编译查询”。

3.2.2 缓存驱逐算法

了解内部算法的工作原理有助于确定何时该启用或禁用查询计划缓存。 清理算法如下所示:

  1. 缓存包含一定数量的条目 (800) 后,我们会启动一个计时器,定期(每分钟一次)扫描缓存。
  2. 扫描缓存期间,会根据 LFRU(频率最低 - 最近使用)从缓存中删除条目。 在决定弹出哪些条目时,算法会同时考虑命中次数和存在时长。
  3. 每次缓存扫描结束后,缓存会再次包含 800 个条目。

在确定要驱逐的条目时,所有缓存条目都得到同等处理。 这意味着 CompiledQuery 的 store 命令被逐出的可能性与实体 SQL 查询的 store 命令相同。

请注意,缓存驱逐计时器会在缓存中有 800 个实体时启动,但在此计时器启动 60 秒后才会开始扫描缓存。 这意味着在长达 60 秒的时间内,缓存可能会变得非常大。

3.2.3 演示查询计划缓存性能的测试指标

为了演示查询计划缓存对应用程序性能的影响,我们执行了一项测试,在此测试中,我们针对 Navision 模型执行了许多实体 SQL 查询。 有关 Navision 模型以及所执行查询类型的说明,请参阅附录。 在此测试中,我们首先循环访问了查询列表,并将每个查询都执行一次,以将其添加到缓存中(如果启用了缓存)。 此步骤未计时。 接下来,我们让主线程睡眠 60 秒以上,以对缓存进行扫描;最后,我们第二次循环访问了列表,以执行缓存的查询。 此外,在执行每组查询之前会刷新 SQL Server 计划缓存,以便获取的时间准确反映查询计划缓存提供的好处。

3.2.3.1 测试结果
测试 EF5 无缓存 EF5 缓存 EF6 无缓存 EF6 缓存
枚举所有 18723 查询 124 125.4 124.3 125.3
避免扫描(仅前 800 个查询,不考虑复杂性) 41.7 5.5 40.5 5.4
仅 AggregatingSubtotals 查询(总共 178 个 - 避免扫描) 39.5 4.5 38.1 4.6

所有时间(以秒为单位)

注意 - 执行大量不同的查询(例如动态创建的查询)时,缓存并未起到帮助作用,并且生成的缓存刷新会使从计划缓存中受益最多的查询无法真正使用缓存。

AggregatingSubtotals 查询是我们测试过的查询中最复杂的查询。 正如预期的那样,查询越复杂,查询计划缓存的优势越明显。

由于 CompiledQuery 实际上是缓存了其计划的 LINQ 查询,因此将 CompiledQuery 与等效的实体 SQL 查询进行比较,得出的结果应该相似。 事实上,如果应用具有很多动态实体 SQL 查询,使用查询填充缓存实际上也会导致 CompiledQueries 从缓存中刷新时进行“反向编译”。 这种情况下,可以通过禁用动态查询上的缓存来提高 CompiledQueries 的优先级,从而提高性能。 当然,更好的做法是重写应用,以使用参数化查询,而不是动态查询。

3.3 使用 CompiledQuery 提高 LINQ 查询的性能

我们的测试表明,与自动编译的 LINQ 查询相比,使用 CompiledQuery 可以带来 7% 的好处;这是指从实体框架堆栈执行代码的时间将减少 7%;并非应用程序的速度会提升 7%。 一般来说,在 EF 5.0 中编写和维护 CompiledQuery 对象所花的成本与这一好处相比,可能并不值得这么麻烦。 由于里程可能不尽相同,因此,如果项目需要额外的推送,请使用此选项。 请注意,CompiledQueries 仅与 ObjectContext 派生模型兼容,与 DbContext 派生模型不兼容。

有关创建和调用 CompiledQuery 的详细信息,请参阅编译查询 (LINQ to Entities)

使用 CompiledQuery 时必须考虑两个因素,即使用静态实例的要求以及它们在可组合性方面的问题。 下面对这两个注意事项进行了深入说明。

3.3.1 使用静态 CompiledQuery 实例

由于编译 LINQ 查询的过程十分耗时,我们不希望每次需要从数据库中获取数据时都这样做。 使用 CompiledQuery 实例,即可编译一次并多次运行,但必须谨慎并确保每次都重新使用同一 CompiledQuery 实例,而不是反复对其进行编译。 必须使用静态成员来存储 CompiledQuery 实例;否则没有任何好处。

例如,假设页面具有以下方法主体,用于处理对所选类别的产品的显示:

    // Warning: this is the wrong way of using 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 都将通过计划编译器,而非通过从查询计划缓存中检索 store 命令来获得性能优势。 事实上,每次调用该方法时,新生成的 CompiledQuery 条目都会污染查询计划缓存。

建议改为创建已编译查询的静态实例,以便在每次调用该方法时都调用同一编译查询。 这样做的一种方法是将 CompiledQuery 实例添加为对象上下文的成员。  然后,你可以通过 helper 方法访问 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();
        }

这种 helper 方法将按如下方式调用:

    this.productsGrid.DataSource = context.GetProductsForCategory(selectedCategory);

3.3.2 根据 CompiledQuery 进行撰写

可根据任何 LINQ 查询进行撰写的功能极为有用;要做到这一点,只需在 IQueryable 之后调用一个方法,如 Skip() 或 Count()。 但这样做实际上会返回新的 IQueryable 对象。 虽然技术上并没什么阻止根据 CompiledQuery 进行撰写,但这样做会导致生成新的 IQueryable 对象,该对象需要再次通过计划编译器。

某些组件会利用撰写的 IQueryable 对象来启用高级功能。 例如,ASP.NET 的 GridView 可以通过 SelectMethod 属性数据绑定到 IQueryable 对象。 然后,GridView 将根据此 IQueryable 对象进行撰写,以允许对数据模型进行排序和分页。 如你所见,使用 GridView 的 CompiledQuery 不会命中编译查询,但会生成新的自动编译查询。

将渐进式筛选器添加到查询中后,可能会遇到这种情况。 例如,假设你有一个“客户”页面,其中包含多个可选筛选器的下拉列表(例如 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();
    }

 这里需要权衡的是,生成的 store 命令将始终包含带有 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)

3.4 元数据缓存

实体框架还支持元数据缓存。 本质上,这是在同一模型的不同连接之间缓存类型信息和类型到数据库的映射信息。 每个 AppDomain 的元数据缓存都具有唯一性。

3.4.1 元数据缓存算法

  1. 模型的元数据信息存储在每个 EntityConnection 的 ItemCollection 中。

    • 附带说明,模型的不同部分具有不同的 ItemCollection 对象。 例如,StoreItemCollections 包含有关数据库模型的信息;ObjectItemCollection 包含有关数据模型的信息;EdmItemCollection 包含有关概念模型的信息。
  2. 如果两个连接使用相同的连接字符串,则它们将共享同一 ItemCollection 实例。

  3. 功能相同但文本不同的连接字符串可能会导致不同的元数据缓存。 我们标记了连接字符串,因此只需更改标记的顺序就可以共享元数据。 但进行标记后,看起来功能相同的两个连接字符串可能会计算出不同的值。

  4. 定期检查 ItemCollection 的使用情况。 如果确定某个工作区最近未被访问过,则会在下一次缓存扫描时将其标记为清理。

  5. 仅创建 EntityConnection 将导致创建元数据缓存(尽管在打开连接之前不会初始化其中的项集合)。 在缓存算法确定此工作区未处于“使用中”之前,该工作区将保留在内存中。

客户咨询团队撰写了一篇博客文章,介绍了如何保留对 ItemCollection 的引用,以便在使用大型模型时避免“弃用”:<https://learn.microsoft.com/archive/blogs/appfabriccat/holding-a-reference-to-the-ef-metadataworkspace-for-wcf-services>。

3.4.2 元数据缓存和查询计划缓存之间的关系

查询计划缓存实例驻留在 MetadataWorkspace 的存储类型 ItemCollection 中。 这意味着缓存的 store 命令将用于针对任何使用给定 MetadataWorkspace 实例化的上下文的查询。 这也意味着,如果你有两个略有不同的连接字符串,并且在标记后不匹配,你将会有不同的查询计划缓存实例。

3.5 结果缓存

使用结果缓存(也称为“二级缓存”),可以将查询结果保存在本地缓存中。 发出查询时,首先查看结果是否在本地可用,然后再对存储进行查询。 虽然实体框架不直接支持结果缓存,但可以使用包装提供程序添加二级缓存。 使用二级缓存的示例包装提供程序是 Alachisoft 的基于 NCache 的实体框架二级缓存

二级缓存的这种实现是一项注入功能,该功能将在计算(和 funcletize 处理)LINQ 表达式后发生,并从第一级缓存计算或检索查询执行计划。 然后,第二级缓存将仅存储原始数据库结果,因此具体化管道随后仍会执行。

3.5.1 使用包装提供程序的结果缓存的其他参考

  • Julie Lerman 撰写了一篇“实体框架和 Windows Azure 中的二级缓存”MSDN 文章,其中介绍了如何更新示例包装提供程序以使用 Windows Server AppFabric 缓存:https://msdn.microsoft.com/magazine/hh394143.aspx
  • 如果使用的是实体框架 5,团队博客中有一篇文章介绍了如何使用缓存提供程序运行实体框架 5:<https://learn.microsoft.com/archive/blogs/adonet/ef-caching-with-jarek-kowalskis-provider>。 其中同样也介绍到了 T4 模板,帮助你自动将二级缓存添加到项目。

4 自动编译的查询

使用实体框架针对数据库发出查询时,必须完成一系列步骤,然后才能真正具体化结果;其中一个步骤为“查询编译”。 众所周知,实体 SQL 查询具有良好的性能,因为它们会自动缓存,因此第二次或第三次执行相同的查询时,它可以跳过计划编译器,改用缓存的计划。

实体框架 5 也为 LINQ to Entities 查询引入了自动缓存。 在过去版本的实体框架中,创建 CompiledQuery 以提高性能是一种常见做法,这样可使 LINQ to Entities 查询可缓存。 由于缓存现在无需使用 CompiledQuery 即可自动完成,因此我们将此功能称为“自动编译的查询”。 有关查询计划缓存及其机制的详细信息,请参阅“查询计划缓存”。

实体框架可检测何时需要重新编译查询,并在调用查询时执行重新编译,即使该查询之前已编译过。 导致查询重新编译的常见情况如下:

  • 更改与查询关联的 MergeOption。 缓存的查询将不会被使用,而是再次运行计划编译器,并缓存新创建的计划。
  • 更改 ContextOptions.UseCSharpNullComparisonBehavior 的值。 与更改 MergeOption 的效果相同。

其他情况可能会阻止查询使用缓存。 常见示例包括:

  • 使用 IEnumerable<T>.Contains<>(T 值)。
  • 使用生成包含常量的查询的函数。
  • 使用非映射对象的属性。
  • 将查询链接到需要重新编译的另一个查询。

4.1 使用 IEnumerable<T>.Contains<T>(T 值)

实体框架不会缓存针对内存集合调用 IEnumerable<T>.Contains<T>(T 值)的查询,因为集合的值被视为具有易失性。 下面的示例查询将不会缓存,因此计划编译器将始终对其进行处理:

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 的大小决定了编译查询的速度。 使用较大的集合(如以上示例所示)时,性能可能会显著降低。

实体框架 6 包含对 IEnumerable<T>.Contains<T>(T 值)工作方式(执行查询时)的优化。 生成的 SQL 代码生成速度更快,可读性更强,而且在大多数情况下,它在服务器中的执行速度也更快。

4.2 使用生成包含常量查询的函数

Skip()、Take()、Contains() 和 DefautIfEmpty() LINQ 运算符不生成带参数的 SQL 查询,而是将传递给它们的值作为常量。 因此,除非在后续查询执行中使用相同的常量,否则可能相同的查询最终会污染 EF 堆栈和数据库服务器上的查询计划缓存,并且不会被重新利用。 例如:

var id = 10;
...
using (var context = new MyContext())
{
    var query = context.MyEntities.Select(entity => entity.Id).Contains(id);

    var results = query.ToList();
    ...
}

在此示例中,每次使用不同的 ID 值执行此查询时,该查询都将编译为新计划。

执行分页时,要特别注意 Skip 和 Take 的使用。 在 EF6 中,这些方法具有 lambda 重载,可以有效地使缓存的查询计划可重复使用,因为 EF 可捕获传递给这些方法的变量并将其转换为 SQL 参数。 这也有助于保持更干净的缓存,否则每个具有不同常量的 Skip 和 Take 的查询都会获取自己的查询计划缓存条目。

考虑以下代码,虽然该代码不够适当,但仅用于举例说明此类查询:

var customers = context.Customers.OrderBy(c => c.LastName);
for (var i = 0; i < count; ++i)
{
    var currentCustomer = customers.Skip(i).FirstOrDefault();
    ProcessCustomer(currentCustomer);
}

这一代码的较快版本涉及到使用 lambda 调用 Skip:

var customers = context.Customers.OrderBy(c => c.LastName);
for (var i = 0; i < count; ++i)
{
    var currentCustomer = customers.Skip(() => i).FirstOrDefault();
    ProcessCustomer(currentCustomer);
}

第二个代码段的运行速度最多可提升 11%,因为每次运行查询时都使用相同的查询计划,这样可以节省 CPU 时间并避免污染查询缓存。 此外,由于 Skip 的参数在一个闭包中,所以现在的代码可能看起来如下所示:

var i = 0;
var skippyCustomers = context.Customers.OrderBy(c => c.LastName).Skip(() => i);
for (; i < count; ++i)
{
    var currentCustomer = skippyCustomers.FirstOrDefault();
    ProcessCustomer(currentCustomer);
}

4.3 使用非映射对象的属性

如果查询使用非映射对象类型的属性作为参数,则不会缓存查询。 例如:

using (var context = new MyContext())
{
    var myObject = new NonMappedType();

    var query = from entity in context.MyEntities
                where entity.Name.StartsWith(myObject.MyProperty)
                select entity;

   var results = query.ToList();
    ...
}

在此示例中,假定类 NonMappedType 不是实体模型的一部分。 可以轻松地将此查询更改为不使用非映射类型,而是使用局部变量作为查询的参数:

using (var context = new MyContext())
{
    var myObject = new NonMappedType();
    var myValue = myObject.MyProperty;
    var query = from entity in context.MyEntities
                where entity.Name.StartsWith(myValue)
                select entity;

    var results = query.ToList();
    ...
}

在这种情况下,查询将能够缓存,并将受益于查询计划缓存。

4.4 链接到需要重新编译的查询

按照与上面相同的示例,如果有第二个查询依赖于需要重新编译的查询,则还将重新编译整个第二个查询。 以下示例说明了这种情况:

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;

    var results = secondQuery.ToList();
    ...
}

这是一个泛型示例,但它说明了链接到 firstQuery 会如何导致 secondQuery 无法缓存。 如果 firstQuery 不是需要重新编译的查询,那么 secondQuery 将被缓存。

5 NoTracking 查询

5.1 禁用更改跟踪以减少状态管理开销

如果属于只读的情况,且要避免将对象加载到 ObjectStateManager 的开销,则可以发出“无跟踪”查询。  可以在查询级禁用更改跟踪。

但请注意,禁用更改跟踪可以有效关闭对象缓存。 查询实体时,无法通过从 ObjectStateManager 中拉取先前具体化的查询结果来跳过具体化。 如果在同一上下文中反复查询相同的实体,则可能会发现启用更改跟踪带来的性能优势。

使用 ObjectContext 进行查询时,ObjectQuery 和 ObjectSet 实例在设置 MergeOption 后将其记住,而在其上编写的查询将继承父查询的有效 MergeOption。 使用 DbContext 时,可以通过对 DbSet 调用 AsNoTracking() 修饰符来禁用跟踪。

5.1.1 使用 DbContext 时禁用查询的更改跟踪

可以通过在查询中链接对 AsNoTracking () 方法的调用,将查询的模式切换为 NoTracking。 与 ObjectQuery 不同,DbContext API 中的 DbSet 和 DbQuery 类没有 MergeOption 的可变属性。

    var productsForCategory = from p in context.Products.AsNoTracking()
                                where p.Category.CategoryName == selectedCategory
                                select p;


5.1.2 使用 ObjectContext 在查询级禁用更改跟踪

    var productsForCategory = from p in context.Products
                                where p.Category.CategoryName == selectedCategory
                                select p;

    ((ObjectQuery)productsForCategory).MergeOption = MergeOption.NoTracking;

5.1.3 使用 ObjectContext 禁用整个实体集的更改跟踪

    context.Products.MergeOption = MergeOption.NoTracking;

    var productsForCategory = from p in context.Products
                                where p.Category.CategoryName == selectedCategory
                                select p;

5.2 演示 NoTracking 查询性能优势的测试指标

在此测试中,我们通过比较 Navision 模型的 Tracking 和 NoTracking 查询来了解填充 ObjectStateManager 的成本。 有关 Navision 模型以及所执行查询类型的说明,请参阅附录。 在此测试中,我们循环访问了查询列表并将每个查询执行一次。 我们运行了两种不同的测试,一次使用 NoTracking 查询,一次使用“AppendOnly”的默认合并选项。 我们对每种测试运行 3 次,并取运行的平均值。 在测试之间,我们清理了 SQL Server 上的查询缓存,并通过运行以下命令来收缩 tempdb:

  1. DBCC DROPCLEANBUFFERS
  2. DBCC FREEPROCCACHE
  3. DBCC SHRINKDATABASE (tempdb, 0)

测试结果,3 次运行的中值:

无跟踪 – 工作集 无跟踪 – 时间 仅追加 – 工作集 仅追加 – 时间
实体框架 5 460361728 1163536 毫秒 596545536 1273042 毫秒
Entity Framework 6 647127040 190228 毫秒 832798720 195521 毫秒

运行结束时,实体框架 5 的内存占用量要低于实体框架 6。 实体框架 6 消耗的额外内存在于使用了额外的内存结构和代码,这些结构和代码用于启用新功能和实现更好的性能。

使用 ObjectStateManager 时,内存占用情况也有明显差异。 在跟踪从数据库中具体化的所有实体时,实体框架 5 增加了 30% 的内存占用量。 执行此操作时,实体框架 6 增加了 28% 的占用量。

在时间方面,实体框架 6 在本次测试中大大优于实体框架 5。 实体框架 6 完成测试的时间大约是实体框架 5 所用时间的 16%。 此外,使用 ObjectStateManager 时,实体框架 5 完成测试的时间要多 9%。 相比之下,实体框架 6 在使用 ObjectStateManager 时多花了 3% 的时间。

6 查询执行选项

实体框架提供了多种不同的查询方法。 我们将查看以下选项,比较每种选项的优缺点,并检查其性能特征:

  • LINQ to Entities。
  • 无跟踪 LINQ to Entities。
  • 基于 ObjectQuery 的实体 SQL。
  • 基于 EntityCommand 的实体 SQL。
  • ExecuteStoreQuery。
  • SqlQuery。
  • CompiledQuery。

6.1 LINQ to Entities 查询

var q = context.Products.Where(p => p.Category.CategoryName == "Beverages");

优点

  • 适用于 CUD 操作。
  • 完全具体化的对象。
  • 使用编程语言中内置的语法进行编写的最简单方法。
  • 良好的性能。

缺点

  • 某些技术限制,例如:
    • 使用 DefaultIfEmpty 进行 OUTER JOIN 查询的模式会导致查询比实体 SQL 中的简单 OUTER JOIN 语句更复杂。
    • 你仍无法将 LIKE 用于常规模式匹配。

6.2 无跟踪 LINQ to Entities 查询

如果上下文派生 ObjectContext:

context.Products.MergeOption = MergeOption.NoTracking;
var q = context.Products.Where(p => p.Category.CategoryName == "Beverages");

如果上下文派生 DbContext:

var q = context.Products.AsNoTracking()
                        .Where(p => p.Category.CategoryName == "Beverages");

优点

  • 改进了常规 LINQ 查询的性能。
  • 完全具体化的对象。
  • 使用编程语言中内置的语法进行编写的最简单方法。

缺点

  • 不适用于 CUD 操作。
  • 某些技术限制,例如:
    • 使用 DefaultIfEmpty 进行 OUTER JOIN 查询的模式会导致查询比实体 SQL 中的简单 OUTER JOIN 语句更复杂。
    • 你仍无法将 LIKE 用于常规模式匹配。

请注意,即使未指定 NoTracking,也不跟踪项目标量属性的查询。 例如:

var q = context.Products.Where(p => p.Category.CategoryName == "Beverages").Select(p => new { p.ProductName });

此特定查询没有显示指定为 NoTracking,但由于它没有具体化对象状态管理器已知的类型,因此不会跟踪具体化的结果。

6.3 ObjectQuery 的实体 SQL

ObjectQuery<Product> products = context.Products.Where("it.Category.CategoryName = 'Beverages'");

优点

  • 适用于 CUD 操作。
  • 完全具体化的对象。
  • 支持查询计划缓存。

缺点

  • 涉及文本查询字符串,这些字符串比语言中内置的查询构造更容易出现用户错误。

6.4 基于实体命令的实体 SQL

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())
    {
        // manually 'materialize' the product
    }
}

优点

  • 支持 .NET 4.0 中的查询计划缓存(.NET 4.5 中的所有其他查询类型都支持计划缓存)。

缺点

  • 涉及文本查询字符串,这些字符串比语言中内置的查询构造更容易出现用户错误。
  • 不适用于 CUD 操作。
  • 结果不会自动具体化,必须从数据读取器中读取。

6.5 SqlQuery 和 ExecuteStoreQuery

数据库中的 SqlQuery:

// use this to obtain entities and not track them
var q1 = context.Database.SqlQuery<Product>("select * from products");

DbSet 中的 SqlQuery:

// use this to obtain entities and have them tracked
var q2 = context.Products.SqlQuery("select * from products");

ExecuteStoreQuery:

var 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')"
);

优点

  • 由于绕过了计划编译器,性能方面通常表现出最快的速度。
  • 完全具体化的对象。
  • 适用于 CUD 操作(从 DbSet 使用时)。

缺点

  • 查询具有文本性,且易于出错。
  • 查询使用存储语义(而非概念语义)绑定到特定后端。
  • 存在继承时,手动查询需要考虑到所请求类型的映射条件。

6.6 CompiledQuery

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");

优点

  • 与常规 LINQ 查询相比,性能提升可达 7%。
  • 完全具体化的对象。
  • 适用于 CUD 操作。

缺点

  • 复杂性和编程开销增加。
  • 在已编译的查询上进行撰写时,性能改进会丢失。
  • 某些 LINQ 查询不能编写为 CompiledQuery,例如匿名类型的投影。

6.7 不同查询选项的性能比较

对简单微基准进行了测试(该基准不对上下文创建进行计时)。 我们测量了在受控环境中对一组非缓存实体进行的 5000 次查询。 关于这些数字需要注意的是:它们不反映应用程序生成的实际数字,而是非常准确的度量值,可表示不同查询选项进行逐一比较时(不包括创建新上下文的成本)出现的性能差异。

EF 测试 时间(毫秒) 内存
EF5 ObjectContext ESQL 2414 38801408
EF5 ObjectContext LINQ 查询 2692 38277120
EF5 DbContext LINQ 查询无跟踪 2818 41840640
EF5 DbContext LINQ 查询 2930 41771008
EF5 ObjectContext LINQ 查询无跟踪 3013 38412288
EF6 ObjectContext ESQL 2059 46039040
EF6 ObjectContext LINQ 查询 3074 45248512
EF6 DbContext LINQ 查询无跟踪 3125 47575040
EF6 DbContext LINQ 查询 3420 47652864
EF6 ObjectContext LINQ 查询无跟踪 3593 45260800

EF5 micro benchmarks, 5000 warm iterations

EF6 micro benchmarks, 5000 warm iterations

微基准对代码中的细微更改非常敏感。 在这种情况下,实体框架 5 与实体框架 6 之间的成本差异是由于添加了拦截事务改进。 然而,这些微基准数字是对实体框架操作极小部分的放大呈现。 从实体框架 5 升级到实体框架 6 时,热查询的实际情况中不应出现性能下降。

为了比较不同查询选项的实际性能,我们创建了 5 种测试变体,其中使用了不同的查询选项来选择类别名称为“饮料”的所有产品。 每次迭代都包括了创建上下文的成本,以及具体化所有返回实体的成本。 在取 1000 次定时迭代的总和之前,会不定时运行 10 次迭代。 显示的结果是从各测试 5 次运行中所取的中值运行。 有关详细信息,请参阅包含测试代码的附录 B。

EF 测试 时间(毫秒) 内存
EF5 ObjectContext 实体命令 621 39350272
EF5 对数据库进行 DbContext SQL 查询 825 37519360
EF5 ObjectContext 存储查询 878 39460864
EF5 ObjectContext LINQ 查询无跟踪 969 38293504
EF5 使用对象查询的 ObjectContext 实体 SQL 1089 38981632
EF5 ObjectContext 编译查询 1099 38682624
EF5 ObjectContext LINQ 查询 1152 38178816
EF5 DbContext LINQ 查询无跟踪 1208 41803776
EF5 DbSet 的 DbContext SQL 查询 1414 37982208
EF5 DbContext LINQ 查询 1574 41738240
EF6 ObjectContext 实体命令 480 47247360
EF6 ObjectContext 存储查询 493 46739456
EF6 对数据库进行 DbContext SQL 查询 614 41607168
EF6 ObjectContext LINQ 查询无跟踪 684 46333952
EF6 使用对象查询的 ObjectContext 实体 SQL 767 48865280
EF6 ObjectContext 编译查询 788 48467968
EF6 DbContext LINQ 查询无跟踪 878 47554560
EF6 ObjectContext LINQ 查询 953 47632384
EF6 DbSet 的 DbContext SQL 查询 1023 41992192
EF6 DbContext LINQ 查询 1290 47529984

EF5 warm query 1000 iterations

EF6 warm query 1000 iterations

注意

为完整起见,我们提供了一种测试,我们通过该测试使用 EntityCommand 执行了实体 SQL 查询。 但是,由于此类查询的结果并未具体化,因此并非都是一对一进行比较。 为了使比较更准确,测试中包括了接近具体化的查询结果。

在这种端到端的情况下,由于实体框架 6 在堆栈多个部分进行了性能改进(例如 DbContext 初始化更便捷,MetadataCollection<T> 查找速度更快),因而实体框架 6 优于实体框架 5。

7 设计时性能注意事项

7.1 继承策略

使用实体框架时的另一个性能注意事项在于所使用的继承策略。 实体框架支持 3 种基本类型的继承及其组合:

  • 每个层次结构一张表 (TPH) – 其中每个继承集都映射到一个具有鉴别器列的表,用于指示层次结构中具体哪种类型要在行中表示。
  • 每个类型一张表 (TPT) – 其中每种类型在数据库中都有其自己的表;子表仅定义父表不包含的列。
  • 每个类一张表 (TPC) – 其中每种类型在数据库中都有其自己的完整表;子表定义其所有字段,包括在父类型中定义的字段。

如果模型使用 TPT 继承,则生成的查询将比使用其他继承策略生成的查询更复杂,这可能会导致存储上的执行时间较长。  根据 TPT 模型生成查询并具体化生成的对象通常需要更长的时间。

请参阅“在实体框架中使用 TPT(每种类型一张表)继承时的性能注意事项”MSDN 博客文章:<https://learn.microsoft.com/archive/blogs/adonet/performance-considerations-when-using-tpt-table-per-type-inheritance-in-the-entity-framework>。

7.1.1 避免在 Model First 或 Code First 应用程序中使用 TPT

如果根据具有 TPT 架构的现有数据库创建模型,则不会有太多选择。 但如果使用 Model First 或 Code First 创建应用程序,出于性能考虑,应避免使用 TPT 继承。

在实体设计器向导中使用 Model First 时,你将获取模型中任何继承的 TPT。 如果要使用 Model First 切换到 TPH 继承策略,则可以使用 Visual Studio 库中提供的“实体设计器数据库生成 Power Pack”(<http://visualstudiogallery.msdn.microsoft.com/df3541c3-d833-4b65-b942-989e7ec74c87/>)。

使用 Code First 配置模型与继承的映射时,EF 将默认使用 TPH,因此继承层次结构中的所有实体都将映射到同一个表。 有关更多详细信息,请参阅 MSDN 杂志 (http://msdn.microsoft.com/magazine/hh126815.aspx) 中“实体框架 4.1 中的 Code First”一文的“使用 Fluent API 映射”部分。

7.2 升级 EF4 以缩短模型生成时间

在实体框架 5 和 6 中,对生成模型存储层 (SSDL) 的算法进行了特定于 SQL Server 的改进,安装 Visual Studio 2010 SP1 时,则作为实体框架 4 的更新。 以下测试结果演示了生成大型模型时的改进,本例中为 Navision 模型。 有关详细信息,请参阅附录 C。

该模型包含 1005 个实体集和 4227 个关联集。

配置 消耗时间具体构成
Visual Studio 2010,实体框架 4 SSDL 生成:2 小时 27 分钟
映射生成:1 秒
CSDL 生成:1 秒
ObjectLayer 生成:1 秒
视图生成:2 小时 14 分钟
Visual Studio 2010 SP1,实体框架 4 SSDL 生成:1 秒
映射生成:1 秒
CSDL 生成:1 秒
ObjectLayer 生成:1 秒
视图生成:1 小时 53 分钟
Visual Studio 2013,实体框架 5 SSDL 生成:1 秒
映射生成:1 秒
CSDL 生成:1 秒
ObjectLayer 生成:1 秒
视图生成:65 分钟
Visual Studio 2013,实体框架 6 SSDL 生成:1 秒
映射生成:1 秒
CSDL 生成:1 秒
ObjectLayer 生成:1 秒
视图生成:28 秒。

值得注意的是,生成 SSDL 时,负载几乎全部花在 SQL Server 上,而客户端开发计算机则处于空闲状态,等待从服务器返回结果。 这一改进对 DBAS 意义重大。 还值得注意的是,生成模型的全部成本现在基本上都发生在视图生成过程中。

7.3 使用 Database First 和 Model First 拆分大型模型

随着模型大小的增加,设计器图面变得混乱且难以使用。 通常,拥有超过 300 个实体的模型就可以视为过于庞大,将无法有效地使用设计器。 以下博客文章介绍了用于拆分大型模型的多种选项:<https://learn.microsoft.com/archive/blogs/adonet/working-with-large-models-in-entity-framework-part-2>。

虽然文章内容针对的是实体框架的第一个版本,但这些步骤仍适用。

7.4 实体数据源控件的性能注意事项

我们在多线程性能和压力测试中看到这样的情况:使用 EntityDataSource 控件的 Web 应用程序的性能显著降低。 其深层原因为,EntityDataSource 在 Web 应用程序引用的程序集上重复调用 MetadataWorkspace.LoadFromAssembly,以发现要用作实体的类型。

解决方案是将 EntityDataSource 的 ContextTypeName 设置为派生的 ObjectContext 类的类型名称。 这将关闭扫描所有引用的程序集以发现实体类型的机制。

设置 ContextTypeName 字段还可防止出现一项功能问题,即 .NET 4.0 中的 EntityDataSource 在无法通过反射从程序集加载类型时会引发 ReflectionTypeLoadException。 此问题已在 .NET 4.5 中得到修复。

7.5 POCO 实体和更改跟踪代理

使用实体框架,你可以将自定义数据类与数据模型一起使用,而无需对数据类本身进行任何修改。 这意味着可以将“纯旧式”CLR 对象 (POCO)(例如,现有的域对象)与数据模型一起使用。 这些 POCO 数据类(也称为“永久性未知对象”)映射到数据模型中定义的实体,它们支持与使用实体数据模型工具生成的实体类型相同的大多数查询、插入、更新和删除行为。

实体框架还可以创建从 POCO 类型派生的代理类,如果想要在 POCO 实体上启用延迟加载和自动更改跟踪等功能,则可使用这些类。 POCO 类必须满足某些要求,实体框架才能使用代理,如下面所述:http://msdn.microsoft.com/library/dd468057.aspx

每次实体的属性值发生更改时,更改跟踪代理都会通知对象状态管理器,因此实体框架始终了解实体的实际状态。 实现此举的方式如下:将通知事件添加到属性的 setter 方法的主体,并让对象状态管理器处理此类事件。 请注意,由于增加了实体框架创建的事件集,创建代理实体通常比创建非代理 POCO 实体更昂贵。

如果 POCO 实体没有更改跟踪代理,则可以通过将实体的内容与之前保存的状态副本进行比较来发现更改。 如果上下文中具有多个实体,或者实体具有非常多的属性(即使在上次进行比较之后它们都没有发生更改),这种深度比较的过程都将相当耗时。

总结:创建更改跟踪代理时会付出一定的性能损失,但如果实体有很多属性或模型中有很多实体,更改跟踪将有助于加速更改检测过程。 如果实体只有少量属性,且实体的数量增长并不多,则拥有更改跟踪代理的优势可能并不大。

8.1 延迟加载与预先加载

实体框架提供多种不同的方法来加载与目标实体相关的实体。 例如,查询“产品”时,会有不同的方式将相关的“订单”加载到对象状态管理器中。 从性能角度来看,加载相关实体时要考虑的首要问题在于,是使用延迟加载还是预先加载。

若使用预先加载,相关的实体会和目标实体集一起加载。 在查询中使用 Include 语句来指示想要引入的相关实体。

若使用延迟加载,初始查询仅引入目标实体集。 但每当访问导航属性时,都会针对存储发出另一个查询来加载相关实体。

加载实体后,对该实体的任何进一步的查询都将直接从对象状态管理器中加载它(无论使用延迟加载还是预先加载)。

8.2 如何在延迟加载和预先加载之间进行选择

请务必了解延迟加载和预先加载的区别,以便为应用程序做出正确的选择。 这将有助于你在针对数据库的多个请求与可能包含大量有效负载的单个请求之间,进行评估与权衡。 可能在应用程序的某些部分使用预先加载,在其他部分使用延迟加载会比较合适。

举个示例来加以说明,假设你想查询住在英国的客户以及他们的订单数。

使用预先加载

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;

    //Notice that the Include method call is missing in the query
    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);
}

使用预先加载时,你将发出一个返回所有客户和所有订单的查询。 store 命令如下所示:

SELECT
[Project1].[C1] AS [C1],
[Project1].[CustomerID] AS [CustomerID],
[Project1].[CompanyName] AS [CompanyName],
[Project1].[ContactName] AS [ContactName],
[Project1].[ContactTitle] AS [ContactTitle],
[Project1].[Address] AS [Address],
[Project1].[City] AS [City],
[Project1].[Region] AS [Region],
[Project1].[PostalCode] AS [PostalCode],
[Project1].[Country] AS [Country],
[Project1].[Phone] AS [Phone],
[Project1].[Fax] AS [Fax],
[Project1].[C2] AS [C2],
[Project1].[OrderID] AS [OrderID],
[Project1].[CustomerID1] AS [CustomerID1],
[Project1].[EmployeeID] AS [EmployeeID],
[Project1].[OrderDate] AS [OrderDate],
[Project1].[RequiredDate] AS [RequiredDate],
[Project1].[ShippedDate] AS [ShippedDate],
[Project1].[ShipVia] AS [ShipVia],
[Project1].[Freight] AS [Freight],
[Project1].[ShipName] AS [ShipName],
[Project1].[ShipAddress] AS [ShipAddress],
[Project1].[ShipCity] AS [ShipCity],
[Project1].[ShipRegion] AS [ShipRegion],
[Project1].[ShipPostalCode] AS [ShipPostalCode],
[Project1].[ShipCountry] AS [ShipCountry]
FROM ( 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],
      1 AS [C1],
       [Extent2].[OrderID] AS [OrderID],
       [Extent2].[CustomerID] AS [CustomerID1],
       [Extent2].[EmployeeID] AS [EmployeeID],
       [Extent2].[OrderDate] AS [OrderDate],
       [Extent2].[RequiredDate] AS [RequiredDate],
       [Extent2].[ShippedDate] AS [ShippedDate],
       [Extent2].[ShipVia] AS [ShipVia],
       [Extent2].[Freight] AS [Freight],
       [Extent2].[ShipName] AS [ShipName],
       [Extent2].[ShipAddress] AS [ShipAddress],
       [Extent2].[ShipCity] AS [ShipCity],
       [Extent2].[ShipRegion] AS [ShipRegion],
       [Extent2].[ShipPostalCode] AS [ShipPostalCode],
       [Extent2].[ShipCountry] AS [ShipCountry],
      CASE WHEN ([Extent2].[OrderID] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C2]
      FROM  [dbo].[Customers] AS [Extent1]
      LEFT OUTER JOIN [dbo].[Orders] AS [Extent2] ON [Extent1].[CustomerID] = [Extent2].[CustomerID]
      WHERE N'UK' = [Extent1].[Country]
)  AS [Project1]
ORDER BY [Project1].[CustomerID] ASC, [Project1].[C2] ASC

使用延迟加载时,最初将发出以下查询:

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'

有关详细信息,请参阅加载相关对象

8.2.1 延迟加载与预先加载速查表

选择预先加载和延迟加载时,并没有一种完全通用的方法。 首先需要尝试了解这两种策略的区别,才能做出明智的决定;同时,考虑你的代码是否符合以下任何一种情况:

方案 我们的建议
是否需要从提取的实体访问多种导航属性? - 两种选项可能均适合。 但是,如果查询引入的有效负载不是太大,则使用预先加载可能在性能上会有优势,因为它对具体化对象所需的网络往返更少。

- 如果需要从实体访问多个导航属性,可以通过预先加载使用查询中的多个 Include 语句来实现。 包含的实体越多,查询返回的有效负载就越大。 在查询中包括三个或以上实体后,请考虑切换使用延迟加载。
是否确切知道运行时需要哪些数据? - 延迟加载会更适合。 否则,最终可能会查询出不需要的数据。

- 预先加载可能是最佳选择;它有助于更快地加载整个集。 如果查询需要提取大量数据,但速度过慢,请改为尝试延迟加载。
代码是否在远离数据库的地方执行? (网络延迟增加) - 如果网络延迟不是问题,使用延迟加载可简化代码。 请记住,应用程序的拓扑结构可能会发生变化,因此不要以为会一直临近数据库。

- 如果需要考虑网络问题,只能由你根据自身情况决定更适合的方案。 通常预先加载会更好,因为它需要更少的网络往返。

8.2.2 具有多个 Include 时的性能问题

当听到涉及服务器响应时间的性能问题时,问题的根源往往是带有多个 Include 语句的查询。 虽然将相关实体包括在查询中是一项强大的功能,但了解实际上发生的情况很重要。

对于包含多个 Include 语句的查询,需要相对较长的时间来通过内部计划编译器生成 store 命令。 大部分时间都花在尝试优化产生的查询上。 生成的 store 命令将包含每个 Include 的外部联接或联合,具体取决于映射。 类似这样的查询将在单个有效负载中从数据库中引入大型连接图表,这会加剧所有带宽问题,尤其是当有效负载中有大量冗余时(例如,使用多个级别的 Include 来遍历一对多方向中的关联时)。

可以通过使用 ToTraceString 访问查询的基础 TSQL 并在 SQL Server Management Studio 中执行 store 命令来查看有效负载大小,从而检查查询返回过大有效负载的情况。 在这种情况下,可以尝试减少查询中的 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)
    {
        ...
    }
}

这仅适用于跟踪的查询,因为我们利用的是上下文必须自动执行标识解析和关联修复的功能。

与延迟加载一样,这样做的代价是会有更多较小有效负载的查询。 也可使用各属性的投影来仅仅显式选择每个实体中所需的数据,但在这种情况下不会加载实体,并且不支持更新。

8.2.3 获取属性延迟加载的解决方法

实体框架目前不支持延迟加载标量或复杂属性。 但是,如果表包含大型对象(如 BLOB),可以使用表拆分将大型属性分隔为单独的实体。 例如,假设你有一个包含 varbinary 照片列的“产品”表。 如果不需要经常在查询中访问此属性,可以使用表拆分来仅引入通常所需的实体部分。 只有在明确需要产品照片时,才会加载表示产品照片的实体。

Gil Fink 的“实体框架中的表拆分”博客文章是演示如何启用表拆分的一种好资源:<http://blogs.microsoft.co.il/blogs/gilf/archive/2009/10/13/table-splitting-in-entity-framework.aspx>。

9 其他注意事项

9.1 服务器垃圾回收

如果垃圾回收器未正确配置,某些用户可能会遇到资源争用的情况,从而限制了他们期望的并行度。 每当在多线程场景或类似于服务器端系统的任何应用程序中使用 EF 时,请确保启用服务器垃圾回收。 这可通过在应用程序配置文件中进行简单设置完成:

<?xmlversion="1.0" encoding="utf-8" ?>
<configuration>
        <runtime>
               <gcServer enabled="true" />
        </runtime>
</configuration>

在 CPU 饱和的情况下,这样做可减少线程争用,并提升吞吐量,最多可达 30%。 一般而言,应该始终使用经典垃圾回收(更适合于 UI 和客户端场景)以及服务器垃圾回收来测试应用程序的行为方式。

9.2 AutoDetectChanges

如前所述,当对象缓存具有多个实体时,实体框架可能会显示性能问题。 某些操作(如 Add、Remove、Find、Entry 和 SaveChanges)会触发对 DetectChanges 的调用,这些调用可能会根据对象缓存的大小消耗大量 CPU。 出现这种情况的原因在于,对象缓存和对象状态管理器会尝试在针对上下文执行的每项操作上尽可能保持同步,保证生成的数据在多种场景中都正确无误。

通常,在应用程序的整个生命周期内启用实体框架的自动更改检测是一种很好的做法。 如果相关场景受到 CPU 使用率过高的负面影响,并且配置文件指出造成这一问题的主要原因在于调用了 DetectChanges,请考虑在代码的敏感部分暂时关闭 AutoDetectChanges:

try
{
    context.Configuration.AutoDetectChangesEnabled = false;
    var product = context.Products.Find(productId);
    ...
}
finally
{
    context.Configuration.AutoDetectChangesEnabled = true;
}

在关闭 AutoDetectChanges 之前,最好了解这可能会导致实体框架失去跟踪实体上发生更改的某些信息的能力。 如果处理不当,这可能会导致应用程序上的数据不一致。 有关关闭 AutoDetectChanges 的详细信息,请阅读 <http://blog.oneunicorn.com/2012/03/12/secrets-of-detectchanges-part-3-switching-off-automatic-detectchanges/>。

9.3 每个请求的上下文

实体框架的上下文旨在用作短期实例,以提供最佳性能体验。 上下文应短时保存并丢弃,因此,实现的上下文非常轻便,且可在需要时再次利用元数据。 在 Web 场景中,请务必记住这一点,并不要让上下文留存时间超过单个请求的持续时间。 同样,在非 Web 场景中,应根据对实体框架中不同级别缓存的理解来决定是否丢弃上下文。 一般而言,应避免在应用程序的整个生命周期中拥有上下文实例,以及每个线程的上下文和静态上下文。

9.4 数据库 NULL 语义

实体框架将默认生成具有 C# NULL 比较语义的 SQL 代码。 请参考以下示例查询:

            int? categoryId = 7;
            int? supplierId = 8;
            decimal? unitPrice = 0;
            short? unitsInStock = 100;
            short? unitsOnOrder = 20;
            short? reorderLevel = null;

            var q = from p incontext.Products
                    where p.Category.CategoryName == "Beverages"
                          || (p.CategoryID == categoryId
                                || p.SupplierID == supplierId
                                || p.UnitPrice == unitPrice
                                || p.UnitsInStock == unitsInStock
                                || p.UnitsOnOrder == unitsOnOrder
                                || p.ReorderLevel == reorderLevel)
                    select p;

            var r = q.ToList();

本示例将多个可为空变量与实体上的可为空属性(如 SupplierID 和 UnitPrice)进行比较。 为此查询生成的 SQL 将询问参数值是否与列值相同,或者参数值和列值是否都为空。 这将隐藏数据库服务器处理 NULL 的方式,并在不同的数据库供应商之间提供一致的 C# NULL 体验。 另一方面,生成的代码有点复杂,当查询的 where 语句中的比较量增长到较大数量时,可能无法很好地执行。

处理这种情况的一种方式是使用数据库 NULL 语义。 请注意,这可能与 C# NULL 语义的行为不同,因为现在实体框架将生成更简单的 SQL,从而公开数据库引擎处理 NULL 值的方式。 可以使用针对上下文配置的单个配置行在每个上下文中激活数据库 NULL 语义:

                context.Configuration.UseDatabaseNullSemantics = true;

使用数据库 NULL 语义时,小型到中型查询不会显示明显的性能改进,但对于拥有大量潜在 NULL 比较的查询,差异将更加明显。

在上面的示例查询中,在受控环境中运行时性能差异小于 2%(以微基准进行测量)。

9.5 异步

在 .NET 4.5 或更高版本上运行时,实体框架 6 引入了对异步操作的支持。 大多数情况下,具有 IO 相关争用的应用程序会从使用异步查询和保存操作中获益最大。 如果应用程序没有 IO 争用问题,那么在最好的情况下,异步的使用将同步运行并在与同步调用相同的时间内返回结果,而在最坏的情况下,只需将执行推迟到异步任务并增加额外的时间来完成你的方案。

有关异步编程工作如何帮助你确定异步是否将提高应用程序性能的信息,请参阅使用 Async 和 Await 的异步编程。 有关在实体框架上使用异步操作的详细信息,请参阅异步查询和保存

9.6 NGEN

NET Framework 中不会默认安装实体框架 6。 因此,实体框架程序集在默认情况下没有进行 NGen,这意味着所有实体框架代码与任何其他 MSIL 程序集一样,都需要支付 JIT 费用。 这可能会降影响开发过程中 F5 的体验,也会影响在生产环境中冷启动应用程序的体验。 为了减少 JIT 的 CPU 和内存成本,建议适当对实体框架映像进行 NGEN 操作。 若要详细了解如何使用 NGen 提高实体框架 6 的启动性能,请参阅使用 NGen 提高启动性能

9.7 Code First 和 EDMX

实体框架通过在内存中表示概念模型(对象)、存储架构(数据库)和两者之间的映射,来解释面向对象编程和关系数据库之间的阻抗不匹配问题。 此元数据称为实体数据模型,简称 EDM。 从此 EDM 中,实体框架将派生视图以将数据从内存中的对象往返到数据库,然后返回。

实体框架与正式指定概念模型、存储架构和映射的 EDMX 文件一起使用时,模型加载阶段只需验证 EDM 是否正确(例如,确保没有缺失任何映射),然后生成视图,再验证视图并准备好使用此元数据。 只有这样才能执行查询或将新数据保存到数据存储中。

Code First 方法的核心是复杂的实体数据模型生成器。 实体框架必须根据提供的代码生成 EDM;它通过分析模型中涉及的类、应用约定和通过 Fluent API 配置模型来实现。 生成 EDM 后,实体框架的行为方式与项目中存在 EDMX 文件时的行为方式基本相同。 因此,与使用 EDMX 相比,从 Code First 生成模型会更加复杂,会导致实体框架的启动时间更慢。 成本完全取决于正在生成的模型的大小及其复杂度。

在选择是使用 EDMX 还是 Code First 时,务必要了解一点,即 Code First 引入的灵活性会增加首次生成模型的成本。 如果应用程序可以承受首次加载的成本,那么通常 Code First 将是更优选择。

10 调查性能

10.1 使用 Visual Studio Profiler

如果遇到实体框架相关的性能问题,可以使用 Visual Studio 中内置的探查器等工具来查看应用程序的时间用在何处。 这是我们在“浏览 ADO.NET 实体框架的性能 - 第 1 部分”博客文章 (<https://learn.microsoft.com/archive/blogs/adonet/exploring-the-performance-of-the-ado-net-entity-framework-part-1>) 中用于生成饼图的工具,其中显示了实体框架在冷搜索和暖查询期间花费的时间。

数据和建模客户咨询团队撰写的“使用 Visual Studio 2010 Profiler 分析实体框架”博客文章展示了他们如何使用探查器调查性能问题的真实案例。  <https://learn.microsoft.com/archive/blogs/dmcat/profiling-entity-framework-using-the-visual-studio-2010-profiler>。 本文内容针对 Windows 应用程序。 如果需要分析 Web 应用程序,Windows 性能记录器 (WPR) 和 Windows 性能分析器 (WPA) 工具可能优于使用 Visual Studio。 WPR 和 WPA 属于 Windows 性能工具包,包含在 Windows 评估和部署工具包中。

10.2 应用程序/数据库分析

Visual Studio 中内置的探查器等工具会告诉你应用程序将时间用在何处。  可根据需要使用另一种类型的探查器,以在生产中或预生产中对正在运行的应用程序执行动态分析,并查找数据库访问的常见缺陷和反模式。

两种商用探查器:实体框架探查器 (<http://efprof.com>) 和 ORMProfiler (<http://ormprofiler.com>)。

如果应用程序是使用 Code First 的 MVC 应用程序,则可以使用 StackExchange 的 MiniProfiler。 Scott Hanselman 在他的博客中介绍了此工具:<http://www.hanselman.com/blog/NuGetPackageOfTheWeek9ASPNETMiniProfilerFromStackExchangeRocksYourWorld.aspx>。

有关分析应用程序数据库活动的详细信息,请参阅 Julie Lerman 的 MSDN 杂志文章,标题为在实体框架中分析数据库活动

10.3 数据库记录器

如果使用实体框架 6,还可考虑使用内置的日志记录功能。 可以通过简单的单行配置指示上下文的 Database 属性记录其活动:

    using (var context = newQueryComparison.DbC.NorthwindEntities())
    {
        context.Database.Log = Console.WriteLine;
        var q = context.Products.Where(p => p.Category.CategoryName == "Beverages");
        q.ToList();
    }

在此示例中,数据库活动将记录到控制台,但可以将 Log 属性配置为调用任何 Action<string> 委托。

如果要在不重新编译的情况下启用数据库日志记录,并且你使用的是实体框架 6.1 或更高版本,则可以通过在应用程序的 web.config 或 app.config 文件中添加侦听器来实现。

  <interceptors>
    <interceptor type="System.Data.Entity.Infrastructure.Interception.DatabaseLogger, EntityFramework">
      <parameters>
        <parameter value="C:\Path\To\My\LogOutput.txt"/>
      </parameters>
    </interceptor>
  </interceptors>

若要详细了解如何在不重新编译的情况下添加日志记录,请转到 <http://blog.oneunicorn.com/2014/02/09/ef-6-1-turning-on-logging-without-recompiling/>。

11 附录

11.1 A. 测试环境

此环境使用 2 台计算机设置,数据库位于与客户端应用程序不同的计算机上。 计算机都位于同一机架中,因此网络延迟相对较低,但比单一计算机环境更真实。

11.1.1 应用服务器

11.1.1.1 软件环境
  • 实体框架 4 软件环境
    • OS 名称:Windows Server 2008 R2 Enterprise SP1。
    • Visual Studio 2010 – 旗舰版。
    • Visual Studio 2010 SP1(仅适用于某些比较)。
  • 实体框架 5 和 6 软件环境
    • OS 名称:Windows 8.1 企业版
    • Visual Studio 2013 – 旗舰版。
11.1.1.2 硬件环境
  • 双处理器:Intel(R) Xeon(R) CPU L5520 W3530 @ 2.27GHz、2261 Mhz8 GHz、4 核、84 逻辑处理器。
  • 2412 GB RamRAM。
  • 136 GB SCSI250GB SATA 7200 rpm 3GB/s 驱动器拆分为 4 个分区。

11.1.2 DB 服务器

11.1.2.1 软件环境
  • OS 名称:Windows Server 2008 R28.1 Enterprise SP1。
  • SQL Server 2008 R22012。
11.1.2.2 硬件环境
  • 单处理器:Intel(R) Xeon(R) CPU L5520 @ 2.27GHz、2261 MhzES-1620 0 @ 3.60GHz、4 核、8 逻辑处理器。
  • 824 GB RamRAM。
  • 465 GB ATA500GB SATA 7200 rpm 6GB/s 驱动器拆分为 4 个分区。

11.2 B. 查询性能比较测试

Northwind 模型用于执行这些测试。 该模型使用实体框架设计器从数据库生成。 以下代码用于比较查询执行选项的性能:

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Common;
using System.Data.Entity.Infrastructure;
using System.Data.EntityClient;
using System.Data.Objects;
using System.Linq;

namespace QueryComparison
{
    public partial class NorthwindEntities : ObjectContext
    {
        private static readonly Func<NorthwindEntities, string, IQueryable<Product>> productsForCategoryCQ = CompiledQuery.Compile(
            (NorthwindEntities context, string categoryName) =>
                context.Products.Where(p => p.Category.CategoryName == categoryName)
                );

        public IQueryable<Product> InvokeProductsForCategoryCQ(string categoryName)
        {
            return productsForCategoryCQ(this, categoryName);
        }
    }

    public class QueryTypePerfComparison
    {
        private static string entityConnectionStr = @"metadata=res://*/Northwind.csdl|res://*/Northwind.ssdl|res://*/Northwind.msl;provider=System.Data.SqlClient;provider connection string='data source=.;initial catalog=Northwind;integrated security=True;multipleactiveresultsets=True;App=EntityFramework'";

        public void LINQIncludingContextCreation()
        {
            using (NorthwindEntities context = new NorthwindEntities())
            {                 
                var q = context.Products.Where(p => p.Category.CategoryName == "Beverages");
                q.ToList();
            }
        }

        public void LINQNoTracking()
        {
            using (NorthwindEntities context = new NorthwindEntities())
            {
                context.Products.MergeOption = MergeOption.NoTracking;

                var q = context.Products.Where(p => p.Category.CategoryName == "Beverages");
                q.ToList();
            }
        }

        public void CompiledQuery()
        {
            using (NorthwindEntities context = new NorthwindEntities())
            {
                var q = context.InvokeProductsForCategoryCQ("Beverages");
                q.ToList();
            }
        }

        public void ObjectQuery()
        {
            using (NorthwindEntities context = new NorthwindEntities())
            {
                ObjectQuery<Product> products = context.Products.Where("it.Category.CategoryName = 'Beverages'");
                products.ToList();
            }
        }

        public void EntityCommand()
        {
            using (EntityConnection eConn = new EntityConnection(entityConnectionStr))
            {
                eConn.Open();
                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))
                {
                    List<Product> productsList = new List<Product>();
                    while (reader.Read())
                    {
                        DbDataRecord record = (DbDataRecord)reader.GetValue(0);

                        // 'materialize' the product by accessing each field and value. Because we are materializing products, we won't have any nested data readers or records.
                        int fieldCount = record.FieldCount;

                        // Treat all products as Product, even if they are the subtype DiscontinuedProduct.
                        Product product = new Product();  

                        product.ProductID = record.GetInt32(0);
                        product.ProductName = record.GetString(1);
                        product.SupplierID = record.GetInt32(2);
                        product.CategoryID = record.GetInt32(3);
                        product.QuantityPerUnit = record.GetString(4);
                        product.UnitPrice = record.GetDecimal(5);
                        product.UnitsInStock = record.GetInt16(6);
                        product.UnitsOnOrder = record.GetInt16(7);
                        product.ReorderLevel = record.GetInt16(8);
                        product.Discontinued = record.GetBoolean(9);

                        productsList.Add(product);
                    }
                }
            }
        }

        public void ExecuteStoreQuery()
        {
            using (NorthwindEntities context = new NorthwindEntities())
            {
                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
    FROM            Products AS P INNER JOIN Categories AS C ON P.CategoryID = C.CategoryID
    WHERE        (C.CategoryName = 'Beverages')"
);
                beverages.ToList();
            }
        }

        public void ExecuteStoreQueryDbContext()
        {
            using (var context = new QueryComparison.DbC.NorthwindEntities())
            {
                var beverages = context.Database.SqlQuery\<QueryComparison.DbC.Product>(
@"    SELECT        P.ProductID, P.ProductName, P.SupplierID, P.CategoryID, P.QuantityPerUnit, P.UnitPrice, P.UnitsInStock, P.UnitsOnOrder, P.ReorderLevel, P.Discontinued
    FROM            Products AS P INNER JOIN Categories AS C ON P.CategoryID = C.CategoryID
    WHERE        (C.CategoryName = 'Beverages')"
);
                beverages.ToList();
            }
        }

        public void ExecuteStoreQueryDbSet()
        {
            using (var context = new QueryComparison.DbC.NorthwindEntities())
            {
                var beverages = context.Products.SqlQuery(
@"    SELECT        P.ProductID, P.ProductName, P.SupplierID, P.CategoryID, P.QuantityPerUnit, P.UnitPrice, P.UnitsInStock, P.UnitsOnOrder, P.ReorderLevel, P.Discontinued
    FROM            Products AS P INNER JOIN Categories AS C ON P.CategoryID = C.CategoryID
    WHERE        (C.CategoryName = 'Beverages')"
);
                beverages.ToList();
            }
        }

        public void LINQIncludingContextCreationDbContext()
        {
            using (var context = new QueryComparison.DbC.NorthwindEntities())
            {                 
                var q = context.Products.Where(p => p.Category.CategoryName == "Beverages");
                q.ToList();
            }
        }

        public void LINQNoTrackingDbContext()
        {
            using (var context = new QueryComparison.DbC.NorthwindEntities())
            {
                var q = context.Products.AsNoTracking().Where(p => p.Category.CategoryName == "Beverages");
                q.ToList();
            }
        }
    }
}

11.3 C. Navision 模型

Navision 数据库是一个大型数据库,用于演示 Microsoft Dynamics – NAV。 生成的概念模型包含 1005 个实体集和 4227 个关联集。 测试中使用的模型较简单 - 未添加任何继承。

11.3.1 用于 Navision 测试的查询

与 Navision 模型一同使用的查询列表包含 3 类实体 SQL 查询:

11.3.1.1 查找

没有聚合的简单查找查询

  • 计数:16232
  • 示例:
  <Query complexity="Lookup">
    <CommandText>Select value distinct top(4) e.Idle_Time From NavisionFKContext.Session as e</CommandText>
  </Query>
11.3.1.2 SingleAggregating

具有多个聚合的普通 BI 查询,但无小计(单个查询)

  • 计数:2313
  • 示例:
  <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>
11.3.1.3 AggregatingSubtotals

包含聚合和小计的 BI 查询(通过 union all)

  • 计数:178
  • 示例:
  <Query complexity="AggregatingSubtotals">
    <CommandText>
using NavisionFK;
function AmountConsumed(entities Collection([CRONUS_International_Ltd__Zone])) as
(
    Edm.Sum(select value N.Block_Movement FROM entities as E, E.CRONUS_International_Ltd__Bin as N)
)
function AmountConsumed(P1 Edm.Int32) as
(
    AmountConsumed(select value e from NavisionFKContext.CRONUS_International_Ltd__Zone as e where e.Zone_Ranking = P1)
)
----------------------------------------------------------------------------------------------------------------------
(
    select top(10) Zone_Ranking, Cross_Dock_Bin_Zone, AmountConsumed(GroupPartition(E))
    from NavisionFKContext.CRONUS_International_Ltd__Zone as E
    where AmountConsumed(E.Zone_Ranking) > @MinAmountConsumed
    group by E.Zone_Ranking, E.Cross_Dock_Bin_Zone
)
union all
(
    select top(10) Zone_Ranking, Cast(null as Edm.Byte) as P2, AmountConsumed(GroupPartition(E))
    from NavisionFKContext.CRONUS_International_Ltd__Zone as E
    where AmountConsumed(E.Zone_Ranking) > @MinAmountConsumed
    group by E.Zone_Ranking
)
union all
{
    Row(Cast(null as Edm.Int32) as P1, Cast(null as Edm.Byte) as P2, AmountConsumed(select value E
                                                                         from NavisionFKContext.CRONUS_International_Ltd__Zone as E
                                                                         where AmountConsumed(E.Zone_Ranking) > @MinAmountConsumed))
}</CommandText>
    <Parameters>
      <Parameter Name="MinAmountConsumed" DbType="Int32" Value="10000" />
    </Parameters>
  </Query>