预编译 LINQ 查询之CompiledQuery类及性能优化的介绍
在应用程序中使用 LINQ to SQL 或 LINQ to Entities 时,有必要考虑对您创建并重复执行的任何查询进行预编译。我经常在埋头苦干一项特定任务时忘了利用预编译查询,等我意识到时为时已晚。这很像“异常处理病”,即开发人员试图在事发后将异常处理强行加入应用程序中。
然而,即使您已经实施了此项重要的性能增强方法,往往也只是徒劳。您可能会发现预期的性能增强并未实现,但原因(和解决方法)可能仍悬而未决。
在本篇专栏文章中,我首先将解释如何预编译查询,然后将重点讲述在 Web 应用程序、服务和其他方案中导致预编译无用的原因。您将学习如何确保在回发、短期服务操作以及其他会导致关键实例超出作用域的代码中获得性能优势。
预编译查询
在庞大的查询执行中,将 LINQ 查询转换为相关的存储查询(例如,数据库执行的 T-SQL)需要较高的成本。图 1 显示了将 LINQ to Entities 查询转换为存储查询时涉及的流程。
图 1 将 LINQ 查询转换为相关的存储查询
实体框架团队的博客文章“探讨 ADO.NET 实体框架的性能 – 第 1 部分”(blogs.msdn.com/adonet/archive/2008/02/04/exploring-the-performance-of-the-ado-net-entity-framework-part-1.aspx) 对该流程进行了细分,并提供了每个步骤的对应时间。注意:此篇文章基于 Microsoft .NET Framework 3.5 SP1 版本的实体框架,新版本中各步骤的时间分配可能发生了变化。但查询执行流程中的预编译成本仍然较高。
通过预编译查询,实体框架和 LINQ to SQL 可以重复使用存储查询并跳过每次计算的冗余流程。例如,如果您的应用程序经常从数据存储中检索不同的客户,您可能会有一个与下面类似的查询:
Context.Customers.Where(c=>c.CustomerID==_custID)
在从一个执行进入下一个执行时,如果只有 _custID 参数发生了变化,为什么要浪费时间将此查询反复转置到 SQL 命令呢?
LINQ to SQL 和实体框架都启用了查询预编译,但由于这两个框架中的流程不同,它们的 CompiledQuery 类也不同。LINQ to SQL 使用 System.Data.LINQ.CompiledQuery,而实体框架使用 System.Data.Objects.CompiledQuery。这两种形式的 CompiledQuery 都允许您传入参数,而且都要求您传入当前正在使用的 DataContext 或 ObjectContext。从编码的角度来说,它们在本质上是相同的。
CompiledQuery.Compile 方法以 Func 的形式返回一个委托,在需要时可以调用该委托。
以下是实体框架的 CompiledQuery 类编译的一个简单查询,由于是静态查询,因此不要求实例化:
C#
var _custByID = CompiledQuery.Compile<SalesEntities, int, Customer> ((ctx, id) =>ctx.Customers.Where(c=> c.ContactID == id).Single());
VB
Dim _custByID= CompiledQuery.Compile(Of SalesEntities, Integer, Customer) (Function(ctx As ObjectContext, id As Integer) ctx.Customers.Where(Function(c) c.CustomerID = custID).Single)
您可以在查询表达式中使用 LINQ 方法或 LINQ 运算符。这些查询是利用 LINQ 方法和 lambda 创建的。
与典型的泛型方法相比,该语法相对复杂,因此我将对该语法进行细分讲述。再次强调,Compile 方法的目标是创建一个可在以后调用的 Func(委托),如下所示:
C#
CompiledQuery.Compile<SalesEntities, int, Customer>
VB
CompiledQuery.Compile(Of SalesEntities, Integer, Customer)
由于采用的是泛型方法,因此方法必须被告知传入参数的类型以及调用委托时的返回类型。至少,您必须传入某种类型的 ObjectContext 或 DataContext(对于 LINQ to SQL)。您可以指定 System.Data.Objects.ObjectContext 或从其派生的参数。在此例中,我明确地使用了与我的实体数据模型关联的派生类 SalesEntities。
您也可以定义多个参数,但它们必须紧跟在上下文之后。在此例中,我指示 Compile,产生的预编译查询还应采用 int/整数参数。最后的类型描述了查询的返回类型,在此例中是 Customer 对象:
C#
((ctx, id) =>ctx.Customers.Where(c => c.ContactID == id).Single())
VB
Function(ctx As ObjectContext, id As Integer) ctx.Customers.Where(Function(c) c.CustomerID = custID).Single
之前编译方法的结果是下列委托:
C#
private System.Func<SalesEntities, int, Customer> _custByID
Private _custByID As System.Func(Of SalesEntities, Integer, Customer)
编译查询后,您今后只需在希望执行查询时调用它,传入 ObjectContext 或 DataContext 实例和所需任何其他参数。此处有一个名为 _commonContext 的实例和一个名为 _custID 的变量:
Customer cust = _custByID.Invoke(_commonContext, _custID);
第一次调用委托时,此查询将转换为存储查询,并且将对此次转换进行缓存以供今后调用 Invoke 时重复使用。LINQ 可以跳过编译查询的任务,直接进入执行。
确保预编译查询真正被使用
对于预编译查询,有一个不太明显而且人们知之甚少的问题。很多开发人员都想当然地认为查询被缓存在应用程序进程中,并且一直保存在那里。我原来也这么认为,因为除了一些不太引人注意的性能数字之外,没有迹象表明不是这样。但是,当您实例化编译查询的对象超出作用域时,您也随之失去了预编译查询。您需要在每次使用时重新预编译,因而完全丧失了预编译带来的好处。事实上,如果您只是执行 LINQ 查询,由于 CLR 必须为委托进行额外的工作,您反而要付出比正常情况更高的代价。
Rico Mariani 在他的博客文章“性能问题 #13 — Linq to SQL 编译查询成本 — 解决方案”(blogs.msdn.com/ricom/archive/2008/01/14/performance-quiz-13-linq-to-sql-compiled-query-cost-solution.aspx) 中深入探讨了使用委托的成本。评论中的讨论同样具有启发意义。
我看过一些博客文章,报道即使使用了预编译查询,LINQ to Entities 在 Web 应用程序中的性能仍令人无法接受。原因是每次回发页面时,都要获取新实例化的上下文并重新预编译查询。预编译查询永远得不到反复使用。只要您的上下文生存期很短,您就会遇到相同的问题。在某些地方很容易发现这种问题,例如在 Web 或 Windows Communication Foundation (WCF) 服务中;但在某些地方不太容易发现这种问题,例如在未对其提供实例时将动态实例化新上下文的存储库中。
您可以避免失去委托,方式是使用静态变量(在 VB 中为共享变量)在进程之间保留查询,然后使用当前提供的任何上下文调用查询。
在 Web 应用程序、WCF 服务和存储库中,ObjectContext 经常超出作用域,我成功地利用下列模式使委托在整个应用程序进程中一直可用。您需要在将调用查询的类构造函数中声明静态委托。我在此处声明的委托与之前创建的已编译查询相符:
C#
static Func<ObjectContext, int, Customer> _custByID;
VB
Shared _custByID As Func(Of ObjectContext, Integer, Customer)
有几个可以编译查询地方。您可以在类构造函数中或在即将调用查询之前编译查询。此处的方法用于执行查询并返回 Customer 对象:
public static Customer GetCustomer( int ID) { //test for an existing instance of the compiled query if (_custByID == null) { _custByID = CompiledQuery.Compile<SalesEntities, int, Customer> ((ctx, id) => ctx.Customers.Where(c => c.CustomerID == id).Single()); } return _custByID.Invoke(_context, ID); }
此方法将使用已编译查询。首先,它将仅在需要时动态编译查询,我通过测试了解是否已实例化查询来确定是否需要编译。如果您在类构造函数中编译,也需要执行相同的测试,以确信仅在必要时使用要编译的资源。
由于委托 _custByID 是静态的,因此即使其包含类超出作用域,该委托仍然保持在内存中。因此,只要应用程序进程本身在作用域中,委托就存在;它不会为 null,编译步骤将被跳过。
预编译查询和投影
还有一些值得注意并且也容易发现的问题。第一个问题与投影有关,但它并不针对不明智地重新编译已预编译查询的问题。当您投影查询中的列而非返回特定类型时,您将始终获得匿名类型。
定义查询时不可能指定其返回类型,因为无法表示“匿名类型的类型”。如果您希望将查询放在返回结果的方法中,您将遇到相同的问题,因为您无法指定方法返回什么。使用 LINQ 的开发人员经常遇到后一种制约。
考虑到匿名类型是不宜重复使用的动态类型这个事实,这些限制虽然令人沮丧,但还是有些道理。匿名类型不适合在方法之间传递。
您需要为预编译查询定义符合投影的类型。注意:在实体框架中,您必须使用类而不是结构,这是因为 LINQ to Entities 不允许投影到没有构造函数的类型中。LINQ to SQL 不允许结构成为投影目标。因此,对于实体框架只能使用类,而对于 LINQ to SQL,可以使用类或结构以避免匿名类型的相关限制。
预编译查询和 LINQ to SQL 预取
预编译查询可能出现的另一个问题是预取,又称作“预先加载”,但此问题仅存在于 LINQ to SQL 中。在实体框架中,您使用 Include 方法预先加载,这将导致在数据库中执行单个查询。Include 可以是查询的一部分(如 context.Customer.Include(“Orders”)),因此,此处不存在问题。然而,对于 LINQ to SQL,预先加载是在 DataContext 而非查询自身中定义的。
DataContext.LoadOptions 具有 LoadWith 方法,该方法允许您指定哪些相关数据与特定实体一起预先加载。
可以将 LoadWith 定义为将 Order 与查询的任何 Customer 一起加载:
Context.LoadOptions.LoadWith<Customer>(c => c.Orders)
然后,可以添加一条规则,指定将详细信息与任何加载的订单一起加载:
Context.LoadOptions.LoadWith<Customer>(c => c.Orders) Context.LoadOptions.LoadWith<Order>(o =>o.Details)
您可以针对 DataContext 实例直接定义 LoadOptions 或者创建 DataLoadOptions 类,在此对象中定义 LoadWith 规则,然后将其附加到您的上下文中:
DataLoadOptions _myLoadOptions = new DataLoadOptions(); _myLoadOptions.LoadWith<Customer>(c => c.Orders) Context.LoadOptions= myLoadOptions
对于 LoadOptions 和 DataLoadOptions 类的常规使用,有若干注意事项。例如,如果您先后定义和附加 DataLoadOptions,则一旦执行针对 DataContext 的查询后,您将无法附加新的 DataLoadOptions 组。尽管您还有很多加载选项及其注意事项需要了解,但还是让我们先看看将一些 LoadOptions 应用到预编译查询的基本模式。
此模式的关键是您无需将 DataLoadOptions 与特定上下文关联即可对其进行预定义。
在您声明静态 Func 变量以包含预编译查询的类声明中,声明一个新的 DataLoadOptions 变量。务必将此变量也设置为静态变量,从而使其与委托一起保持可用:
static DataLoadOptions Load_Customer_Orders_Details = new DataLoadOptions();
然后,在编译并调用查询的方法中,可与委托一起定义 LoadOptions(请参见图 2)。此方法对 .NET Framework 3.5 和 .NET Framework 4 有效。
图 2 与委托一起定义 LoadOptions
public Customer GetRandomCustomerWithOrders() { if (Load_Customer_Orders_Details == null) { Load_Customer_Orders_Details = new DataLoadOptions(); Load_Customer_Orders_Details.LoadWith<Customer>(c => c.Orders); Load_Customer_Orders_Details.LoadWith<Order>(o => o.Details); } if (_context.LoadOptions == null) { _context.LoadOptions = Load_Customer_Orders_Details; } if (_CustWithOrders == null) { _CustWithOrders = CompiledQuery.Compile<DataClasses1DataContext, Customer> (ctx => ctx.Customers.Where(c => c.Orders.Any()).FirstOrDefault()); } return _CustWithOrders.Invoke(_context); }
DataLoadOptions 是静态的,因此仅在需要时进行定义。DataContext 可能是新的,也可能是旧的,具体取决于类的逻辑。如果是正在重复使用的上下文,则它拥有之前分配的 LoadOptions。否则,您需要对其进行分配。现在,您可以反复调用此查询,并且仍将获得 LINQ to SQL 的预取功能带来的好处。
将预编译放在检查表的首位
在 LINQ 查询执行的作用域中,查询编译在流程中属于高成本部分。每次向基于 LINQ to SQL 或实体框架的应用程序添加 LINQ 查询逻辑时,您都应考虑预编译查询并重复使用这些查询。但不要以为您可以从此高枕无忧了。如您所见,有些情况下您可能无法享受预编译查询带来的好处。请使用某种类型的探查工具,例如 SQL 事件探查器或来自 Hibernating Rhinos 的探查工具,包括 L2SProf (l2sprof.com/.com) 和 EFProf (efprof.com)。您可能需要利用此处所示的部分模式,以确保获取预编译查询本可提供的好处。
来自 Microsoft 实体框架团队的 Danny Simmons 解释了如何在预编译时控制合并选项,并在其博客文章blogs.msdn.com/dsimmons/archive/2010/01/12/ef-merge-options-and-compiled-queries.aspx 中列出了需引起注意的一些麻烦问题。