为什么Contains()运算符会如此大幅度降低Entity Framework的性能?


79

更新3:根据此公告,EF团队已在EF6 alpha 2中解决了此问题。

更新2:我已经提出了解决此问题的建议。要投票,请转到此处

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

CREATE TABLE Main (Id INT PRIMARY KEY)

我用10,000条记录填充表。

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)

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

var rows = 
  Main
  .ToArray();

执行时间约为0.07秒。现在,我添加了Contains运算符并重新运行查询。

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 4.1和Sql Server 2008 R2。

更新1:

在下面的讨论中,存在一些有关是否在EF构建初始查询时或在解析回接收到的数据时发生延迟的问题。为了对此进行测试,我运行了以下代码,

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秒的时间才能运行,因此似乎几乎所有时间都花在了构建初始查询上。

然后把CompiledQuery救出来?并不是那么快... CompiledQuery要求传递到查询中的参数是基本类型(int,string,float等)。它不接受数组或IEnumerable,因此我不能将其用于ID列表。


1
您是否尝试var qry = Main.Where (a => ids.Contains(a.Id)); var rows = qry.ToArray();查看查询的哪部分花费时间?
安德鲁·库珀

不是EF使您的查询降级,而是您尝试运行的实际查询。你能解释一下你想做什么吗?也许有一种更好的方法可以满足您的需求
Kris Ivanov

@AndrewCooper我刚刚尝试过,由于延迟执行,第一条语句(没有ToArray)几乎立即执行。在执行ToArray()之前,包括包含筛选在内的查询实际上不会运行。
迈克

5
请对此进行更新:EF6 alpha 2包含一项改进,可加速Enumerable.Contains的翻译。请参阅此处的公告:blogs.msdn.com/b/adonet/archive/2012/12/10/…。我自己的测试表明,现在对具有100,000个int元素的列表转换list.Contains(x)只需不到一秒钟的时间,并且时间随列表中元素的数量线性增长。感谢您的反馈,并帮助我们改善EF!
divega

1
请注意...无法缓存具有任何IEnumerable参数的查询,当您的查询计划复杂时,这可能会导致非常严重的副作用。如果必须多次运行操作(例如,使用Contains获取数据块),则可能会有一些讨厌的查询重新编译时间!自己检查源,您会看到parent._recompileRequired = () => true;所有包含IEnumerable <T>参数的查询都会发生这种情况。!
jocull

Answers:


66

更新:通过在EF6中添加InExpression,处理Enumerable.Contains的性能得到了显着提高。不再需要此答案中描述的方法。

没错,大多数时间都花在处理查询的翻译上。EF的提供程序模型当前不包含表示IN子句的表达式,因此ADO.NET提供程序无法原生支持IN。取而代之的是,Enumerable.Contains的实现将其转换为OR表达式的树,即对于C#中看起来像这样的东西:

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

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

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

(必须平衡表达式树,因为如果我们在单个长脊柱上拥有所有OR,那么表达式访问者将有更多机会遇到堆栈溢出(是的,我们实际上在测试中确实达到了该值))

稍后我们将这样的树发送给ADO.NET提供程序,该提供程序可以识别这种模式并将其缩减为SQL生成期间的IN子句。

当我们增加对EF4中包含的Enumerable.Contains的支持时,我们认为这样做是理想的,而不必在提供程序模型中引入对IN表达式的支持,说实话,10,000远远超过了我们预期客户将传递给的元素数量可枚举。就是说,我知道这很烦人,并且在特定情况下对表达式树的操作使事情变得过于昂贵。

我与一位开发人员讨论了此问题,我们相信将来我们可以通过为IN添加一流的支持来更改实现。我将确保将其添加到我们的待办事项列表中,但是鉴于我们还有许多其他方面的改进,因此我无法保证它会在何时实现。

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

考虑创建一种方法来平衡数据库往返次数与传递给Contains的元素数量之间的平衡。例如,在我自己的测试中,我观察到针对SQL Server的本地实例进行计算和执行时,具有100个元素的查询花费的时间为1/60秒。如果您以这样的方式编写查询,即使用100个不同的ID集执行100个查询将为您提供与10,000个元素相同的查询结果,那么您可以在大约1.67秒而不是18秒的时间内得到结果。

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

这是一个代码片段(很抱歉,如果用于将输入切成块的代码看起来太复杂了。实现相同功能的方法比较简单,但是我试图提出一种模式,该模式可以保留序列和我在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);
            }
        }
    }
}

希望这可以帮助!


为了解释!(status.EndOfSequence = true)在TakeOnEnumerator <T>方法:所以这个表达式分配的副作用将永远是真实的,从而不影响整体表现!本质上stats.EndOfSequencetrue仅当有剩余要提取的项目时才将其标记为,但是您已经达到了枚举的末尾。
arviman 2014年

Enumerable.Contains与早期版本的EF相比,也许EF 6中的处理性能有了显着提高。但是,不幸的是,在我们的用例中,它还远未达到令人满意的/可用于生产的状态。
Nik

24

如果发现阻碍您的性能问题,请不要花很多时间来解决它,因为您很可能不会成功,并且您必须直接与MS交流(如果您有高级支持),这会花费很多时间。年龄。

如果存在性能问题,请使用变通办法和变通办法,并且EF表示直接SQL。没有什么不好的。使用EF =不再使用SQL的全球想法是一个谎言。您拥有SQL Server 2008 R2,因此:

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

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


@Laddislav Mrnka由于list.Contains(),我们遇到了类似的性能问题。我们将尝试通过传递ID创建过程。如果通过EF运行此过程,是否会对性能造成任何影响?
库鲁巴兰2015年

9

通过添加中间表并从需要使用Contains子句的LINQ查询中联接该表,我们能够解决EF Contains问题。通过这种方法,我们能够获得惊人的结果。我们拥有大型的EF模型,并且由于在预编译EF查询时不允许使用“包含”,因此对于使用“包含”子句的查询而言,性能会变得很差。

概述:

  • 例如-在SQL Server中创建一个表HelperForContainsOfIntType具有HelperIDGuid数据类型和ReferenceIDint数据类型的列。根据需要使用具有不同数据类型的ReferenceID创建不同的表。

  • HelperForContainsOfIntTypeEF模型中的表以及其他此类表创建Entity / EntitySet 。根据需要为不同的数据类型创建不同的Entity / EntitySet。

  • 在.NET代码中创建一个辅助方法,该方法接受输入IEnumerable<int>并返回Guid。此方法生成一个新的Guid并插入从值IEnumerable<int>HelperForContainsOfIntType与所产生的沿Guid。接下来,该方法将此新生成的返回Guid给调用方。为了快速插入HelperForContainsOfIntType表中,请创建一个存储过程,该过程将输入值列表并进行插入。请参见SQL Server 2008(ADO.NET)中的表值参数。为不同的数据类型创建不同的帮助程序,或创建通用的帮助程序方法以处理不同的数据类型。

  • 创建一个类似于以下内容的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();
    

谢谢你!我使用了多种解决方案来解决我的问题。
迈克

5

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

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

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

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

我将是第一个说它很丑陋的人,希望能提出一个更好的解决方案。

更新

经过一番思考,我意识到,如果您使用联接来过滤结果,则EF不必构建冗长的id列表。根据并发查询的数量,这可能很复杂,但是我相信您可以使用用户ID或会话ID来隔离它们。

为了测试这一点,我创建了一个Target具有与相同模式的表Main。然后,我使用aStringBuilder创建INSERT命令以Target批量方式填充表,因为这是单个SQL Server接受的最多SQL Server,数量为1,000 INSERT。直接执行sql语句要比通过EF快得多(大约0.3秒vs. 2.5秒),而且我相信这是可以的,因为表架构不应更改。

最后,使用join结果进行选择会导致查询简单得多,并且执行时间不到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 Profiler显示延迟在执行第一个查询(Main.Select)和第二个Main.Where查询之间,因此我怀疑问题出在生成和发送该大小(48,980字节)的查询。

但是,在T-SQL中动态地构建相同的sql语句要花费不到1秒的时间,然后ids从您的Main.Select语句中获取,构建相同的sql语句并用SqlCommand0.112秒的时间执行它,这包括将内容写入控制台的时间。 。

在这一点上,我怀疑EF在ids构建查询时正在对10,000个中的每个做一些分析/处理。希望我能提供一个明确的答案和解决方案:(。

这是我在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));
        }
    }
}

感谢您为此所做的工作。知道您能够重现它会使我感觉更好-至少我没有疯!不幸的是,您的解决方法对我的情况并没有真正帮助,因为您可能会猜到,我在此处给出的示例已尽可能简化,以找出问题所在。我的实际查询涉及一个相当复杂的模式,其他几个表上的.include()以及其他一些LINQ运算符。
迈克,

@Mike,我添加了另一个适用于复杂实体的想法。如果没有其他选择,希望实现起来不会太困难。
杰夫·绪方

我做了一些测试,我认为您是对的,延迟是在执行SQL之前创建SQL。我已经用详细信息更新了我的问题。
迈克,

@Mike,您能够尝试加入ID(请参阅我的答案中的更新)吗?
杰夫·绪方

我最终采用了多种方法来解决性能问题。最终它看起来很丑陋,但这可能是Microsoft解决此问题之前的最佳选择。
迈克,

5

我对Entity Framework不熟悉,但是如果执行以下操作,性能会更好吗?

代替这个:

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

怎么样(假设ID是一个整数):

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

我不知道为什么和怎么做,但是它却像魅力一样起作用:)非常感谢:)
Wahid Bitar 2014年

1
为什么性能更好的原因是int []。第一个调用中的contains调用是O(n)-可能是全数组扫描-而HashSet <int> .Contains调用是O(1)。有关哈希集性能,请参见stackoverflow.com/questions/9812020/…
2014年

3
@Shiv我不认为那是正确的。EF将采用任何集合并将其转换为SQL。收集类型应该是非问题。
罗布

@Rob我很怀疑-如果是这种情况,不知为何要解释性能差异。可能必须分析二进制文件才能查看其功能。
2015年

1
HashSet不是IEnumerable。IEnumerables调用LINQ。载有表现不佳(至少前EF6)
贾森·贝克


2

可替代包含的内容吗?

这只是给我带来一点麻烦,因此我在“实体框架功能建议”链接中添加了两便士。

绝对是在生成SQL时出现问题。我有一个关于谁的数据的客户端,查询生成是4秒,但是执行是0.1秒。

我注意到,当使用动态LINQ和OR时,SQL生成花费了同样的时间,但是它生成了可以缓存的内容。因此,当再次执行它时,它减少到0.2秒。

请注意,仍然会生成SQL in。

如果您可以忍受最初的命中,数组计数不会有太大变化,并且需要大量运行查询,这只是需要考虑的其他事情。(在LINQ Pad中测试)


还选它在CodePlex上网站< entityframework.codeplex.com/workitem/245 >
戴夫·

2

问题出在实体框架的SQL生成上。如果参数之一是列表,它将无法缓存查询。

为了让EF缓存查询,您可以将列表转换为字符串,并对字符串进行.Contains。

因此,例如,由于EF可以缓存查询,因此该代码可以运行得更快:

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#的运行速度明显加快。


1
不错的主意,但这将不使用所讨论列上的任何索引。
支出者

是的,这是事实,这就是为什么我提到它可能会减慢SQL执行的原因。我想如果您不能使用谓词生成器并且您正在使用足够小的数据集,那么您可以负担不起使用索引的情况,这只是一个潜在的选择。我还假设我应该提到谓词生成器是首选选项
user2704238

1
多么惊人的解决方案。我们设法将生产查询的运行时间从〜12,600毫秒提高到了〜18毫秒。这是巨大的改进。非常感谢你 !!!
雅各布
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.