Чому оператор Contains () так різко погіршує продуктивність Entity Framework?


79

ОНОВЛЕННЯ 3: Згідно з цим оголошенням , це було розглянуто командою EF у EF6 alpha 2.

ОНОВЛЕННЯ 2: Я створив пропозицію щодо вирішення цієї проблеми. Щоб проголосувати за це, йди сюди .

Розглянемо базу даних SQL з однією дуже простою таблицею.

CREATE TABLE Main (Id INT PRIMARY KEY)

Я заповнюю таблицю 10000 записами.

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 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 вимагає, щоб параметри, що передаються у запит, були основними типами (int, string, float тощо). Він не приймає масиви або IEnumerable, тому я не можу використовувати його для списку ідентифікаторів.


1
Ви намагалися var qry = Main.Where (a => ids.Contains(a.Id)); var rows = qry.ToArray();зрозуміти, яка частина запиту займає час?
Ендрю Купер,

не EF погіршує ваш запит, це фактичний запит, який ви намагаєтеся виконати; не могли б ви пояснити, що ви намагаєтесь зробити можливо, є кращий підхід до ваших потреб
Кріс Іванов

@AndrewCooper Я щойно спробував, і через відкладене виконання перший оператор (без ToArray) виконується майже миттєво. Запит, включаючи фільтрацію Contains, насправді не виконується, поки ви не виконаєте ToArray ().
Mike

5
Просто і оновлення щодо цього: EF6 alpha 2 включає вдосконалення, яке прискорює переклад Enumerable.Contens. Дивіться оголошення тут: blogs.msdn.com/b/adonet/archive/2012/12/10/… . Мої власні тести показують, що переклад list.Constens (x) для списку зі 100000 елементів int зараз займає близько секунди, і час зростає приблизно лінійно з кількістю елементів у списку. Дякуємо за Ваш відгук та допомагаєте нам покращити EF!
divega

1
Остерігайтеся цього ... запити з будь-яким параметром IEnumerable не можуть бути кешовані, що може спричинити досить серйозні побічні ефекти, якщо ваші плани запитів ускладнені. Якщо вам доводиться запустити операції багато разів (наприклад, використовуючи Contains для отримання шматків даних), у вас можуть бути досить неприємні часи перекомпіляції запитів! Перевірте джерело самостійно, і ви побачите, що parent._recompileRequired = () => true;відбувається для всіх запитів, що містять параметр IEnumerable <T>. Бу!
jocull

Відповіді:


66

ОНОВЛЕННЯ: Завдяки додаванню InExpression в EF6 продуктивність обробки Enumerable. Містить значно покращені. Підхід, описаний у цій відповіді, більше не потрібний.

Ви маєте рацію, що більшу частину часу витрачаєте на обробку перекладу запиту. Наразі модель постачальника EF не включає вираз, який представляє речення IN, тому постачальники ADO.NET не можуть підтримувати IN спочатку. Натомість реалізація Enumerable.Contains перекладає її у дерево виразів АБО, тобто для чогось, що в C # виглядає так:

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

... ми створимо дерево DbExpression, яке може бути представлене так:

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

(Дерева виразів повинні бути збалансовані, оскільки якби у нас були всі АБО за одним довгим хребтом, було б більше шансів, що відвідувач виразу потрапить у переповнення стека (так, насправді це вдалося в нашому тестуванні))

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

Коли ми додали підтримку Enumerable.Contens в EF4, ми вважали, що бажано це робити без необхідності вводити підтримку виразів IN у моделі провайдера, і чесно кажучи, 10000 - це набагато більше, ніж кількість елементів, на які, як ми очікували, перейдуть клієнти Містить. Тим не менш, я розумію, що це дратує і що маніпуляції деревами виразів роблять речі занадто дорогими у вашому конкретному сценарії.

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

До обхідних шляхів, вже запропонованих у потоці, я б додав наступне:

Подумайте про створення методу, який врівноважує кількість зворотних поїздок до бази даних з кількістю елементів, які ви передаєте в "Зміст". Наприклад, під час власного тестування я помітив, що обчислення та виконання проти локального екземпляра SQL Server запиту зі 100 елементами займає 1/60 секунди. Якщо ви можете написати свій запит таким чином, що виконання 100 запитів із 100 різними наборами ідентифікаторів дасть вам еквівалентний результат запиту з 10000 елементами, тоді ви зможете отримати результати приблизно за 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);
            }
        }
    }
}

Сподіваюся, це допомагає!


Для пояснення !(status.EndOfSequence = true)в <T> метод TakeOnEnumerator: Так побічний ефект цього завдання вираження завжди буде істинним , таким чином , не впливає на загальний вираз !. По суті, він позначає stats.EndOfSequenceяк trueлише тоді, коли є інші елементи, які потрібно отримати, але ви вже закінчили перелік.
arviman

Можливо, продуктивність обробки Enumerable.Containsзначно покращилася в EF 6 порівняно з попередніми версіями EF. Але, на жаль, це ще далеко не задовільно / готово до виробництва у наших випадках використання.
Нік

24

Якщо ви виявили проблему з продуктивністю, яка блокує вас, ви не намагаєтесь витратити віки на її вирішення, оскільки, швидше за все, ви не досягнете успіху, і вам доведеться спілкуватися з нею безпосередньо з державою-членом (якщо у вас є преміальна підтримка) віків.

Використовуйте обхідне та обхідне рішення у разі проблеми продуктивності, а EF означає прямий SQL. У цьому немає нічого поганого. Глобальна ідея, що використання EF = більше не використовувати SQL - це брехня. У вас є SQL Server 2008 R2, тому:

  • Створіть збережену процедуру, що приймає табличний параметр для передачі ваших ідентифікаторів
  • Нехай ваша збережена процедура повертає кілька наборів результатів, щоб Includeоптимально наслідувати логіку
  • Якщо вам потрібна якась складна побудова запитів, використовуйте динамічний SQL всередині збереженої процедури
  • Використовуйте SqlDataReaderдля отримання результатів та побудови своїх об’єктів
  • Приєднайте їх до контексту та працюйте з ними так, ніби вони завантажені з EF

Якщо продуктивність є критичною для вас, ви не знайдете кращого рішення. Ця процедура не може бути зіставлена ​​та виконана EF, оскільки поточна версія не підтримує ані табличних параметрів, ані декількох наборів результатів.


@Laddislav Mrnka Ми зіткнулися з подібною проблемою продуктивності через list.Contains (). Ми спробуємо створити процедури, передаючи ідентифікатори. Чи повинні ми відчувати якісь результативність, якщо ми запускаємо цю процедуру через EF?
Курубаран,

9

Ми змогли вирішити проблему 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

Редагування моєї оригінальної відповіді - Існує можливий обхідний шлях, залежно від складності ваших об’єктів. Якщо ви знаєте sql, який EF створює для заповнення ваших сутностей, ви можете виконати його безпосередньо за допомогою DbContext.Database.SqlQuery . У EF 4, я думаю, ви могли б використовувати ObjectContext.ExecuteStoreQuery , але я не спробував.

Наприклад, використовуючи код з моєї оригінальної відповіді нижче, щоб сформувати оператор sql за допомогою a StringBuilder, я зміг зробити наступне

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

а загальний час перейшов приблизно з 26 секунд до 0,5 секунди.

Я буду першим, хто скаже, що це потворно, і, сподіваюся, є кращим рішенням.

оновлення

Трохи подумавши, я зрозумів, що якщо ви використовуєте об’єднання для фільтрування результатів, EF не повинен будувати такий довгий список ідентифікаторів. Це може бути складним залежно від кількості одночасних запитів, але я вважаю, що ви можете використовувати ідентифікатори користувачів або ідентифікатори сеансу, щоб їх ізолювати.

Щоб перевірити це, я створив Targetтаблицю з тією ж схемою, що і Main. Потім я використовував a 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 та його виконання за допомогою SqlCommand0,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));
        }
    }
}

Дякую за вашу роботу над цим. Знання того, що ви змогли його відтворити, змушує мене почуватись краще - принаймні я не божевільний! На жаль, ваш обхідний шлях насправді не допомагає в моєму випадку, оскільки, як ви можете здогадатися, приклад, який я навів тут, був максимально спрощений, щоб відокремити проблему. Мій фактичний запит включає досить складну схему .Include () у кількох інших таблицях, а також кілька інших операторів LINQ.
Mike

@Mike, я додав ще одну ідею, яка буде працювати для складних сутностей. Сподіваємось, це не буде надто складно реалізувати, якщо у вас немає іншого вибору.
Jeff Ogata

Я провів кілька тестів, і я думаю, що ви праві, що затримка полягає у створенні SQL перед його виконанням. Я оновив своє запитання деталями.
Mike

@Mike, ти зміг спробувати приєднатися до ідентифікаторів (див. Оновлення в моїй відповіді)?
Джефф Огата,

Я вирішив використовувати варіант вашого підходу для вирішення проблеми продуктивності. Це закінчилося досить потворно, але, мабуть, найкращий варіант, поки (і якщо) Microsoft не вирішить цю проблему.
Mike

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();

Я не знаю чому та як, але це спрацювало як шарм :) Щиро дякую :)
Вахід Бітар

1
Поясненням того, чому продуктивність краща, є int []. Містить виклик у першому виклику O (n) - потенційно повне сканування масиву - тоді як HashSet <int>. Містить виклик O (1). Див stackoverflow.com/questions/9812020 / ... для виконання HashSet.
Шив,

3
@Shiv Я не вважаю, що це правильно. EF візьме будь-яку колекцію та переведе її в SQL. Тип колекції не повинен бути випуском.
Роб

@Rob Я скептично налаштований - з втратою пояснюю різницю в продуктивності, якщо це так. Можливо, доведеться проаналізувати двійковий файл, щоб побачити, що він зробив.
Шив

1
HashSet не є незліченною. Виклик IEnumerables .Сонці в LINQ працюють погано (принаймні до EF6.)
Джейсон Бек,


2

Альтернатива кешируванню, яку можна кешувати, містить?

Це мене просто покусало, тому я додав свої два пенси до посилання Пропозиції функцій Entity Framework.

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

Я помітив, що при використанні динамічних LINQ та АБО генерація sql зайняла стільки ж часу, але вона створила щось, що можна було кешувати . Отже, при його повторному виконанні воно становило 0,2 секунди.

Зауважте, що SQL все-таки був створений.

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


Також проголосуйте за це на сайті codeplex < entityframework.codeplex.com/workitem/245 >
Дейв

2

Проблема полягає в генерації SQL Entity Framework. Він не може кешувати запит, якщо одним із параметрів є список.

Щоб EF кешував ваш запит, ви можете перетворити свій список на рядок і створити .Contens на рядку.

Так, наприклад, цей код буде працювати набагато швидше, оскільки EF може кешувати запит:

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

Коли цей запит генерується, він, швидше за все, буде сформований за допомогою Подобається замість In, тому це пришвидшить роботу вашого C #, але це може потенційно сповільнити ваш SQL. У моєму випадку я не помітив жодного зниження продуктивності мого виконання SQL, і C # працював значно швидше.


1
Приємна ідея, але при цьому не буде використано жодного індексу у відповідній колонці.
Спендер

Так, це правда, саме тому я згадав, що це може уповільнити виконання SQL. Я думаю, це лише потенційна альтернатива, якщо ви не можете використовувати конструктор предикатів і працюєте з досить малим набором даних, щоб ви могли дозволити собі не використовувати індекс. Я також вважаю, що я мав би згадати, що найкращим варіантом є
конструктор

1
Яке дивовижне рішення. Нам вдалося збільшити час виконання виробничого запиту з ~ 12600 мілісекунд до лише ~ 18 мілісекунд. Це ВЕЛИЧЕЗНЕ поліпшення. Дуже дякую !!!
Якоб
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.