针对 SQL 后端的 LINQ 的可扩展包含方法

Posted

技术标签:

【中文标题】针对 SQL 后端的 LINQ 的可扩展包含方法【英文标题】:Scalable Contains method for LINQ against a SQL backend 【发布时间】:2014-08-23 10:15:15 【问题描述】:

我正在寻找一种优雅的方式来以可扩展的方式执行 Contains() 语句。在我提出实际问题之前,请允许我提供一些背景知识。

IN 声明

在 Entity Framework 和 LINQ to SQL 中,Contains 语句被转换为 SQL IN 语句。例如,从这句话:

var ids = Enumerable.Range(1,10);
var courses = Courses.Where(c => ids.Contains(c.CourseID)).ToList();

实体框架会生成

SELECT 
    [Extent1].[CourseID] AS [CourseID], 
    [Extent1].[Title] AS [Title], 
    [Extent1].[Credits] AS [Credits], 
    [Extent1].[DepartmentID] AS [DepartmentID]
    FROM [dbo].[Course] AS [Extent1]
    WHERE [Extent1].[CourseID] IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

很遗憾,In 语句不可扩展。根据MSDN:

在 IN 子句中包含大量值(数千个)会消耗资源并返回错误 8623 或 8632

这与资源耗尽或超出表达式限制有关。

但是这些错误发生之前,IN 语句会随着项目数量的增加而变得越来越慢。我找不到有关其增长率的文档,但它在多达几千个项目的情况下表现良好,但除此之外它变得非常缓慢。 (基于 SQL Server 经验)。

可扩展

我们不能总是避免这种说法。使用源数据的JOIN 通常会执行得更好,但这只有在源数据位于相同上下文中时才有可能。在这里,我正在处理在断开连接的情况下来自客户端的数据。所以我一直在寻找一个可扩展的解决方案。结果证明,一种令人满意的方法是将操作切成块:

var courses = ids.ToChunks(1000)
                 .Select(chunk => Courses.Where(c => chunk.Contains(c.CourseID)))
                 .SelectMany(x => x).ToList();

(其中ToChunks 是this 小扩展方法)。

这会以 1000 个块执行查询,这些块都执行得很好。与例如5000 个项目,5 个查询一起运行可能比一个 5000 个项目的查询要快。

但不干燥

但我当然不想在我的代码中分散这个结构。我正在寻找一种扩展方法,通过该方法可以将任何IQueryable<T> 转换为粗大的执行语句。理想情况下是这样的:

var courses = Courses.Where(c => ids.Contains(c.CourseID))
              .AsChunky(1000)
              .ToList();

但也许这个

var courses = Courses.ChunkyContains(c => c.CourseID, ids, 1000)
              .ToList();

我已经先尝试了后一种解决方案:

public static IEnumerable<TEntity> ChunkyContains<TEntity, TContains>(
    this IQueryable<TEntity> query, 
    Expression<Func<TEntity,TContains>> match, 
    IEnumerable<TContains> containList, 
    int chunkSize = 500)

    return containList.ToChunks(chunkSize)
               .Select (chunk => query.Where(x => chunk.Contains(match)))
               .SelectMany(x => x);

显然,x =&gt; chunk.Contains(match) 部分无法编译。但我不知道如何将match 表达式操作为Contains 表达式。

也许有人可以帮我解决这个问题。当然,我愿意接受其他方法来使这种说法具有可扩展性。

【问题讨论】:

我遇到了同样的问题。如何使您的初始解决方案 (ToChunks) 异步运行? EF-core 用户:考虑使用this answer,而不是最高票数。 【参考方案1】:

一个月前,我用一种稍微不同的方法解决了这个问题。也许这对您来说也是一个很好的解决方案。

我不希望我的解决方案更改查询本身。所以 ids.ChunkContains(p.Id) 或特殊的 WhereContains 方法是不可行的。解决方案还应该能够将 Contains 与另一个过滤器结合起来,以及多次使用同一个集合。

db.TestEntities.Where(p => (ids.Contains(p.Id) || ids.Contains(p.ParentId)) && p.Name.StartsWith("Test"))

因此我尝试将逻辑封装在一个特殊的 ToList 方法中,该方法可以为要分块查询的指定集合重写 Expression。

var ids = Enumerable.Range(1, 11);
var result = db.TestEntities.Where(p => Ids.Contains(p.Id) && p.Name.StartsWith ("Test"))
                                .ToChunkedList(ids,4);

为了重写表达式树,我发现查询中本地集合中的所有 Contains Method 调用都带有帮助类的视图。

private class ContainsExpression

    public ContainsExpression(MethodCallExpression methodCall)
    
        this.MethodCall = methodCall;
    

    public MethodCallExpression MethodCall  get; private set; 

    public object GetValue()
    
        var parent = MethodCall.Object ?? MethodCall.Arguments.FirstOrDefault();
        return Expression.Lambda<Func<object>>(parent).Compile()();
    

    public bool IsLocalList()
    
        Expression parent = MethodCall.Object ?? MethodCall.Arguments.FirstOrDefault();
        while (parent != null) 
            if (parent is ConstantExpression)
                return true;
            var member = parent as MemberExpression;
            if (member != null) 
                parent = member.Expression;
             else 
                parent = null;
            
        
        return false;
    


private class FindExpressionVisitor<T> : ExpressionVisitor where T : Expression

    public List<T> FoundItems  get; private set; 

    public FindExpressionVisitor()
    
        this.FoundItems = new List<T>();
    

    public override Expression Visit(Expression node)
    
        var found = node as T;
        if (found != null) 
            this.FoundItems.Add(found);
        
        return base.Visit(node);
    


public static List<T> ToChunkedList<T, TValue>(this IQueryable<T> query, IEnumerable<TValue> list, int chunkSize)

    var finder = new FindExpressionVisitor<MethodCallExpression>();
    finder.Visit(query.Expression);
    var methodCalls = finder.FoundItems.Where(p => p.Method.Name == "Contains").Select(p => new ContainsExpression(p)).Where(p => p.IsLocalList()).ToList();
    var localLists = methodCalls.Where(p => p.GetValue() == list).ToList();

如果在查询表达式中找到了在 ToChunkedList 方法中传递的本地集合,我将对原始列表的 Contains 调用替换为对包含一批 id 的临时列表的新调用。

if (localLists.Any()) 
    var result = new List<T>();
    var valueList = new List<TValue>();

    var containsMethod = typeof(Enumerable).GetMethods(BindingFlags.Static | BindingFlags.Public)
                        .Single(p => p.Name == "Contains" && p.GetParameters().Count() == 2)
                        .MakeGenericMethod(typeof(TValue));

    var queryExpression = query.Expression;

    foreach (var item in localLists) 
        var parameter = new List<Expression>();
        parameter.Add(Expression.Constant(valueList));
        if (item.MethodCall.Object == null) 
            parameter.AddRange(item.MethodCall.Arguments.Skip(1));
         else 
            parameter.AddRange(item.MethodCall.Arguments);
        

        var call = Expression.Call(containsMethod, parameter.ToArray());

        var replacer = new ExpressionReplacer(item.MethodCall,call);

        queryExpression = replacer.Visit(queryExpression);
    

    var chunkQuery = query.Provider.CreateQuery<T>(queryExpression);


    for (int i = 0; i < Math.Ceiling((decimal)list.Count() / chunkSize); i++) 
        valueList.Clear();
        valueList.AddRange(list.Skip(i * chunkSize).Take(chunkSize));

        result.AddRange(chunkQuery.ToList());
    
    return result;

// if the collection was not found return query.ToList()
return query.ToList();

表达式替换器:

private class ExpressionReplacer : ExpressionVisitor 

    private Expression find, replace;

    public ExpressionReplacer(Expression find, Expression replace)
    
        this.find = find;
        this.replace = replace;
    

    public override Expression Visit(Expression node)
    
        if (node == this.find)
            return this.replace;

        return base.Visit(node);
    

【讨论】:

这是一部很棒的作品!你应该在 Github 或 Codeplex 上分享它。它最接近我认为的“理想”,因此我将其标记为答案。唯一感觉有点不自然的部分是必须在ToChunkedList 方法中再次通过列表,但我不知道如何避免这种情况。多次使用Contains 的能力非常棒。【参考方案2】:

Linqkit 来救援!可能是直接执行此操作的更好方法,但这似乎工作正常,并且很清楚正在做什么。新增的是AsExpandable(),它允许您使用Invoke 扩展。

using LinqKit;

public static IEnumerable<TEntity> ChunkyContains<TEntity, TContains>(
    this IQueryable<TEntity> query, 
    Expression<Func<TEntity,TContains>> match, 
    IEnumerable<TContains> containList, 
    int chunkSize = 500)

    return containList
            .ToChunks(chunkSize)
            .Select (chunk => query.AsExpandable()
                                   .Where(x => chunk.Contains(match.Invoke(x))))
            .SelectMany(x => x);

您可能还想这样做:

containsList.Distinct()
            .ToChunks(chunkSize)

...或类似的东西,所以如果发生这种情况,您不会得到重复的结果:

query.ChunkyContains(x => x.Id, new List<int>  1, 1 , 1);

【讨论】:

【参考方案3】:

另一种方法是用这种方式构建谓词(当然,有些部分应该改进,只是给出想法)。

public static Expression<Func<TEntity, bool>> ContainsPredicate<TEntity, TContains>(this IEnumerable<TContains> chunk, Expression<Func<TEntity, TContains>> match)
        
            return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(
                typeof (Enumerable),
                "Contains",
                new[]
                
                    typeof (TContains)
                ,
                Expression.Constant(chunk, typeof(IEnumerable<TContains>)), match.Body),
                match.Parameters);
        

您可以在 ChunkContains 方法中调用它

return containList.ToChunks(chunkSize)
               .Select(chunk => query.Where(ContainsPredicate(chunk, match)))
               .SelectMany(x => x);

【讨论】:

【参考方案4】:

请允许我提供一种替代 Chunky 方法的方法。

在谓词中涉及Contains 的技术适用于:

常量值列表(无易失性)。 small 值列表。

如果您的本地数据具有这两个特征,Contains 将非常有用,因为这些小值集将在最终 SQL 查询中被硬编码。

当您的值列表具有熵(非常量)时,问题就开始了。在撰写本文时,实体框架(经典和核心)不会尝试以任何方式参数化这些值,这会强制 SQL Server 每次在查询中看到新的值组合时生成查询计划。此操作代价高昂,并且会因查询的整体复杂性而加剧(例如,许多表、列表中的许多值等)。

Chunky 方法仍然受到 SQL Server query plan cache pollution problem 的影响,因为它没有对查询进行参数化,它只是将创建大型执行计划的成本转移到更容易被 SQL Server 计算(和丢弃)的更小的执行计划中,此外,每个块都会向数据库添加额外的往返行程,这会增加解析查询所需的时间。

一种高效的解决方案(目前适用于 EF Core)

如果能以一种对 SQL Server 友好的方式在查询中组合本地数据不是很好吗?输入QueryableValues。

我设计这个库有两个主要目标:

必须解决 SQL Server 的查询计划缓存污染问题✅ 必须是fast!⚡

它有一个灵活的 API,允许您组合由 IEnumerable&lt;T&gt; 提供的本地数据,然后返回 IQueryable&lt;T&gt;;就像它是你的 DbContext 的另一个实体一样使用它(真的),例如:

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

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

// Using Contains (method syntax)
var query2 = dbContext.MyEntities
    .Where(e => dbContext.AsQueryableValues(values).Contains(e.Id))
    .Select(e => new
    
        e.Id,
        e.Name
    );

你也可以compose complex types!

不用说,提供的IEnumerable&lt;T&gt; 仅在您的查询实现时(而不是之前)枚举,在这方面保留了 EF Core 的相同行为。

它是如何工作的?

QueryableValues 在内部创建参数化查询,并以 SQL Server 本身可以理解的序列化格式提供您的值。这样一来,您的查询就可以通过单次往返数据库来解决,并避免由于其参数化性质而在后续执行中创建新的查询计划。

有用的链接

Nuget Package GitHub Repository Benchmarks SQL Server Cache Pollution Problem

QueryableValues 在 MIT 许可下分发

【讨论】:

这基本上是一个仅链接的答案。如果链接断开,则答案将失去其价值。甚至指向 Stack Overflow 答案的链接也可能会中断(例如,如果用户删除了问题)。除此之外,这里没有回答我的问题。 也就是说,这将是this question 的答案,因此您可以考虑在此处添加答案,但请根据具体情况调整您的答案并显示可以解决问题的代码。 @Gert 我不确定我的答案的格式,因为这个问题与不同的用例重叠,我想避免使用链接重复。我会听取您的建议并在此处和其他问题中定制更好的答案。感谢您的反馈。 @GertArnold 我对我的答案进行了更改。我希望它现在为这个问题提供更多价值。 是的,这样更好。尽管它不是解决原始问题的方法,但它可能有助于防止附带损害。值得研究。【参考方案5】:

使用带有表值参数的存储过程也可以很好地工作。您实际上在表/视图和表值参数之间的存储过程中编写了一个联合。

https://docs.microsoft.com/en-us/dotnet/framework/data/adonet/sql/table-valued-parameters

【讨论】:

以上是关于针对 SQL 后端的 LINQ 的可扩展包含方法的主要内容,如果未能解决你的问题,请参考以下文章

LINQ

Linq to SQL -- Group ByHaving和ExistsInAnyAllContains

列车数据库中的 linq 查询

LINQ to SQL语句之Group By/Having

LINQ TO SQL

WinDbg扩展