小编典典

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

c#

更新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列表。


阅读 898

收藏
2020-05-19

共1个答案

小编典典

更新:通过在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);
            }
        }
    }
}

希望这可以帮助!

2020-05-19