Подтвердить что ты не робот

Как избежать повторной компиляции плана запроса при использовании запросов IEnumerable.Contains в Entity Framework LINQ?

У меня есть следующий запрос LINQ, выполняемый с использованием Entity Framework (v6.1.1):

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

Этот запрос переведен в довольно хороший SQL:

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

Тем не менее, я получаю очень значительное повышение производительности на этапе компиляции запросов. Призвание:

ELinqQueryState.GetExecutionPlan(MergeOption? forMergeOption) 

Получает ~ 50% времени каждого запроса. Копая глубже, оказалось, что запрос перекомпилируется каждый раз, когда я передаю разные клиенты. Согласно статье MSDN, это ожидаемое поведение, поскольку IEnumerable, который используется в запросе, считается изменчивым и является частью SQL, который кэшируется. Именно поэтому SQL отличается для каждой комбинации клиентов и всегда имеет различный хеш, который используется для получения скомпилированного запроса из кеша.

Теперь возникает вопрос: Как я могу избежать этой повторной компиляции, все еще запрашивая несколько клиентов?

4b9b3361

Ответ 1

Это большой вопрос. Прежде всего, на ум приходит пара обходных путей (все они требуют изменений в запросе):

Первый обходной путь

Это может быть немного очевидно и, к сожалению, не всегда применимо: если в таблице в базе данных уже существует выбор элементов, которые вам нужно передать в Enumerable.Contains, вы можете написать запрос, который вызывает Enumerable.Contains для соответствующего объекта установить в предикат вместо того, чтобы сначала занести элементы в память. Enumerable.Contains вызов данных в базе данных должен привести к некоторому запросу на основе JOIN, который можно кэшировать. Например. при условии отсутствия навигационных свойств между Customers и SelectedCustomers, вы сможете написать запрос следующим образом:

var q = db.Customers.Where(c => 
    db.SelectedCustomers.Select(s => s.Id).Contains(c.Id));

Синтаксис запроса с Any в этом случае немного проще:

var q = db.Customers.Where(c => 
    db.SelectedCustomers.Any(s => s.Id == c.Id));

Если у вас еще нет необходимых данных выбора, хранящихся в базе данных, вам, вероятно, не понадобятся дополнительные затраты на их сохранение, поэтому вам следует рассмотреть следующий обходной путь.

Второй обходной путь

Если вы заранее знаете, что у вас будет относительно управляемое максимальное количество элементов в списке, вы можете заменить Enumerable.Contains на дерево сравнений равенства с ИЛИ, например:

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

Это должно привести к параметризованному запросу, который можно кэшировать. Если размер списка меняется от запроса к запросу, это должно привести к разной записи в кэше для каждого размера списка. В качестве альтернативы вы можете использовать список с фиксированным размером и передать какое-нибудь значение часового, которое, как вы знаете, никогда не будет соответствовать аргументу значения, например, 0, -1 или, в качестве альтернативы, просто повторите одно из других значений. Чтобы создать такое выражение предиката программно во время выполнения на основе списка, вы можете рассмотреть возможность использования чего-то вроде PredicateBuilder.

Возможные исправления и их проблемы

С одной стороны, изменения, необходимые для поддержки кэширования запросов такого типа с использованием явного CompiledQuery, в текущей версии EF будут довольно сложными. Основная причина в том, что элементы в IEnumerable<T>, переданные методу Enumerable.Contains, должны были бы преобразоваться в структурную часть запроса для конкретного перевода, который мы производим, например:

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

Перечислимый "список" выглядит как простая переменная в С#/LINQ, но его необходимо преобразовать в запрос, подобный этому (упрощенно для ясности):

SELECT * FROM Customers WHERE Id IN(1,2,3)

Если список изменится на новый [] {5,4,3,2,1}, и нам придется сгенерировать запрос SQL снова!

SELECT * FROM Customers WHERE Id IN(5,4,3,2,1)

В качестве потенциального решения мы говорили о том, чтобы оставить сгенерированные запросы SQL открытыми с помощью какого-то специального заполнителя, например, хранить в кеше запросов, который просто говорит

SELECT * FROM Customers WHERE Id IN(<place holder>)

Во время выполнения мы могли бы выбрать этот SQL из кэша и завершить генерацию SQL с фактическими значениями. Другой вариант - использовать табличный параметр для списка, если целевая база данных может его поддерживать. Первый вариант, вероятно, будет работать нормально только с постоянными значениями, последний требует базы данных, которая поддерживает специальную функцию. Оба очень сложны для реализации в EF.

Автоматически скомпилированные запросы

С другой стороны, для автоматически скомпилированных запросов (в отличие от явного CompiledQuery) проблема становится несколько искусственной: в этом случае мы вычисляем ключ кэша запроса после первоначальной трансляции LINQ, поэтому любой переданный аргумент IEnumerable<T> должен быть уже расширен в Узлы DbExpression: дерево сравнений равенства в ИЛИ в EF5 и, как правило, один узел DbInExpression в EF6. Поскольку дерево запросов уже содержит отдельное выражение для каждой отдельной комбинации элементов в исходном аргументе Enumerable.Contains (и, следовательно, для каждого отдельного выходного запроса SQL), можно кэшировать запросы.

Однако даже в EF6 эти запросы не кэшируются даже в случае автоматически скомпилированных запросов. Основной причиной этого является то, что мы ожидаем, что изменчивость элементов в списке будет высокой (это связано с переменным размером списка, но также усугубляется тем фактом, что мы обычно не параметризуем значения, которые отображаются как константы к запросу, поэтому список констант будет переведен в константы литералов в SQL), поэтому при достаточном количестве обращений к запросу с Enumerable.Contains вы можете получить значительное загрязнение кэша.

Мы рассмотрели альтернативные решения для этого, но мы еще не реализовали. Итак, я пришел к выводу, что в большинстве случаев вам лучше использовать второй обходной путь, если, как я уже сказал, вы знаете, что количество элементов в списке останется небольшим и управляемым (в противном случае вы столкнетесь с проблемами с производительностью).

Надеюсь это поможет!

Ответ 2

Это действительно огромная проблема, и нет единого универсального ответа. Однако, когда большинство списков относительно невелики, диверга "Второй обходной путь" работает хорошо. Я построил библиотеку, распространяемую в виде пакета NuGet, для выполнения этого преобразования с минимальным изменением запроса:

https://github.com/bchurchill/EFCacheContains

Он был опробован в одном проекте, но отзывы и впечатления пользователей будут приветствоваться! Если возникнут какие-либо проблемы, пожалуйста, сообщите на github, чтобы я мог продолжить.

Ответ 3

У меня был именно этот вызов. Вот как я решил эту проблему для строк или long в методе расширения для IQueryables.

Чтобы ограничить загрязнение кэширования, мы создаем один и тот же запрос с множеством n из m (настраиваемых) параметров, поэтому 1 * m, 2 * m и т.д. Итак, если установлено значение 15; Планы запросов будут иметь параметры 15, 30, 45 и т.д., В зависимости от количества элементов в контейнере (мы не знаем заранее, но, вероятно, менее 100), ограничивая количество планов запросов 3, если наибольшее содержит меньше или равно 45.

Остальные параметры заполняются значением placeholder, которое (мы знаем) не существует в базе данных. В этом случае '-1'

Результирующая часть запроса;

... WHERE [Filter1].[SomeProperty] IN (@p__linq__0,@p__linq__1, (...) ,@p__linq__19)
... @p__linq__0='SomeSearchText1',@p__linq__1='SomeSearchText2',@p__linq__2='-1',
(...) ,@p__linq__19='-1'

Использование:

ICollection<string> searchtexts = .....ToList();
//or
//ICollection<long> searchIds = .....ToList();

//this is the setting that is relevant for the resulting multitude of possible queryplans
int itemsPerSet = 15;

IQueryable<MyEntity> myEntities = (from c in dbContext.MyEntities
select c)
.WhereContains(d => d.SomeProperty, searchtexts, "-1", itemsPerSet);

Метод расширения:

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

namespace MyCompany.Something.Extensions
{

public static class IQueryableExtensions
    {
        public static IQueryable<T> WhereContains<T, U>(this IQueryable<T> source, Expression<Func<T,U>> propertySelector, ICollection<U> identifiers, U placeholderThatDoesNotExistsAsValue, int cacheLevel)
        {
            if(!(propertySelector.Body is MemberExpression))
            {
                throw new ArgumentException("propertySelector must be a MemberExpression", nameof(propertySelector));
            }

            var propertyExpression = propertySelector.Body as MemberExpression;
            var propertyName = propertyExpression.Member.Name;

            return WhereContains(source, propertyName, identifiers, placeholderThatDoesNotExistsAsValue, cacheLevel);
        }

        public static IQueryable<T> WhereContains<T, U>(this IQueryable<T> source, string propertyName, ICollection<U> identifiers, U placeholderThatDoesNotExistsAsValue, int cacheLevel)
        {
            return source.Where(ContainsPredicateBuilder<T, U>(identifiers, propertyName, placeholderThatDoesNotExistsAsValue, cacheLevel));
        }

        public static Expression<Func<T, bool>> ContainsPredicateBuilder<T,U>(ICollection<U> ids, string propertyName, U placeholderValue, int cacheLevel = 20)
        {
            if(cacheLevel < 1)
            {
                throw new ArgumentException("cacheLevel must be greater than or equal to 1", nameof(cacheLevel));
            }

            Expression<Func<T, bool>> predicate;

            var propertyIsNullable = Nullable.GetUnderlyingType(typeof(T).GetProperty(propertyName).PropertyType) != null;

            // fill a list of cachableLevel number of parameters for the property, equal the selected items and padded with the placeholder value to fill the list.

            Expression finalExpression = Expression.Constant(false);
            var parameter = Expression.Parameter(typeof(T), "x");
            /* factor makes sure that this query part contains a multitude of m parameters (i.e. 20, 40, 60, ...), 
             * so the number of query plans is limited even if lots of users have more than m items selected */
            int factor = Math.Max(1, (int)Math.Ceiling((double)ids.Count / cacheLevel));
            for (var i = 0; i < factor * cacheLevel; i++)
            {
                U id = placeholderValue;
                if (i < ids.Count)
                {
                    id = ids.ElementAt(i);
                }

                var temp = new { id };
                var constant = Expression.Constant(temp);
                var field = Expression.Property(constant, "id");
                var member = Expression.Property(parameter, propertyName);
                if (propertyIsNullable)
                {
                    member = Expression.Property(member, "Value");
                }
                var expression = Expression.Equal(member, field);
                finalExpression = Expression.OrElse(finalExpression, expression);

            }

            predicate = Expression.Lambda<Func<T, bool>>(finalExpression, parameter);


            return predicate;
        }
    }
}