Почему оператор Contains () так резко снижает производительность Entity Framework? - PullRequest
79 голосов
/ 26 октября 2011

ОБНОВЛЕНИЕ 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 # 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, отправляемого для запроса, занимало больше времени, поэтому я попытался вырезать и вставить его из панели SQL LINQPad в 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, поэтому я не могу использовать его для списка идентификаторов.

Ответы [ 8 ]

65 голосов
/ 29 октября 2011

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

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

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

... мы сгенерируем дерево DbExpression, которое можно представитькак это:

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

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

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

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

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

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

Подумайте о создании метода, который уравновешивает количество обращений к базе данных и количество элементов, которые вы передаете в Contains.Например, в моем собственном тестировании я заметил, что для вычисления и выполнения на локальном экземпляре 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);
            }
        }
    }
}

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

24 голосов
/ 26 октября 2011

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

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

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

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

9 голосов
/ 15 февраля 2012

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

Обзор:

  • Создайте таблицу в 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();
    
5 голосов
/ 06 марта 2014

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

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

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

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

var ids = new HashSet<int>(Main.Select(a => a.Id));
var rows = Main.Where (a => ids.Contains(a.Id)).ToArray();
5 голосов
/ 26 октября 2011

Редактирование моего первоначального ответа - возможен обходной путь, в зависимости от сложности ваших сущностей. Если вы знаете, что 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));
        }
    }
}
3 голосов
/ 13 декабря 2012
2 голосов
/ 30 сентября 2015

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

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

2 голосов
/ 04 октября 2012

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

Это просто укусило меня, поэтому я добавил свои два пенса в ссылку "Предложения по функциям Entity Framework".

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

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

Обратите внимание, что SQL in все еще генерируется.

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

...