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

Почему оператор 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 (я использую режим "С# Statements", так что 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, испущенный для запроса, занимал больше времени, поэтому я попытался вырезать и вставить его из панели LINQPad SQL в SQL Server Management Studio.

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

4b9b3361

Ответ 1

ОБНОВЛЕНИЕ: с добавлением InExpression в EF6 производительность обработки Enumerable.Contains значительно улучшилась. Подход, описанный в этом ответе, больше не нужен.

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

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.Contains в EF4, мы думали, что было бы желательно сделать это без необходимости вводить поддержку выражений IN в модели поставщика, и, честно говоря, 10 000 намного больше, чем количество ожидаемых нами клиентов Enumerable.Contains. Тем не менее, я понимаю, что это раздражение и что манипуляция деревьями выражений делает вещи слишком дорогими в вашем конкретном сценарии.

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

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

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

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

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

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

Ответ 2

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

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

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

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

Ответ 3

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

Обзор:

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

  • Создайте Entity/EntitySet для HelperForContainsOfIntType и других подобных таблиц в EF-модели. Создайте другой Entity/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();
    

Ответ 4

Редактирование моего первоначального ответа. Существует возможное обходное решение, в зависимости от сложности ваших объектов. Если вы знаете sql, который EF генерирует для заполнения ваших объектов, вы можете выполнить его напрямую, используя DbContext.Database.SqlQuery. В 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));
        }
    }
}

Ответ 5

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

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

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

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

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

Ответ 7

Кэшируемая альтернатива Содержит?

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

Проблема при создании SQL. У меня есть клиент, у которого данные составляли 4 секунды, а выполнение - 0,1 секунды.

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

Обратите внимание, что SQL-код все еще сгенерирован.

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

Ответ 8

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

Чтобы EF кэшировать ваш запрос, вы можете преобразовать свой список в строку и сделать .Contains в строке.

Итак, например, этот код будет работать намного быстрее, поскольку 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, поэтому он ускорит ваш С#, но может потенциально замедлить ваш SQL. В моем случае я не заметил снижения производительности в моем SQL-исполнении, а С# выполнялся значительно быстрее.