在Entity Framework LINQ查询中使用IEnumerable.Contains时如何避免查询计划重新编译?

t3z*_*t3z 31 linq performance linq-to-entities entity-framework

我使用Entity Framework(v6.1.1)执行以下LINQ查询:

private IList<Customer> GetFullCustomers(IEnumerable<int> customersIds)
{
    IQueryable<Customer> fullCustomerQuery = GetFullQuery();
    return fullCustomerQuery.Where(c => customersIds.Contains(c.Id)).ToList();
}
Run Code Online (Sandbox Code Playgroud)

这个查询被翻译成相当不错的SQL:

SELECT 
[Extent1].[Id] AS [Id], 
[Extent1].[FirstName] AS [FirstName]
-- ...
FROM [dbo].[Customer] AS [Extent1]
WHERE [Extent1].[Id] IN (1, 2, 3, 5)
Run Code Online (Sandbox Code Playgroud)

但是,我在查询编译阶段获得了非常显着的性能影响.呼叫:

ELinqQueryState.GetExecutionPlan(MergeOption? forMergeOption) 
Run Code Online (Sandbox Code Playgroud)

占用每个请求约50%的时间.深入研究,结果是每次我传递不同的customersIds时都会重新编译查询.根据MSDN文章,这是一个正常现象,因为IEnumerable的是在查询中使用被认为是挥发性的,并且是缓存的SQL的一部分.这就是为什么SQL对于customersIds的每个不同组合都是不同的,并且它总是具有用于从缓存中获取编译查询的不同哈希.

现在问题是:如何在仍然查询多个customersIds时避免重新编译?

div*_*ega 24

这是一个很好的问题.首先,这里有一些解决方法(它们都需要更改查询):

第一个解决方法

这个可能有点显而易见,遗憾的是通常不适用:如果您需要传递的项目的选择Enumerable.Contains已经存在于数据库的表中,您可以编写一个查询来调用Enumerable.Contains谓词中的相应实体集而不是首先将项目放入内存.一个Enumerable.Contains数据库在数据调用应导致某种JOIN基于查询的,可以被缓存.例如,假设Customers和SelectedCustomers之间没有导航属性,您应该能够像这样编写查询:

var q = db.Customers.Where(c => 
    db.SelectedCustomers.Select(s => s.Id).Contains(c.Id));
Run Code Online (Sandbox Code Playgroud)

在这种情况下,使用Any的查询语法稍微简单一些:

var q = db.Customers.Where(c => 
    db.SelectedCustomers.Any(s => s.Id == c.Id));
Run Code Online (Sandbox Code Playgroud)

如果您还没有存储在数据库中的必要选择数据,您可能不需要存储它的开销,因此您应该考虑下一个解决方法.

第二种解决方法

如果您事先知道列表中的元素数量相对可管理,则可以Enumerable.Contains使用OR-ed相等比较树替换,例如:

var list = new [] {1,2,3};
var q = db.Customers.Where(c => 
    list[0] == c.Id ||
    list[1] == c.Id ||
    list[2] == c.Id );
Run Code Online (Sandbox Code Playgroud)

这应该生成一个可以缓存的参数化查询.如果列表的大小因查询而异,则应为每个列表大小生成不同的缓存条目.或者,您可以使用具有固定大小的列表,并传递一些您知道永远不会与值参数匹配的sentinel值,例如0,-1.为了在运行时以编程方式生成基于列表的谓词表达式,您可能需要考虑使用类似PredicateBuilder的东西.

潜在的解决方案及其挑战

一方面,在当前版本的EF中,使用CompiledQuery显式支持缓存此类查询所需的更改将非常复杂.关键原因是IEnumerable<T>传递给Enumerable.Contains方法的元素必须转换为我们生成的特定翻译的查询的结构部分,例如:

var list = new [] {1,2,3};
var q = db.Customers.Where(c => list.Contains(c.Id)).ToList();
Run Code Online (Sandbox Code Playgroud)

可枚举的"列表"看起来像C#/ LINQ中的一个简单变量,但它需要转换为这样的查询(为简洁起见,简化):

SELECT * FROM Customers WHERE Id IN(1,2,3)
Run Code Online (Sandbox Code Playgroud)

如果列表更改为new [] {5,4,3,2,1},我们将不得不再次生成SQL查询!

SELECT * FROM Customers WHERE Id IN(5,4,3,2,1)
Run Code Online (Sandbox Code Playgroud)

作为一个潜在的解决方案,我们已经讨论过将生成的SQL查询保留为某种特殊的占位符,例如存储在查询缓存中

SELECT * FROM Customers WHERE Id IN(<place holder>)
Run Code Online (Sandbox Code Playgroud)

在执行时,我们可以从缓存中选择此SQL并使用实际值完成SQL生成.另一种选择是在目标数据库可以支持列表的情况下利用表值参数.第一个选项可能只适用于常量值,后者需要一个支持特殊功能的数据库.在EF中实现这两者都非常复杂.

自动编译的查询

另一方面,对于自动编译查询(与显式CompiledQuery相反),问题变得有点人为:在这种情况下,我们在初始LINQ转换后计算查询缓存键,因此IEnumerable<T>传递的任何参数应该已经扩展到DbExpression节点: EF5中的OR-ed等式比较树,通常是EF6中的单个DbInExpression节点.由于查询树已经为源参数中的每个不同元素组合包含了一个不同的表达式Enumerable.Contains(因此对于每个不同的输出SQL查询),因此可以缓存查询.

但是即使在EF6中,即使在自动编译的查询案例中也不会缓存这些查询.这样做的关键原因是我们期望列表中元素的可变性很高(这与列表的可变大小有关,但是由于我们通常不参数化显示为常量的值这一事实也会加剧这一点.对于查询,所以常量列表将被转换为SQL中的常量文字),因此对Enumerable.Contains您的查询进行足够的调用可能会产生相当大的缓存污染.

我们也考虑了替代解决方案,但我们尚未实施.所以我的结论是,在大多数情况下,如果正如我所说,你知道列表中的元素数量仍然很小且易于管理(否则你将面临性能问题).

希望这可以帮助!

  • 对于不应存在的问题,这是一个很好的答案.由于架构原因,EF可能无法自动缓存已编译的查询.但至少应将列表值作为参数发送到RDBMS.发送的这些数量的不同查询可能会破坏SQL Server中的查询缓存.编译计划可能很大(通常为10-1000KB).如果它有帮助:对于L2S我写了一个自定义查询缓存,我遇到了这个问题.我所做的是每个列表长度*有一个缓存项*.这样,SQL文本可以缓存,并具有可容忍的缓存污染. (7认同)

yv9*_*89c 6

到目前为止,当使用 SQL Server 数据库提供程序时,这仍然是 Entity Framework Core 中的一个问题。

仍在实体框架 6(非核心)上吗?跳到下一节。

我编写了QueryableValues来以灵活且高性能的方式解决这个问题;使用它,您可以组合查询中的值,就像它是您的.IEnumerable<T>DbContext

与其他解决方案相比,QueryableValues通过以下方式实现此级别的性能

  • 通过与数据库的单次往返来解决。
  • 无论提供的值如何,都保留查询的执行计划。

使用示例:

// Sample values.
IEnumerable<int> values = Enumerable.Range(1, 10);

// Using a Join.
var myQuery1 = 
    from e in dbContext.MyEntities
    join v in dbContext.AsQueryableValues(values) on e.Id equals v 
    select new
    {
        e.Id,
        e.Name
    };

// Using Contains.
var myQuery2 = 
    from e in dbContext.MyEntities
    where dbContext.AsQueryableValues(values).Contains(e.Id)
    select new
    {
        e.Id,
        e.Name
    };
Run Code Online (Sandbox Code Playgroud)

您还可以组成复杂的类型!

它以nuget 包的形式提供,并且可以在此处找到该项目。它是根据 MIT 许可证分发的。

基准测试不言自明。


实体框架 6 的替代方案(非核心)

新的! QueryableValuesEF6 Edition已经到来!

我将解释如何在此旧版实体框架上手动提供QueryableValues的一些功能,特别是能够以与QueryableValuesIEnumerable<int>在 EF Core 上相同的方式组合任何实体。您可以使用相同的技术来支持其他简单类型的集合,例如、等。longstring

要求

  • 必须使用 SQL Server 提供程序
  • 必须使用数据库优先策略,或者您已经有办法使用代码优先策略来映射TVF

说明摘要

  1. 创建一个接受IEnumerable<int>并返回 XML 的方法。
  2. 在数据库中创建一个接受 XML 并返回行集的TVF 。
  3. 使用设计器将TVF添加到 EDMX 。
  4. 封装粘合步骤 1 和 2 中创建的函数的代码并返回IQueryable<int>.
  5. IQueryable<int>根据需要在查询中使用。

指示

1. 创建一个接受IEnumerable<int>并返回 XML 的方法

此方法会将提供的值序列化为 XML,因此稍后可以将其作为查询中的参数进行传输。

static string GetXml<T>(IEnumerable<T> values)
{
    var sb = new StringBuilder();

    using (var stringWriter = new System.IO.StringWriter(sb))
    {
        var settings = new System.Xml.XmlWriterSettings
        {
            ConformanceLevel = System.Xml.ConformanceLevel.Fragment
        };

        using (var xmlWriter = System.Xml.XmlWriter.Create(stringWriter, settings))
        {
            xmlWriter.WriteStartElement("R");

            foreach (var value in values)
            {
                xmlWriter.WriteStartElement("V");
                xmlWriter.WriteValue(value);
                xmlWriter.WriteEndElement();
            }

            xmlWriter.WriteEndElement();
        }
    }

    return sb.ToString();
}
Run Code Online (Sandbox Code Playgroud)

如果上面的方法提供了new[] { 1, 2, 3 },它将返回一个具有以下结构的 XML 字符串:

<R><V>1</V><V>2</V><V>3</V></R>
Run Code Online (Sandbox Code Playgroud)

2. 在数据库中创建一个接受 XML 并返回行集的 TVF

下面的表值函数 (TVF)将采用上一个函数创建的 XML,并将其投影为具有单列 ( V) 的行集,然后可以在 SQL Server 端的查询中使用该行集。必须在与您的 EDMX 文件关联的数据库中创建,以便在下一步中将其添加到您的 EDMX 模型中。

CREATE FUNCTION dbo.udf_GetIntValuesFromXml
(
    @Values XML
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
(
    SELECT I.value('. cast as xs:integer?', 'int') AS V
    FROM @Values.nodes('/R/V') N(I)
)
Run Code Online (Sandbox Code Playgroud)

当提供 XML 时<R><V>1</V><V>2</V><V>3</V></R>,上述函数将返回以下行集:

V
1
2
3

3. 使用设计器将 TVF 添加到 EDMX

更新向导

表值函数 (TVF) - EF 文档

将此函数添加到 EDMX 模型后,请确保保存对 EDMX 文件的更改,以便DbContext生成的代码是最新的。

4. 封装粘合步骤 1 和 2 中创建的函数的代码并返回IQueryable<int>

以下代码封装了上面解释的 XML 序列化器函数以及 .NET 端完成此工作所需的所有其他内容:

using System.Collections.Generic;
using System.Linq;

public static class QueryableValuesClassicDbContextExtensions
{
    private static string GetXml<T>(IEnumerable<T> values)
    {
        var sb = new StringBuilder();

        using (var stringWriter = new System.IO.StringWriter(sb))
        {
            var settings = new System.Xml.XmlWriterSettings
            {
                ConformanceLevel = System.Xml.ConformanceLevel.Fragment
            };

            using (var xmlWriter = System.Xml.XmlWriter.Create(stringWriter, settings))
            {
                xmlWriter.WriteStartElement("R");

                foreach (var value in values)
                {
                    xmlWriter.WriteStartElement("V");
                    xmlWriter.WriteValue(value);
                    xmlWriter.WriteEndElement();
                }

                xmlWriter.WriteEndElement();
            }
        }

        return sb.ToString();
    }

    public static IQueryable<int> AsQueryableValues(this IQueryableValuesClassicDbContext dbContext, IEnumerable<int> values)
    {
        return dbContext.GetIntValuesFromXml(GetXml(values));
    }
}

public interface IQueryableValuesClassicDbContext
{
    IQueryable<int> GetIntValuesFromXml(string xml);
}
Run Code Online (Sandbox Code Playgroud)

IQueryableValuesClassicDbContext接口旨在在您的类上显式DbContext实现,以提供对添加到 EDMX 模型的TVF 的访问。

您可以通过为您的DbContext. 例如,如果您的DbContext名字是TestDbContext

using System.Linq;

partial class TestDbContext : IQueryableValuesClassicDbContext
{
    IQueryable<int> IQueryableValuesClassicDbContext.GetIntValuesFromXml(string xml)
    {
        return udf_GetIntValuesFromXml(xml).Select(i => i.Value);
    }
}
Run Code Online (Sandbox Code Playgroud)

IQueryable<int>5.根据需要在查询中使用(via AsQueryableValues)

using (var db = new TestDbContext())
{
    var valuesQuery = db.AsQueryableValues(new[] { 1, 2, 3, 4, 5 });
                
    var resultsUsingContains = db.MyEntity
        .Where(i => valuesQuery.Contains(i.MyEntityID))
        .Select(i => new { i.MyEntityID, i.PropA })
        .ToList();

    var resultsUsingJoin = (
        from i in db.MyEntity
        join v in valuesQuery on i.MyEntityID equals v
        select new { i.MyEntityID, i.PropA }
        )
        .ToList();
}
Run Code Online (Sandbox Code Playgroud)

下面是在幕后为上述 EF 查询生成的 T-SQL。正如您所看到的,它是完全参数化的。

exec sp_executesql N'SELECT 
    [Extent1].[MyEntityID] AS [MyEntityID], 
    [Extent1].[PropA] AS [PropA]
    FROM [dbo].[MyEntity] AS [Extent1]
    WHERE  EXISTS (SELECT 
        1 AS [C1]
        FROM [dbo].[udf_GetIntValuesFromXml](@Values) AS [Extent2]
        WHERE ([Extent2].[V] = [Extent1].[MyEntityID]) AND ([Extent2].[V] IS NOT NULL)
    )',N'@Values nvarchar(4000)',@Values=N'<R><V>1</V><V>2</V><V>3</V><V>4</V><V>5</V></R>'

exec sp_executesql N'SELECT 
    [Extent1].[MyEntityID] AS [MyEntityID], 
    [Extent1].[PropA] AS [PropA]
    FROM  [dbo].[MyEntity] AS [Extent1]
    INNER JOIN [dbo].[udf_GetIntValuesFromXml](@Values) AS [Extent2] ON [Extent1].[MyEntityID] = [Extent2].[V]',N'@Values nvarchar(4000)',@Values=N'<R><V>1</V><V>2</V><V>3</V><V>4</V><V>5</V></R>'
Run Code Online (Sandbox Code Playgroud)

局限性

  • 提供的内容IEnumerable<int>是在查询构建时而不是执行时枚举的。
  • IQueryable<T>最终查询不能引用扩展方法返回的多个查询AsQueryableValues。这是多次编写相同TVF的另一个限制。EF 会创建两个同名的参数,这是非法的,你会得到以下错误:

    参数集合中已存在名为“Values”的参数。参数名称在参数集合中必须是唯一的。

  • TVF的 XML 类型参数使用的类型不正确(请注意上面 T-SQL 中使用 代替nvarchar) 。这是用于组成TVF的 EF 基础结构 ( ObjectParameterxml )的缺陷。由于 SQL Server 必须执行隐式转换,因此不使用正确的参数类型会对性能产生不利影响。

结论

尽管存在限制,但与不使用参数化 T-SQL 查询相比,这仍然是一个强大的解决方案。要了解这缓解的根本问题,您可以继续阅读此处

法律事务

请随意使用上面的代码和示例。我根据 MIT 许可证发布它:

麻省理工学院许可证

版权所有 (c) 卡洛斯·维勒加斯 ( yv989c )

特此免费授予获得本软件和相关文档文件(“软件”)副本的任何人不受限制地使用本软件,包括但不限于使用、复制、修改、合并的权利、发布、分发、再许可和/或销售软件的副本,并允许向其提供软件的人员这样做,但须满足以下条件:

上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。

本软件按“原样”提供,不提供任何明示或暗示的保证,包括但不限于适销性、特定用途的适用性和不侵权的保证。在任何情况下,作者或版权持有者均不对因本软件或本软件中的使用或其他交易而产生或与之相关的任何索赔、损害或其他责任负责,无论是合同、侵权行为还是其他行为。软件。