为什么Contains()运算符会显着降低entity framework的性能?

更新3:根据这个公告 ,EF小组已经在EF6 alpha 2中解决了这个问题。

更新2:我已经创build了一个解决这个问题的build议。 为了投票, 去这里 。

考虑一个具有一个非常简单的表的SQL数据库。

CREATE TABLE Main (Id INT PRIMARY KEY) 

我用10,000条logging填充表格。

 WITH Numbers AS ( SELECT 1 AS Id UNION ALL SELECT Id + 1 AS Id FROM Numbers WHERE Id <= 10000 ) INSERT Main (Id) SELECT Id FROM Numbers OPTION (MAXRECURSION 0) 

我为表build立一个EF模型,并在LINQPad中运行以下查询(我使用“C#语句”模式,因此LINQPad不会自动创build一个转储)。

 var rows = Main .ToArray(); 

执行时间是〜0.07秒。 现在我添加包含运算符并重新运行查询。

 var ids = Main.Select(a => a.Id).ToArray(); var rows = Main .Where (a => ids.Contains(a.Id)) .ToArray(); 

这种情况下的执行时间是20.14秒 (慢288倍)!

起初,我怀疑为查询发出的T-SQL执行时间较长,所以我尝试将其从LINQPad的SQL窗格粘贴到SQL Server Management Studio中。

 SET NOCOUNT ON SET STATISTICS TIME ON SELECT [Extent1].[Id] AS [Id] FROM [dbo].[Primary] AS [Extent1] WHERE [Extent1].[Id] IN (1,2,3,4,5,6,7,8,... 

结果是

 SQL Server Execution Times: CPU time = 0 ms, elapsed time = 88 ms. 

接下来我怀疑LINQPad导致了这个问题,但是无论我在LINQPad还是在控制台应用程序中运行它,性能都是一样的。

所以,这个问题似乎是在entity framework内的某个地方。

我在这里做错了什么? 这是我的代码中时间关键的部分,那么我能做些什么来加速性能?

我正在使用Entity Framework 4.1和Sql Server 2008 R2。

更新1:

在下面的讨论中,有些问题是EF在构build初始查询时,还是在parsing接收到的数据时,是否发生延迟。 为了testing这个,我运行了下面的代码,

 var ids = Main.Select(a => a.Id).ToArray(); var rows = (ObjectQuery<MainRow>) Main .Where (a => ids.Contains(a.Id)); var sql = rows.ToTraceString(); 

这迫使EF生成查询而不对数据库执行查询。 结果是这个代码需要运行20个secords,所以看起来几乎所有的时间都是在构build初始查询的时候。

编译查询到救援呢? 不太快… CompiledQuery要求传递到查询中的参数是基本types(int,string,float等)。 它不会接受数组或IEnumerable,所以我不能使用它作为ID列表。

更新:EF6包含对Enumerable.Contains戏剧性的改进。

你说得对,大部分时间都花在处理查询的翻译上。 EF的提供者模型当前不包含表示IN子句的expression式,因此ADO.NET提供者本身不支持IN。 相反,Enumerable.Contains的实现将其转换为ORexpression式的树,即在C#中看起来像这样的东西:

 new []{1, 2, 3, 4}.Contains(i) 

…我们将生成一个DbExpression树,可以像这样表示:

 ((1 = @i) OR (2 = @i)) OR ((3 = @i) OR (4 = @i)) 

(expression式树必须是平衡的,因为如果我们把所有的OR都放在一个单独的长脊上,expression式访问者会碰到堆栈溢出(是的,我们在testing中确实碰到了)

我们稍后将这样一棵树发送到ADO.NET提供程序,它可以在SQL生成过程中识别此模式并将其减less到IN子句。

当我们在EF4中添加对Enumerable.Contains的支持时,我们认为不需要在提供者模型中引入对INexpression式的支持就可以了,实际上,10,000远远超过了我们预期的客户将传递给Enumerable.Contains。 这就是说,我知道这是一个烦恼,expression式树的操纵使得在特定场景中的东西太贵了。

我与我们的一位开发人员讨论过这个问题,我们相信在将来我们可以通过添加对IN的一stream支持来改变实现。 我会确定这是添加到我们的积压,但我不能保证什么时候会使它,因为还有许多其他的改进,我们想做的。

对于线程中已经build议的解决方法,我将添加以下内容:

考虑创build一个平衡数据库往返数量与传递给Contains的元素数量的方法。 例如,在我自己的testing中,我发现计算和执行SQL Server的本地实例时,具有100个元素的查询需要1/60秒。 如果你可以用这样一种方式编写你的查询,即用100个不同的id集合执行100个查询会给你带有10000个元素的查询的等效结果,那么你可以得到大约1.67秒而不是18秒的结果。

根据查询和数据库连接的延迟,不同的块大小应该更好。 对于某些查询,即如果传递的序列具有重复或Enumerable.Contains用于嵌套条件,则可能会在结果中获得重复的元素。

这是一个代码片段(抱歉,如果代码用于切片input块看起来有点太复杂了。有更简单的方法来实现相同的事情,但我试图想出一个模式,保留stream的序列和我在LINQ中找不到像这样的东西,所以我可能超过了那部分:)):

用法:

 var list = context.GetMainItems(ids).ToList(); 

上下文或存储库的方法:

 public partial class ContainsTestEntities { public IEnumerable<Main> GetMainItems(IEnumerable<int> ids, int chunkSize = 100) { foreach (var chunk in ids.Chunk(chunkSize)) { var q = this.MainItems.Where(a => chunk.Contains(a.Id)); foreach (var item in q) { yield return item; } } } } 

切片可枚举序列的扩展方法:

 public static class EnumerableSlicing { private class Status { public bool EndOfSequence; } private static IEnumerable<T> TakeOnEnumerator<T>(IEnumerator<T> enumerator, int count, Status status) { while (--count > 0 && (enumerator.MoveNext() || !(status.EndOfSequence = true))) { yield return enumerator.Current; } } public static IEnumerable<IEnumerable<T>> Chunk<T>(this IEnumerable<T> items, int chunkSize) { if (chunkSize < 1) { throw new ArgumentException("Chunks should not be smaller than 1 element"); } var status = new Status { EndOfSequence = false }; using (var enumerator = items.GetEnumerator()) { while (!status.EndOfSequence) { yield return TakeOnEnumerator(enumerator, chunkSize, status); } } } } 

希望这可以帮助!

如果你发现一个阻碍你的性能问题,不要花费时间来解决问题,因为你很可能不会成功,你将不得不直接与MS沟通(如果你有高级支持),它需要年龄。

在性能问题的情况下使用解决方法和解决方法,EF意味着直接的SQL。 这没什么不好的。 使用EF =不再使用SQL的全局思想是一个谎言。 你有SQL Server 2008 R2,所以:

  • 创build接受表值参数的存储过程来传递你的ID
  • 让您的存储过程返回多个结果集,以最佳方式模拟Include逻辑
  • 如果你需要一些复杂的查询构build在存储过程中使用dynamicSQL
  • 使用SqlDataReader来获得结果并构造你的实体
  • 将它们附加到上下文并使用它们,就好像它们是从EF加载的一样

如果性能对您至关重要,您将找不到更好的解决scheme。 此过程不能由EF映射和执行,因为当前版本不支持表值参数或多个结果集。

我们能够通过添加一个中间表并joinLINQ查询中需要使用Contains子句的表来解决EF Contains问题。 用这种方法我们可以得到惊人的结果。 我们有一个很大的EF模型,因为预编译EF查询时不允许使用“Contains”,所以对于使用“Contains”子句的查询来说性能很差。

概述:

  • 在SQL Server中创build表 – 例如HelperForContainsOfIntTypeHelperID Guid数据types的HelperIDint数据types列的ReferenceID 。 根据需要创build具有不同数据types的ReferenceID的不同表格。

  • 在EF模型中为HelperForContainsOfIntType和其他类似的表创build一个Entity / EntitySet。 根据需要为不同的数据types创build不同的实体/实体集。

  • 在.NET代码中创build一个帮助器方法,它接受一个IEnumerable<int>的input并返回一个Guid 。 此方法生成一个新的Guid ,并将IEnumerable<int>的值与生成的Guid一起插入到HelperForContainsOfIntType 。 接下来,该方法将这个新生成的Guid返回给调用者。 为了快速插入到HelperForContainsOfIntType表中,创build一个存储过程,该过程需要input值列表并进行插入。 请参阅SQL Server 2008(ADO.NET)中的表值参数 。 为不同的数据types创build不同的帮助器,或创build一个通用的帮助器方法来处理不同的数据types。

  • 创build一个类似于如下所示的EF编译查询:

     static Func<MyEntities, Guid, IEnumerable<Customer>> _selectCustomers = CompiledQuery.Compile( (MyEntities db, Guid containsHelperID) => from cust in db.Customers join x in db.HelperForContainsOfIntType on cust.CustomerID equals x.ReferenceID where x.HelperID == containsHelperID select cust ); 
  • 使用Contains子句中要使用的值调用helper方法,并获取Guid以在查询中使用。 例如:

     var containsHelperID = dbHelper.InsertIntoHelperForContainsOfIntType(new int[] { 1, 2, 3 }); var result = _selectCustomers(_dbContext, containsHelperID).ToList(); 

编辑我原来的答案 – 有一个可能的解决方法,取决于实体的复杂性。 如果您知道EF生成的用于填充实体的SQL,则可以使用DbContext.Database.SqlQuery直接执行它。 在EF 4中,我认为你可以使用ObjectContext.ExecuteStoreQuery ,但我没有尝试。

例如,使用下面的原始答案中的代码来使用StringBuilder生成sql语句,我可以执行以下操作

 var rows = db.Database.SqlQuery<Main>(sql).ToArray(); 

总时间从大约26秒到0.5秒。

我会第一个说这是丑陋的,希望能有一个更好的解决scheme。

更新

经过一番思考之后,我意识到,如果您使用连接来过滤结果,则EF不必构build那么长的ID列表。 这可能是复杂的,取决于并发查询的数量,但我相信你可以使用用户ID或会话ID来隔离它们。

为了testing这个,我使用与Main相同的模式创build了一个Target表。 然后,我使用了一个StringBuilder来创buildINSERT命令,以批量填充1,000个Target表,因为这是大多数SQL Server在一个INSERT接受的。 直接执行sql语句比通过EF(大约0.3秒比2.5秒)要快得多,我相信会好的,因为表格模式不应该改变。

最后,使用joinselect会导致更简单的查询,并在0.5秒内执行。

 ExecuteStoreCommand("DELETE Target"); var ids = Main.Select(a => a.Id).ToArray(); var sb = new StringBuilder(); for (int i = 0; i < 10; i++) { sb.Append("INSERT INTO Target(Id) VALUES ("); for (int j = 1; j <= 1000; j++) { if (j > 1) { sb.Append(",("); } sb.Append(i * 1000 + j); sb.Append(")"); } ExecuteStoreCommand(sb.ToString()); sb.Clear(); } var rows = (from m in Main join t in Target on m.Id equals t.Id select m).ToArray(); rows.Length.Dump(); 

而EF为连接生成的sql:

 SELECT [Extent1].[Id] AS [Id] FROM [dbo].[Main] AS [Extent1] INNER JOIN [dbo].[Target] AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id] 

(原始答案)

这不是一个答案,但我想分享一些额外的信息,这是太长,以适应评论。 我能够重现您的结果,并添加一些其他的东西:

SQL事件探查器显示执行第一个查询( Main.Select )和第二个Main.Where查询之间的延迟,所以我怀疑问题是在生成和发送该大小(48,980字节)的查询。

但是,在T-SQL中构build相同的sql语句dynamic地花费不到1秒钟,并从Main.Select语句中获取ids ,构build相同的sql语句并使用SqlCommand执行它花费了0.112秒,这包括写入时间内容到控制台。

在这一点上,我怀疑EF正在为构build查询的10,000个ids每一个进行一些分析/处理。 希望我能提供一个明确的答案和解决scheme:(。

这里是我在SSMS和LINQPad上试用的代码(请不要太苛刻的批评,我急于离开工作):

 declare @sql nvarchar(max) set @sql = 'SELECT [Extent1].[Id] AS [Id] FROM [dbo].[Main] AS [Extent1] WHERE [Extent1].[Id] IN (' declare @count int = 0 while @count < 10000 begin if @count > 0 set @sql = @sql + ',' set @count = @count + 1 set @sql = @sql + cast(@count as nvarchar) end set @sql = @sql + ')' exec(@sql) 

 var ids = Mains.Select(a => a.Id).ToArray(); var sb = new StringBuilder(); sb.Append("SELECT [Extent1].[Id] AS [Id] FROM [dbo].[Main] AS [Extent1] WHERE [Extent1].[Id] IN ("); for(int i = 0; i < ids.Length; i++) { if (i > 0) sb.Append(","); sb.Append(ids[i].ToString()); } sb.Append(")"); using (SqlConnection connection = new SqlConnection("server = localhost;database = Test;integrated security = true")) using (SqlCommand command = connection.CreateCommand()) { command.CommandText = sb.ToString(); connection.Open(); using(SqlDataReader reader = command.ExecuteReader()) { while(reader.Read()) { Console.WriteLine(reader.GetInt32(0)); } } } 

我不熟悉Entity Framework,但是如果你做以下的事情,性能会更好吗?

而不是这个:

 var ids = Main.Select(a => a.Id).ToArray(); var rows = Main.Where (a => ids.Contains(a.Id)).ToArray(); 

怎么样(假设ID是一个i​​nt):

 var ids = new HashSet<int>(Main.Select(a => a.Id)); var rows = Main.Where (a => ids.Contains(a.Id)).ToArray(); 

它固定在Entity Framework 6 Alpha 2上: http : //entityframework.codeplex.com/SourceControl/changeset/a7b70f69e551

http://blogs.msdn.com/b/adonet/archive/2012/12/10/ef6-alpha-2-available-on-nuget.aspx

一个可caching的替代包含?

这只是我咬我,所以我已经添加了我的两个便士到entity frameworkfunctionbuild议链接。

这个问题肯定是在生成SQL时。 我有一个客户谁是数据的查询一代是4秒,但执行是0.1秒。

我注意到,当使用dynamic的LINQ和ORs时,sql生成时间一样长,但是它产生了可以被caching的东西。 所以当再次执行它是下降到0.2秒。

请注意,SQL中仍然生成。

只是别的要考虑,如果你能忍受最初的命中,你的数组并没有太大的改变,并且运行查询了很多。 (在LINQ Pad中testing)

问题在于entity framework的SQL生成。 如果其中一个参数是一个列表,它不能caching查询。

要获得EFcaching您的查询,您可以将您的列表转换为一个string,并对string做一个.Contains。

因此,例如,这个代码将运行得更快,因为EF可以caching查询:

 var ids = Main.Select(a => a.Id).ToArray(); var idsString = "|" + String.Join("|", ids) + "|"; var rows = Main .Where (a => idsString.Contains("|" + a.Id + "|")) .ToArray(); 

当这个查询生成时,它可能会使用一个Like而不是一个In生成,所以它会加快你的C#,但它可能会减慢你的SQL。 在我的情况下,我没有注意到我的SQL执行性能下降,并且C#跑得更快。