Почему оператор Contains () так резко снижает производительность Entity Framework?


обновление 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, испускаемый для запроса, занимает больше времени, поэтому я попытался вырезать и вставить его из панели SQL LINQPad в Управление SQL Server Студия.

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.

Я делаю что-то не так здесь? Это важная часть моего кода, так что я могу сделать, чтобы ускорить производительность?

Я с помощью Entity Платформа 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, поэтому я не могу использовать его для списка идентификаторов.

8 74

8 ответов:

обновление: с добавлением выражения в EF6, производительность обработки Перечислима.Содержит значительно улучшилось. Подход, описанный в этом ответе, больше не нужен.

вы правы, что большую часть времени тратится на обработку перевода запроса. Поставщик модель эф в настоящее время не включает выражение, которое представляет собой в статье, поэтому ADO.NET провайдеры могут не поддерживать в собственном коде. Вместо реализации Перечислимый.Содержит переводит его в дерево выражений OR, т. е. для чего - то, что в C# выглядит так:

new []{1, 2, 3, 4}.Contains(i)

... мы создадим дерево DbExpression, которое может быть представлено следующим образом:

((1 = @i) OR (2 = @i)) OR ((3 = @i) OR (4 = @i))

(деревья выражений должны быть сбалансированы, потому что если бы у нас были все ORs на одном длинном позвоночнике, было бы больше шансов, что посетитель выражения попадет в переполнение стека (да, мы действительно попали в это в нашем тестировании))

мы позже отправить дерево, как это ADO.NET поставщик, который может иметь возможность распознавать этот шаблон и сводить его к предложению IN во время генерации SQL.

когда мы добавили поддержку Enumerable.Содержит в EF4, мы подумали, что было бы желательно сделать это без необходимости вводить поддержку выражений IN в модели поставщика, и, честно говоря, 10 000 намного больше, чем количество элементов, которые мы ожидали, что клиенты перейдут к перечисляемым.Содержит. Тем не менее, я понимаю, что это раздражает и то, что манипуляция деревьями выражений делает вещи слишком дорогими в вашем конкретном сценарии.

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

к уже предложенным обходным путям в потоке я бы добавил следующее:

рассмотрите возможность создания метода, который балансирует количество циклов базы данных с количеством элементов, которые вы передаете содержит. Например, в моем собственном тестировании я заметил, что вычисление и выполнение в локальном экземпляре SQL Server запроса со 100 элементами занимает 1/60 секунды. Если вы можете написать свой запрос таким образом, что выполнение 100 запросов с 100 различными наборами идентификаторов даст вам эквивалентный результат запроса с помощью 10 000 элементов, то вы можете получить результаты примерно за 1,67 секунды вместо 18 секунд.

различные размеры блоков должны работать лучше в зависимости от запроса и задержки подключения к базе данных. Для определенных запросов, т. е. если переданная последовательность имеет дубликаты или если она Перечислима.Содержит используется во вложенном состоянии, вы можете получить повторяющиеся элементы в результатах.

вот фрагмент кода (извините, если код, используемый для нарезки входных данных на куски, выглядит следующим образом немного слишком сложно. Есть более простые способы добиться того же самого, но я пытался придумать шаблон, который сохраняет потоковую передачу для последовательности, и я не мог найти ничего подобного в 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);
            }
        }
    }
}

надеюсь, что это помогает!

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

используйте обходной путь и обходной путь в случае проблемы с производительностью, а EF означает прямой SQL. В этом нет ничего плохого. Глобальная идея, что использование EF = больше не использует SQL, является ложью. У вас есть SQL Server 2008 R2 Итак:

  • создать хранимую процедуру, принимающую возвращающий табличное значение параметр для передачи идентификаторов
  • пусть ваша хранимая процедура возвращает несколько результирующих наборов для эмуляции Include логика оптимальным способом
  • Если вам нужно какое-то сложное построение запросов, используйте динамический SQL внутри хранимой процедуры
  • использовать SqlDataReader получить результаты и построить свои лица
  • прикрепить их к контексту и работать с ними, как если бы они были загружены из Эф

Если производительность критична для вас, вы не найдете лучшего решения. Эта процедура не может быть сопоставлена и выполнена EF, поскольку текущая версия не поддерживает ни возвращающие табличное значение параметры, ни несколько результирующих наборов.

мы смогли решить проблему EF Contains, добавив промежуточную таблицу и присоединившись к этой таблице из запроса LINQ, который должен был использовать предложение Contains. Мы смогли добиться потрясающих результатов при таком подходе. У нас есть большая модель EF, и поскольку "содержит" не допускается при предварительной компиляции запросов EF, мы получаем очень низкую производительность для запросов, которые используют предложение "содержит".

общие сведения:

  • создать таблицу в SQL Server-for пример HelperForContainsOfIntType С HelperID на Guid тип данных и ReferenceID на int столбцы типа данных. Создавать различные таблицы с ReferenceID различных типов данных по мере необходимости.

  • создать объект / EntitySet для HelperForContainsOfIntType и другие такие таблицы в модели EF. При необходимости создайте разные сущности / EntitySet для разных типов данных.

  • создать вспомогательный метод в коде .NET, который принимает ввод IEnumerable<int> и возвращает Guid. Этот метод генерирует новый Guid и вставляет значения из IEnumerable<int> на HelperForContainsOfIntType вместе с генерируемым Guid. Далее метод возвращает это вновь сгенерированное Guid для звонящего. Для быстрой вставки в HelperForContainsOfIntType таблица, создать хранимую процедуру, которая принимает ввод списка значений и делает вставку. Смотрите возвращающие табличное значение параметры в SQL Server 2008 (ADO.NET). создайте разные помощники для разных типов данных или создайте общий вспомогательный метод для обработки разных тип данных.

  • создать EF скомпилированный запрос, который похож на что-то вроде ниже:

    static Func<MyEntities, Guid, IEnumerable<Customer>> _selectCustomers =
        CompiledQuery.Compile(
            (MyEntities db, Guid containsHelperID) =>
                from cust in db.Customers
                join x in db.HelperForContainsOfIntType on cust.CustomerID equals x.ReferenceID where x.HelperID == containsHelperID
                select cust 
        );
    
  • вызовите вспомогательный метод со значениями, которые будут использоваться в Contains предложение и получить Guid для использования в запросе. Например:

    var containsHelperID = dbHelper.InsertIntoHelperForContainsOfIntType(new int[] { 1, 2, 3 });
    var result = _selectCustomers(_dbContext, containsHelperID).ToList();
    

редактирование моего исходного ответа - существует возможный обходной путь, в зависимости от сложности ваших объектов. Если вы знаете sql, который EF генерирует для заполнения ваших сущностей, вы можете выполнить его непосредственно с помощью DbContext.База данных.SQL-запрос. В EF 4, я думаю, вы могли бы использовать ObjectContext.ExecuteStoreQuery, но я не пробовал.

например, используя код из моего исходного ответа ниже, чтобы создать инструкцию sql с помощью StringBuilder, Я смог сделать следующее

var rows = db.Database.SqlQuery<Main>(sql).ToArray();

и общее время пошло от приблизительно 26 секунд до 0,5 секунды.

Я буду первым, чтобы сказать, что это некрасиво, и, надеюсь, лучшее решение представляет собой.

обновление

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

чтобы проверить это, я создал Target таблица с той же схемой, как Main. Затем я использовал StringBuilder создать INSERT команды для заполнения Target таблица в партиях по 1000, так как это самый SQL Server будет принимать в одном INSERT. Непосредственное выполнение инструкций sql было намного быстрее, чем прохождение EF (около 0,3 секунды против 2,5 секунд), и я считаю, что все будет в порядке, так как схема таблицы не должно измениться.

и, наконец, выбрав с помощью join привело к гораздо более простому запросу и выполняется менее чем за 0,5 секунды.

ExecuteStoreCommand("DELETE Target");

var ids = Main.Select(a => a.Id).ToArray();
var sb = new StringBuilder();

for (int i = 0; i < 10; i++)
{
    sb.Append("INSERT INTO Target(Id) VALUES (");
    for (int j = 1; j <= 1000; j++)
    {
        if (j > 1)
        {
            sb.Append(",(");
        }
        sb.Append(i * 1000 + j);
        sb.Append(")");
    }
    ExecuteStoreCommand(sb.ToString());
    sb.Clear();
}

var rows = (from m in Main
            join t in Target on m.Id equals t.Id
            select m).ToArray();

rows.Length.Dump();

и sql, созданный EF для соединения:

SELECT 
[Extent1].[Id] AS [Id]
FROM  [dbo].[Main] AS [Extent1]
INNER JOIN [dbo].[Target] AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id]

(оригинал ответа)

это не ответ, но я хотел поделиться некоторой дополнительной информацией, и это слишком долго, чтобы вписаться в комментарий. Я был в состоянии воспроизвести свои результаты, и есть несколько других вещей, чтобы добавить:

SQL Profiler показывает задержку между выполнением первого запроса (Main.Select) и второй Main.Where запрос, поэтому я подозревал, что проблема заключалась в создании и отправке запроса такого размера (48 980 байт).

однако построение одного и того же оператора sql в T-SQL динамически занимает менее 1 секунды и принимает ids из своего Main.Select оператор, создавая тот же оператор sql и выполняя его с помощью SqlCommand заняло 0,112 секунды, и это в том числе время, чтобы записать содержимое на консоль.

на данный момент я подозреваю, что EF делает некоторый анализ/обработку для каждого из 10 000 ids как он строит запрос. Жаль, что я не могу дать окончательный ответ и решение :(.

вот код, который я пробовал в SSMS и LINQPad (пожалуйста, не критикуйте слишком резко, я спешу, пытаясь уйти с работы):

declare @sql nvarchar(max)

set @sql = 'SELECT 
[Extent1].[Id] AS [Id]
FROM [dbo].[Main] AS [Extent1]
WHERE [Extent1].[Id] IN ('

declare @count int = 0
while @count < 10000
begin
    if @count > 0 set @sql = @sql + ','
    set @count = @count + 1
    set @sql = @sql + cast(@count as nvarchar)
end
set @sql = @sql + ')'

exec(@sql)

var ids = Mains.Select(a => a.Id).ToArray();

var sb = new StringBuilder();
sb.Append("SELECT [Extent1].[Id] AS [Id] FROM [dbo].[Main] AS [Extent1] WHERE [Extent1].[Id] IN (");
for(int i = 0; i < ids.Length; i++)
{
    if (i > 0) 
        sb.Append(",");     
    sb.Append(ids[i].ToString());
}
sb.Append(")");

using (SqlConnection connection = new SqlConnection("server = localhost;database = Test;integrated security = true"))
using (SqlCommand command = connection.CreateCommand())
{
    command.CommandText = sb.ToString();
    connection.Open();
    using(SqlDataReader reader = command.ExecuteReader())
    {
        while(reader.Read())
        {
            Console.WriteLine(reader.GetInt32(0));
        }
    }
}

Я не знаком с Entity Framework, но лучше ли perf, если вы сделаете следующее?

вместо этого:

var ids = Main.Select(a => a.Id).ToArray();
var rows = Main.Where (a => ids.Contains(a.Id)).ToArray();

Как насчет этого (предполагая, что ID является int):

var ids = new HashSet<int>(Main.Select(a => a.Id));
var rows = Main.Where (a => ids.Contains(a.Id)).ToArray();

кэшируемая альтернатива Contains?

Это просто укусило меня, поэтому я добавил свои два пенса в ссылку Entity Framework Feature Suggestions.

проблема, безусловно, при создании SQL. У меня есть клиент на данные ВОЗ генерация запроса была 4 секунды, но выполнение было 0.1 секунд.

Я заметил, что при использовании dynamic LINQ и ORs поколение sql занимало столько же времени, но оно генерировало что-то, что может быть cached. Поэтому при повторном выполнении это было до 0,2 секунд.

обратите внимание, что SQL in все еще был сгенерирован.

просто что-то еще, чтобы рассмотреть, если вы можете переварить первоначального обращения, количество не сильно изменится, и выполнить много запросов. (Протестировано в LINQ Pad)

проблема заключается в создании SQL Entity Framework. Он не может кэшировать запрос, если один из параметров представляет собой список.

чтобы получить EF для кэширования запроса, вы можете преобразовать свой список в строку и сделать a .Содержит в строке.

Так, например, этот код будет работать намного быстрее, так как EF может кэшировать запрос:

var ids = Main.Select(a => a.Id).ToArray();
var idsString = "|" + String.Join("|", ids) + "|";
var rows = Main
.Where (a => idsString.Contains("|" + a.Id + "|"))
.ToArray();

когда этот запрос будет сгенерирован, он, скорее всего, будет сгенерирован с использованием Like вместо In, поэтому он ускорит ваш C#, но он может потенциально замедлить SQL. В моем случае я не заметил снижения производительности при выполнении SQL, и C# работал значительно быстрее.