【问题标题】:Combine Expressions instead of using multiple queries in Entity Framework在实体框架中组合表达式而不是使用多个查询
【发布时间】:2016-12-25 06:01:32
【问题描述】:

我有以下通用查询(可能已经应用了选择):

IQueryable<TEntity> queryable = DBSet<TEntity>.AsQueryable();

然后是 Provider 类,如下所示:

public class Provider<TEntity>
{
    public Expression<Func<TEntity, bool>> Condition { get; set; }

    [...]
}

Condition 可以按以下方式为每个实例定义:

Condition = entity => entity.Id == 3;

现在我想选择所有具有Condition 且至少被DBSet 的一个实体满足的Provider 实例:

List<Provider> providers = [...];
var matchingProviders = providers.Where(provider => queryable.Any(provider.Condition))

这个问题:我开始查询列表中的每个Provider 实例。我宁愿使用单个查询来实现相同的结果。由于性能存在问题,该主题尤为重要。如何使用Linq 语句或Expression Trees 使用单个查询来获得相同的结果并提高性能?

【问题讨论】:

  • 这很大程度上取决于provider.Condition 是什么。如果 EF 可以将其转换为 sql 查询,那么您可以收集所有条件并在单个查询中使用它们——与 queryable.Any(provider.Condition) 相反。
  • 我想使用的所有条件都可以被EF翻译成SQL查询
  • 这很大程度上取决于provider.Condition 是什么。如果 EF 可以将其转换为 sql 查询,那么您可以收集所有条件并在单个查询中使用它们——与 queryable.Any(provider.Condition) 相反。

标签: c# asp.net entity-framework expression-trees iqueryable


【解决方案1】:

有趣的挑战。我看到的唯一方法是像这样动态构建UNION ALL 查询:

SELECT TOP 1 0 FROM Table WHERE Condition[0]
UNION ALL
SELECT TOP 1 1 FROM Table WHERE Condition[1]
...
UNION ALL
SELECT TOP 1 N-1 FROM Table WHERE Condition[N-1]

然后使用返回的数字作为索引来获取匹配的提供者。

类似这样的:

var parameter = Expression.Parameter(typeof(TEntity), "e");
var indexQuery = providers
    .Select((provider, index) => queryable
        .Where(provider.Condition)
        .Take(1)
        .Select(Expression.Lambda<Func<TEntity, int>>(Expression.Constant(index), parameter)))
    .Aggregate(Queryable.Concat);

var indexes = indexQuery.ToList();
var matchingProviders = indexes.Select(index => providers[index]);

请注意,我可以在不使用 Expression 类的情况下构建查询,方法是将上面的 Select 替换为

.Select(_ => index)

但这会为每个索引引入不必要的 SQL 查询参数。

【讨论】:

  • 这不是一回事吗?仍然有多个查询,只是这次它们被联合在一起。
  • @DavidG 是的。从技术上讲,它是一个 SQL 查询,但不能保证它会比执行多个查询更有效。虽然理论上 SQL 优化器可能会创建一个有效的执行计划,所以这至少给了它这个机会:)
  • 我觉得这很有趣,我必须立即对其进行测试,该代码在技术上有效,我也赞成你的答案。遗憾的是,根据我使用的一些基准,它似乎仍然较慢:Old Method: 148ms IndexQuery ToList: 395ms 当使用大约 7 个提供 2000 个条目的提供商时。
  • @IvanStoev 顺便说一句,我发现您可以通过将Select(Expression.Lambda&lt;Func&lt;TEntity, int&gt;&gt;(Expression.Constant(index), parameter))) 替换为Select(entity =&gt; index)) 来简化内部Select 语句
  • @SebastianKrogull 我在答案的最后提到了这一点。
【解决方案2】:

这是我想到的另一个(疯狂的)想法。请注意,与我之前的回答类似,它不能保证更好的性能(实际上可能会更糟)。它只是提供了一种使用单个 SQL 查询来完成您所要求的事情的方法。

在这里,我们将创建一个返回单个string 的查询,其长度为 N,由“0”和“1”字符组成,“1”表示匹配(类似于字符串位数组)。该查询将使用我最喜欢的按常量分组技术来动态构建如下内容:

var matchInfo = queryable
    .GroupBy(e => 1)
    .Select(g =>
        (g.Max(Condition[0] ? "1" : "0")) +
        (g.Max(Condition[1] ? "1" : "0")) +
            ...
        (g.Max(Condition[N-1] ? "1" : "0")))
    .FirstOrDefault() ?? "";

这里是代码:

var group = Expression.Parameter(typeof(IGrouping<int, TEntity>), "g");

var concatArgs = providers.Select(provider => Expression.Call(
        typeof(Enumerable), "Max", new[] { typeof(TEntity), typeof(string) },
        group, Expression.Lambda(
            Expression.Condition(
                provider.Condition.Body, Expression.Constant("1"), Expression.Constant("0")),
            provider.Condition.Parameters)));

var concatCall = Expression.Call(
    typeof(string).GetMethod("Concat", new[] { typeof(string[]) }),
    Expression.NewArrayInit(typeof(string), concatArgs));

var selector = Expression.Lambda<Func<IGrouping<int, TEntity>, string>>(concatCall, group);

var matchInfo = queryable
    .GroupBy(e => 1)
    .Select(selector)
    .FirstOrDefault() ?? "";

var matchingProviders = matchInfo.Zip(providers,
    (match, provider) => match == '1' ? provider : null)
    .Where(provider => provider != null)
    .ToList();

享受:)

PS 在我看来,这个查询会以恒定的速度运行(关于条件的数量和类型,即最好考虑 O(N),最坏和平均情况,其中 N 是表中的记录数),因为数据库必须始终执行全表扫描。知道实际性能如何仍然会很有趣,但很可能做这样的事情并不值得。

更新:关于赏金和更新要求:

找到一个只读取表的记录一次如果已经满足所有条件则结束查询

的快速查询

没有满足这两个条件的标准 SQL 构造(甚至没有谈到 LINQ 查询翻译)。像EXISTS 这样允许提前结束的结构可以用于单个条件,因此当针对多个条件执行时,将违反仅读取表记录一次的第一条规则。虽然在这个答案中使用聚合的构造满足第一条规则,但是为了产生聚合值,它们必须读取所有记录,因此不能提前退出。

简而言之,没有一个查询可以同时满足这两个要求。 fast 部分呢,它实际上取决于数据的大小以及条件、表索引等的数量和类型,因此对于所有情况,根本没有“最佳”通用解决方案.

【讨论】:

    【解决方案3】:

    基于@Ivan 的Post,我创建了一个在某些情况下速度稍快的表达式。

    它使用Any 而不是Max 来获得所需的结果。

    var group = Expression.Parameter(typeof(IGrouping<int, TEntity>), "g");
    
    var anyMethod = typeof(Enumerable)
        .GetMethods()
        .First(m => m.Name == "Any" && m.GetParameters()
        .Count() == 2)
        .MakeGenericMethod(typeof(TEntity));
    
    var concatArgs = Providers.Select(provider => 
        Expression.Call(anyMethod, group, 
        Expression.Lambda(provider.Condition.Body, provider.Condition.Parameters)));
    
    var convertExpression = concatArgs.Select(concat =>
        Expression.Condition(concat, Expression.Constant("1"), Expression.Constant("0")));
    
    var concatCall = Expression.Call(
        typeof(string).GetMethod("Concat", new[] { typeof(string[]) }),
        Expression.NewArrayInit(typeof(string), convertExpression));
    
    var selector = Expression.Lambda<Func<IGrouping<int, TEntity>, string>>(concatCall, group);
    
    var matchInfo = queryable
        .GroupBy(e => 1)
        .Select(selector)
        .First();
    
    var MatchingProviders = matchInfo.Zip(Providers,
        (match, provider) => match == '1' ? provider : null)
        .Where(provider => provider != null)
        .ToList();
    

    【讨论】:

    • 很高兴您自己找到了替代方案! (+1) 我正在考虑它,但不喜欢生成的 SQL。当然更好看的 SQL 并不总是更好:)
    【解决方案4】:

    我在这里尝试的方法是创建Conditions 并将它们嵌套到一个Expression 中。如果满足Conditions 之一,我们将得到Provider 的索引。

    private static Expression NestedExpression(
        IEnumerable<Expression<Func<TEntity, bool>>> expressions, 
        int startIndex = 0)
    {
        var range = expressions.ToList();
        range.RemoveRange(0, startIndex);
    
        if (range.Count == 0)
            return Expression.Constant(-1);
    
        return Expression.Condition(
            range[0].Body, 
            Expression.Constant(startIndex), 
            NestedExpression(expressions, ++startIndex));
    }
    

    因为Expressions 仍然可能使用不同的ParameterExpressions,我们需要一个ExpressionVisitor 来重写它们:

    private class PredicateRewriterVisitor : ExpressionVisitor
    {
        private readonly ParameterExpression _parameterExpression;
    
        public PredicateRewriterVisitor(ParameterExpression parameterExpression)
        {
            _parameterExpression = parameterExpression;
        }
    
        protected override Expression VisitParameter(ParameterExpression node)
        {
            return _parameterExpression;
        }
    }
    

    对于重写我们只需要调用这个方法:

    private static Expression<Func<T, bool>> Rewrite<T>(
        Expression<Func<T, bool>> exp, 
        ParameterExpression parameterExpression)
    {
        var newExpression = new PredicateRewriterVisitor(parameterExpression).Visit(exp);
        return (Expression<Func<T, bool>>)newExpression;
    }
    

    查询本身和Provider 实例的选择是这样工作的:

    var parameterExpression = Expression.Parameter(typeof(TEntity), "src");
    var conditions = Providers.Select(provider => 
        Rewrite(provider.Condition, parameterExpression)
    );
    
    var nestedExpression = NestedExpression(conditions);
    var lambda = Expression.Lambda<Func<TEntity, int>>(nestedExpression, parameterExpression);
    
    var matchInfo = queryable.Select(lambda).Distinct();
    var MatchingProviders = Providers.Where((provider, index) => matchInfo.Contains(index));
    

    注意:另一个不太快的选项

    【讨论】:

    • 在某些情况下这也不会产生正确的结果(例如,只有 1 条记录匹配所有过滤器)。那么结论是什么?我用 1M 条记录和 10 个条件做了一些测试。结果因条件而异,但通常自适应查询(可以更早地返回结果,而不管需要读取多个记录的事实)执行得更好。在我的测试中,Union 查询在一般情况下最快,然后是原始查询(多个查询),然后是 Any 的答案。基于Max 的查询是最差的(尤其是如果条件包含变量)。
    • 你是对的。对于我的用例,具有来自问题的多个请求的版本似乎仍然是最快的。其次是你的联合方法。如果一切都失败了,我明天会将赏金奖励给你。
    • 这与赏金无关。正如我在第一篇文章中提到的,这是一个有趣的问题,我真的希望得到一些真正的改进。
    【解决方案5】:

    这是与表达式无关的问题的另一种观点。

    由于主要目标是提高性能,如果尝试使用单个查询生成结果没有帮助,我们可以尝试通过并行执行原始多查询解决方案来提高速度。

    由于它实际上是一个 LINQ to Objects 查询(它在内部执行多个 EF 查询),理论上它应该是通过像这样插入 AsParallel 将其转换为 PLINQ 查询的简单问题(不工作):

    var matchingProviders = providers
        .AsParallel()
        .Where(provider => queryable.Any(provider.Condition))
        .ToList();
    

    然而,事实证明 EF DbContext 不太适合多线程访问,而且上面只会产生运行时错误。所以我不得不求助于TPL 使用Parallel.ForEach 重载之一,它允许我们提供本地状态,我曾经在执行期间分配几个DbContext 实例。

    最终的工作代码如下所示:

    var matchingProviders = new List<Provider<TEntity>>();
    Parallel.ForEach(providers,
        () => new
        {
            context = new MyDbContext(),
            matchingProviders = new List<Provider<TEntity>>()
        },
        (provider, state, data) =>
        {
            if (data.context.Set<TEntity>().Any(provider.Condition))
                data.matchingProviders.Add(provider);
            return data;
        },
        data =>
        {
            data.context.Dispose();
            if (data.matchingProviders.Count > 0)
            {
                lock (matchingProviders)
                    matchingProviders.AddRange(data.matchingProviders);
            }
        }
    );
    

    如果你有一个多核 CPU(现在很正常)和一个好的数据库服务器,这应该会给你你正在寻求的改进。

    【讨论】:

      猜你喜欢
      • 2011-03-30
      • 1970-01-01
      • 2017-08-09
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2013-12-20
      相关资源
      最近更新 更多