为什么Contains()运算符会如此显着地降低Entity Framework的性能?

Mik*_*ike 79 c# sql performance contains entity-framework-4

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

更新2:我已经创建了一个解决此问题的建议.要投票,请到这里.

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

CREATE TABLE Main (Id INT PRIMARY KEY)
Run Code Online (Sandbox Code Playgroud)

我用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)
Run Code Online (Sandbox Code Playgroud)

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

var rows = 
  Main
  .ToArray();
Run Code Online (Sandbox Code Playgroud)

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

var ids = Main.Select(a => a.Id).ToArray();
var rows = 
  Main
  .Where (a => ids.Contains(a.Id))
  .ToArray();
Run Code Online (Sandbox Code Playgroud)

这种情况的执行时间是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,...
Run Code Online (Sandbox Code Playgroud)

结果是

SQL Server Execution Times:
  CPU time = 0 ms,  elapsed time = 88 ms.
Run Code Online (Sandbox Code Playgroud)

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

所以,似乎问题出现在Entity Framework中.

我在这里做错了吗?这是我的代码中一个时间关键部分,所以我可以做些什么来加快性能?

我正在使用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();
Run Code Online (Sandbox Code Playgroud)

它强制EF生成查询而不对数据库执行查询.结果是这段代码需要大约20秒才能运行,所以看起来几乎所有的时间都用于构建初始查询.

CompiledQuery到救援呢?不是那么快...... CompiledQuery要求传递给查询的参数是基本类型(int,string,float等).它不会接受数组或IEnumerable,所以我不能将它用于ID列表.

div*_*ega 65

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

你是对的,大部分时间都花在处理查询的翻译上.EF的提供者模型当前不包含表示IN子句的表达式,因此ADO.NET提供者本身不能支持IN.相反,Enumerable.Contains的实现将它转换为OR表达式树,即对于C#中的某些内容,如下所示:

new []{1, 2, 3, 4}.Contains(i)
Run Code Online (Sandbox Code Playgroud)

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

((1 = @i) OR (2 = @i)) OR ((3 = @i) OR (4 = @i))
Run Code Online (Sandbox Code Playgroud)

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

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

当我们在EF4中添加对Enumerable.Contains的支持时,我们认为这样做是可取的,而不必在提供者模型中引入对IN表达式的支持,老实说,10,000远远超过我们预期客户将传递给的元素数量. Enumerable.Contains.也就是说,我知道这是一个烦恼,表达式树的操作会使你的特定场景中的东西太贵.

我与我们的一位开发人员讨论了这个问题,我们相信将来我们可以通过为IN添加一流的支持来改变实现.我会确保将这个添加到我们的积压工作中,但我不能保证什么时候能够做到这一点,因为我们还希望做出许多其他改进.

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

考虑创建一种方法,该方法可以平衡数据库往返次数与传递给Contains的元素数量.例如,在我自己的测试中,我发现计算和执行SQL Server的本地实例时,使用100个元素的查询需要1/60秒.如果您能够以这样的方式编写查询:使用100个不同的ID集执行100个查询将为您提供具有10,000个元素的查询的等效结果,那么您可以以大约1.67秒而不是18秒的速度获得结果.

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

这是一个代码片段(很抱歉,如果用于将输入切片为块的代码看起来有点过于复杂.有更简单的方法可以实现相同的功能,但我试图想出一个模式来保留序列的流和我在LINQ中找不到类似的东西,所以我可能过分了那个部分:)):

用法:

var list = context.GetMainItems(ids).ToList();
Run Code Online (Sandbox Code Playgroud)

上下文或存储库的方法:

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;
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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);
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

希望这可以帮助!


Lad*_*nka 24

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

如果出现性能问题,请使用变通方法和变通方法,EF表示直接SQL.这没什么不好的.全球的想法,使用EF =不再使用SQL是一个谎言.你有SQL Server 2008 R2所以:

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

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


Dhw*_*hah 9

我们能够通过添加一个中间表并从需要使用Contains子句的LINQ查询连接该表来解决EF Contains问题.通过这种方法我们得到了惊人的结果.我们有一个大型EF模型,并且在预编译EF查询时不允许使用"Contains",对于使用"Contains"子句的查询,我们的性能非常差.

概述:

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

  • HelperForContainsOfIntType在EF模型中为其他此类表创建实体/实体集.根据需要为不同的数据类型创建不同的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 
        );
    
    Run Code Online (Sandbox Code Playgroud)
  • 使用要在Contains子句中使用的值调用helper方法,并Guid在查询中使用它.例如:

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


Jef*_*ata 5

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

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

var rows = db.Database.SqlQuery<Main>(sql).ToArray();
Run Code Online (Sandbox Code Playgroud)

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

我会是第一个说它丑陋的人,并希望有一个更好的解决方案.

更新

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

为了测试这个,我创建了一个Target具有相同模式的表Main.然后,我使用a StringBuilder来创建INSERT命令,以Target1,000个批量填充表,因为这是SQL Server将在单个中接受的最多INSERT.直接执行sql语句要比通过EF(大约0.3秒对2.5秒)快得多,我相信也可以,因为表模式不应该改变.

最后,使用a选择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();
Run Code Online (Sandbox Code Playgroud)

以及EF为连接生成的sql:

SELECT 
[Extent1].[Id] AS [Id]
FROM  [dbo].[Main] AS [Extent1]
INNER JOIN [dbo].[Target] AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id]
Run Code Online (Sandbox Code Playgroud)

(原始答案)

这不是一个答案,但我想分享一些额外的信息,并且它太长,不适合评论.我能够重现您的结果,并添加一些其他内容:

SQL事件探查器显示延迟是在第一个query(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)
Run Code Online (Sandbox Code Playgroud)
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));
        }
    }
}
Run Code Online (Sandbox Code Playgroud)


Shi*_*hiv 5

我不熟悉实体框架,但如果你执行以下操作,性能会更好吗?

而不是这个:

var ids = Main.Select(a => a.Id).ToArray();
var rows = Main.Where (a => ids.Contains(a.Id)).ToArray();
Run Code Online (Sandbox Code Playgroud)

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

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

  • @Shiv我不相信这是正确的.EF将采用任何集合并将其转换为SQL.收集的类型应该是非问题. (2认同)
  • HashSet 不是 IEnumerable。IEnumerables 在 LINQ 中调用 .Contains 表现不佳(至少在 EF6 之前)。 (2认同)