При работе с Entity Framework, как и с любым другими ORM, часто возникают вопросы, связанные с его производительностью. Многие разработчики из-за незнания нюансов делают ошибки, приводящие к плохим результатам. Затем, во время анализа проблем и поиска решений, недостаточно разобравшись в вопросе, приходят к выводу, что улучшить ситуацию можно только переходом на другой ORM или отказом от него вообще. Хоть в некоторых ситуациях такое решение может оказаться разумным, зачастую не все так плохо — просто нужно знать нюансы. В этой статье я попытался собрать те подводные камни, с которыми мне чаще всего приходилось сталкиваться на практике.

  1. Включенный трекинг изменений, когда это не нужно
  2. Постоянная перекомпиляция некоторых запросов
  3. Большое количество Include в одном запросе
  4. Вычитка полей только из базовой сущности при использовании Table Per Type маппинга
  5. Дополнительная информация


Включенный трекинг изменений, когда это не нужно


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

using(var context = new EntityDataContext())
{
    found = context.Entities.Where(e => e.Name.StartsWith(filter)).ToList();
}

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

Возникает вопрос — а зачем добавлять объекты в контекст, если нет никаких дальнейших действий по их изменению и сохранению? Ответ — незачем, это лишние расходы, причем заметные. Если объекты, вычитанные с помощью Entity Framework контекста, не будут изменены и не будут участвовать в модификациях других объектов, в соответствующем запросе нужно вызывать AsNoTracking() для коллекции:

using(var context = new EntityDataContext())
{
    found = context.Entities.AsNoTracking().Where(e => e.Name.StartsWith(filter)).ToList();
}

Эта функция устанавливает атрибут MergeOption в значение NoTracking, тем самым исключая действия по добавлению прочитанных объектов в контекст. Насколько существенен выигрыш, можно увидеть по этой ссылке https://msdn.microsoft.com/en-us/data/hh949853.aspx (пункт 5.2) или здесь — http://blog.staticvoid.co.nz/2012/4/2/entity_framework_and_asnotracking.

Другая потенциальная проблема, связанная с избыточным трекингом изменений, в первую очередь касается сценариев с добавлением и изменением данных. У конфигурации контекста есть свойство AutoDetectChangesEnabled, которое указывает, надо ли автоматически вызывать метод DetectChanges перед выполнением некоторых операций. К таким операциям относятся добавление объекта в контекст, сохранение изменений в базу, поиск объектов через метод Find и т.д. Вызов DetectChanges нужен, в частности, для определения, что именно поменялось/удалилось/добавилось, для обновления связей между объектами и т.д. Более подробно про то, для чего нужен этот метод, можно почитать здесь — http://blog.oneunicorn.com/2012/03/10/secrets-of-detectchanges-part-1-what-does-detectchanges-do/.

Предположим достаточно типовой сценарий — добавление множества объектов в базу:

using (var context = new EntitiesContext())
{
    for(int i = 0; i < 1000; i++)
    {
        context.PassengerCars.Add(new PassengerCar { Name = "RandomCar " + i.ToString() });
    }

    context.SaveChanges();
}

Значение AutoDetectChangesEnabled по умолчанию равно true, что означает, что при добавлении каждого нового объекта PassengerCar, сперва будет вызван DetectChanges, который пройдется по всем объектам в контексте и проверит наличие изменений. Но в данном случае он совершенно не нужен — изменений добавленных сущностей в этом коде нет, они сохраняются в том виде, в котором были добавлены. А затраты на DetectChanges весьма значительны, примеры можно увидеть здесь — http://blog.staticvoid.co.nz/2012/5/7/entityframework_performance_and_autodetectchanges.

Бездумное выключение свойства AutoDetectChangesEnabled может привести к нежелательным последствиям (потеря изменений, исключения из-за нарушения целостности данных), поэтому наиболее простое правило я бы сформулировал так — если ваш код не предполагает дальнейшего изменения добавленных в контекст объектов в пределах той же сессии, то это свойство можно смело отключать. Такая ситуация встречается довольно часто — типовой CRUD API обычно получает объект извне и либо просто его добавляет, либо еще определяет, какие были сделаны изменения с момента вычитки, и соответствующим образом обновляет информацию о состоянии объекта в контексте (например, с помощью GraphDiff, или с использованием self-tracked entities, или любых других похожих решений). Сам объект при этом не изменяется.

Постоянная перекомпиляция некоторых запросов


Начиная с Entity Framework 5, запросы автоматически кешируются после компиляции, что позволяет значительно ускорить их последующие выполнения — текст SQL запроса будет взят из кеша, остается только подставить требуемые значения параметров. Но есть несколько ситуаций, в которых компиляция будет выполняться при каждом выполнении.

Использование Contains по коллекции в памяти


На практике нередко возникает необходимость добавить в запрос условие, аналогичное SQL-оператору IN — проверить, совпадает ли значение свойства с каким-нибудь из элементов коллекции. Например, вот так:

List<int> channels = new List<int> { 1, 5, 9 };
dataContext.Entities
    .AsNoTracking()
    .Where(e => channels.Contains(e.Channel))
    .ToList();

Это выражение в итоге преобразуется в SQL следующего вида:

SELECT
    [Extent1].[Id] AS [Id],
    [Extent1].[Name] AS [Name],
    [Extent1].[Channel] AS [Channel]
    FROM [dbo].[Entities] AS [Extent1]
    WHERE [Extent1].[Channel] IN (1, 5, 9)

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

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

List<int> channels = new List<int> { 1, 5, 9 };

var channelsCondition = PredicateBuilder.False<Entity>();
channelsCondition = channels.Aggregate(channelsCondition,
    (current, value) => current.Or(e => e.Channel == value).Expand());

var query = dataContext.Entities
    .AsNoTracking()
    .Where(channelsCondition);

В итоге получаем уже параметризированный запрос:

SELECT
    [Extent1].[Id] AS [Id],
    [Extent1].[Name] AS [Name],
    [Extent1].[Channel] AS [Channel]
    FROM [dbo].[Entities] AS [Extent1]
    WHERE [Extent1].[Channel] IN (@p__linq__0,@p__linq__1,@p__linq__2)

Несмотря на то, что динамическое построение запроса выглядит дополнительной затратной работой, на практике на него уходит сравнительно немного процессорного времени. В одной из реальных задач построение запроса при каждом вызове занимало больше секунды. А замена Contains на подобное динамическое выражение уменьшило время обработки запросов (кроме первого) до десятков миллисекунд.

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


Во многих проектах возникает необходимость реализовать пейджинг для результатов поиска. Очевидным решением для выборки нужной порции записей тут являются функции Take и Skip:

int pageSize = 10;
int startFrom = 10;

var query = dataContext.Entities
    .AsNoTracking()
    .OrderBy(e => e.Name)
    .Skip(startFrom)
    .Take(pageSize);

Посмотрим, какой в этом случае будет SQL:

SELECT
    [Extent1].[Id] AS [Id],
    [Extent1].[Name] AS [Name],
    [Extent1].[Channel] AS [Channel]
    FROM [dbo].[Entities] AS [Extent1]
    ORDER BY [Extent1].[Name] ASC
    OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY

И размер страницы, и величина смещения указаны в запросе константами, а не параметрами. Это, опять же, говорит о том, что текст запроса кешироваться не будет. К счастью, начиная с Entity Framework 6 есть простая возможность обойти эту проблему — использовать лямбда-выражения в функциях Take и Skip:

var query = dataContext.Entities
    .AsNoTracking()
    .OrderBy(e => e.Name)
    .Skip(() => startFrom)
    .Take(() => pageSize);

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

SELECT
    [Extent1].[Id] AS [Id],
    [Extent1].[Name] AS [Name],
    [Extent1].[Channel] AS [Channel]
    FROM [dbo].[Entities] AS [Extent1]
    ORDER BY [Extent1].[Name] ASC
    OFFSET @p__linq__0 ROWS FETCH NEXT @p__linq__1 ROWS ONLY


Большое количество Include в одном запросе


Очевидно, самый простой способ прочитать данные из базы вместе с дочерними коллекциями и другими навигационными свойствами — это использовать метод Include(). Независимо от количества Include() в LINQ запросе, по итогу будет сформирован один SQL запрос, который возвращает все указанные данные. Может сложиться впечатление, что в рамках Entity Framework такой подход для вычитки сложных объектов будет наиболее оптимальным в любой ситуации. Но это не совсем так.

Для начала рассмотрим структуру итогового SQL запроса. Например, у нас есть LINQ запрос с двумя Include для коллекций.

var query = c.GuidKeySequentialParentEntities
    .AsNoTracking()
    .Include(e => e.Children1)
    .Include(e => e.Children2)
    .Where(e => e.Id == sequentialGuidKey);

Соответствующий SQL будет содержать UNION ALL:

SELECT
    [UnionAll1].[C2] AS [C1],
    [UnionAll1].[Id] AS [C2],
    [UnionAll1].[Name] AS [C3],
    [UnionAll1].[C1] AS [C4],
    [UnionAll1].[Id1] AS [C5],
    [UnionAll1].[Name1] AS [C6],
    [UnionAll1].[ParentId] AS [C7],
    [UnionAll1].[C3] AS [C8],
    [UnionAll1].[C4] AS [C9],
    [UnionAll1].[C5] AS [C10]
    FROM  (SELECT
        CASE WHEN ([Extent2].[Id] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1],
        1 AS [C2],
        [Extent1].[Id] AS [Id],
        [Extent1].[Name] AS [Name],
        [Extent2].[Id] AS [Id1],
        [Extent2].[Name] AS [Name1],
        [Extent2].[ParentId] AS [ParentId],
        CAST(NULL AS uniqueidentifier) AS [C3],
        CAST(NULL AS varchar(1)) AS [C4],
        CAST(NULL AS uniqueidentifier) AS [C5]
        FROM  [dbo].[GuidKeySequentialParentEntities] AS [Extent1]
        LEFT OUTER JOIN [dbo].[GuidKeySequentialChildEntity1] AS [Extent2] ON [Extent1].[Id] = [Extent2].[ParentId]
        WHERE [Extent1].[Id] = @p__linq__0
    UNION ALL
        SELECT
        2 AS [C1],
        2 AS [C2],
        [Extent3].[Id] AS [Id],
        [Extent3].[Name] AS [Name],
        CAST(NULL AS uniqueidentifier) AS [C3],
        CAST(NULL AS varchar(1)) AS [C4],
        CAST(NULL AS uniqueidentifier) AS [C5],
        [Extent4].[Id] AS [Id1],
        [Extent4].[Name] AS [Name1],
        [Extent4].[ParentId] AS [ParentId]
        FROM  [dbo].[GuidKeySequentialParentEntities] AS [Extent3]
        INNER JOIN [dbo].[GuidKeySequentialChildEntity2] AS [Extent4] ON [Extent3].[Id] = [Extent4].[ParentId]
        WHERE [Extent3].[Id] = @p__linq__0) AS [UnionAll1]
    ORDER BY [UnionAll1].[Id] ASC, [UnionAll1].[C1] ASC

Логично было бы предположить, что Include() просто добавляет еще один JOIN в запрос. Но Entity Framework ведет себя сложнее. Если включаемое навигационное свойство — единичный объект, а не коллекция, то будет просто еще один JOIN. Если коллекция — то под каждую будет сформирован отдельный подзапрос, где родительская таблица соединяется с дочерней, а все такие подзапросы будут объединены в общий UNION ALL. Очевидно, что если нужна только одна дочерняя коллекция, то UNION ALL не будет. Схематически это можно изобразить так:

SELECT
  /* список полей */
  FROM  (SELECT
      /* список полей */
      FROM  /* родительская таблица */
      LEFT OUTER JOIN /* дочерняя таблица 1 */
      WHERE /* общее условие */
  UNION ALL
      SELECT
      /* список полей */
      FROM  /* родительская таблица */
      INNER JOIN /* дочерняя таблица 2 */
      WHERE /* общее условие */
  UNION ALL
      SELECT
      /* список полей */
      FROM  /* родительская таблица */
      INNER JOIN /* дочерняя таблица 3 */
      WHERE /* общее условие */
	/* ... */
  ORDER BY /* список полей */

Сделано это для борьбы с проблемой перемножения результатов. Предположим, у объекта есть три дочерних коллекции по 10 элементов в каждой. Если все три добавить через OUTER JOIN напрямую в «главный» запрос, то в результате будет 10 * 10 * 10 = 1000 записей. Если же пойти путем Entity Framework, и эти три коллекции собирать в один запрос через UNION, то получим 30 записей. Чем больше коллекций и элементов в них, тем выигрыш подхода с UNION очевиднее.

Но проблема в том, что при большой сложности самих сущностей и критериев выборки, построение и оптимизация такого запроса весьма трудоемки для Entity Framework, как и выполнение его на уровне сервера базы данных. Поэтому если результаты профилирования показывают неудовлетворительную производительность запросов, содержащих Include, а с индексами в базе все в порядке — есть смысл задуматься об альтернативных решениях.

Основная идея альтернативных решений — это вычитка каждой коллекции отдельным запросом. Наиболее простой вариант возможен, если объекты при выборке добавляются в контекст, т.е. без использования AsNoTracking():

var children1 = c.ChildEntities1
    .Where(e => e.Parent.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -1))

var children2 = c.ChildEntities2
    .Where(e => e.Parent.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -1))

children1.Load();
children2.Load();

var query = c.ParentEntities
    .Where(e => e.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -1))
    .ToList();

Получается, что для каждой дочерней коллекции мы вычитываем все объекты, которые имеют отношение к родительским сущностям, попадающим под критерий запроса. После вызова Load() объекты добавляются в контекст. Во время вычитки родительских сущностей Entity Framework найдет все дочерние, находящиеся в контексте, и соответствующим образом добавит на них ссылки.

Основной недостаток здесь — то, что на каждый запрос идет отдельное обращение к серверу базы данных. К счастью, есть способ решить и эту проблему. В библиотеке EntityFramework.Extended есть возможность создавать «будущие» запросы. Основная идея в том, что все запросы, у которых был вызван extension method Future(), будут посланы в одном обращении к серверу, когда у какого-либо из них будет вызван терминальный метод:

var children1 = c.ChildEntities1
    .Where(e => e.Parent.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -1))
    .Future();

var children2 = c.ChildEntities2
    .Where(e => e.Parent.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -1))
    .Future();

var results = c.ParentEntities
    .Where(e => e.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -1))
    .Future()
    .ToList();

По итогу, как и в первом примере, объекты из коллекции results будут содержать корректно заполненные коллекции Children1 и Children2, причем все данные будут получены за одно обращение к серверу.

Использование «будущих» запросов будет полезно в любых ситуациях, где есть необходимость выполнять несколько отдельных запросов.

Вычитка полей только из базовой сущности при использовании Table Per Type маппинга


Представим себе систему, в которой ряд сущностей имеет базовый класс, содержащий их общие характеристики (название, дата создания, владелец, статус и т.д.). Также есть требование реализовать поиск по этим характеристикам и отображение списка результатов. Отображение подразумевает, опять же, использование только базовых характеристик.

С точки зрения гибкости модели под эту задачу хорошо подходит Table Per Type маппинг, где под каждый тип создается отдельная таблица. Например, у нас есть базовый класс Vehicle и наследники — PassengerCar, Truck, Motorcycle. В этом случае в базе будет создано четыре таблицы.

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

var vehicles = context.Vehicles
    .AsNoTracking()
    .Where(v => v.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -10))
    .ToList();

И посмотрим, во что его преобразует Entity Framework:

SELECT 
    CASE WHEN (( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL))) AND ( NOT (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL))) AND ( NOT (([Project2].[C1] = 1) AND ([Project2].[C1] IS NOT NULL)))) THEN '0X' WHEN (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL)) THEN '0X0X' WHEN (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL)) THEN '0X1X' ELSE '0X2X' END AS [C1], 
    [Extent1].[Id] AS [Id], 
    [Extent1].[CreatedAt] AS [CreatedAt], 
    [Extent1].[Name] AS [Name], 
    CASE WHEN (( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL))) AND ( NOT (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL))) AND ( NOT (([Project2].[C1] = 1) AND ([Project2].[C1] IS NOT NULL)))) THEN CAST(NULL AS bit) WHEN (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL)) THEN [Project1].[HasCycleCar] WHEN (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL)) THEN CAST(NULL AS bit) END AS [C2], 
    CASE WHEN (( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL))) AND ( NOT (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL))) AND ( NOT (([Project2].[C1] = 1) AND ([Project2].[C1] IS NOT NULL)))) THEN CAST(NULL AS int) WHEN (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL)) THEN CAST(NULL AS int) WHEN (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL)) THEN [Project3].[Seats] END AS [C3], 
    CASE WHEN (( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL))) AND ( NOT (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL))) AND ( NOT (([Project2].[C1] = 1) AND ([Project2].[C1] IS NOT NULL)))) THEN CAST(NULL AS int) WHEN (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL)) THEN CAST(NULL AS int) WHEN (([Project3].[C1] = 1) AND ([Project3].[C1] IS NOT NULL)) THEN CAST(NULL AS int) ELSE [Project2].[Capacity] END AS [C4]
    FROM    [dbo].[Vehicles] AS [Extent1]
    LEFT OUTER JOIN  (SELECT 
        [Extent2].[Id] AS [Id], 
        [Extent2].[HasCycleCar] AS [HasCycleCar], 
        cast(1 as bit) AS [C1]
        FROM [dbo].[Motorcycles] AS [Extent2] ) AS [Project1] ON [Extent1].[Id] = [Project1].[Id]
    LEFT OUTER JOIN  (SELECT 
        [Extent3].[Id] AS [Id], 
        [Extent3].[Capacity] AS [Capacity], 
        cast(1 as bit) AS [C1]
        FROM [dbo].[Trucks] AS [Extent3] ) AS [Project2] ON [Extent1].[Id] = [Project2].[Id]
    LEFT OUTER JOIN  (SELECT 
        [Extent4].[Id] AS [Id], 
        [Extent4].[Seats] AS [Seats], 
        cast(1 as bit) AS [C1]
        FROM [dbo].[PassengerCars] AS [Extent4] ) AS [Project3] ON [Extent1].[Id] = [Project3].[Id]
    WHERE [Extent1].[CreatedAt] >= (DATEADD (day, -10, SysUtcDateTime()))

Получается, что нам нужна только базовая информация, а Entity Framework вычитывает всю, причем достаточно громоздким запросом. На самом деле в данной конкретной ситуации ничего плохого нет — несмотря на то, что мы выбираем объекты из коллекции базовых классов, фреймворк должен соблюдать полиморфное поведение и возвращать объект того типа, которым он был создан.

Основной вопрос здесь — как упростить запрос, чтобы он не читал лишнее? К счастью, начиная с Entity Framework 5 такая возможность есть — это использование проекции. Просто создаем объект другого типа или анонимный, используя для его заполнения только свойств базовой сущности:

var vehicles = context.Vehicles
    .AsNoTracking()
    .Where(v => v.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -10))
    .Select(v => new { Id = v.Id, CreatedAt = v.CreatedAt, Name = v.Name })
    .ToList();

И все становится намного проще:

SELECT
    1 AS [C1],
    [Extent1].[Id] AS [Id],
    [Extent1].[CreatedAt] AS [CreatedAt],
    [Extent1].[Name] AS [Name]
    FROM [dbo].[Vehicles] AS [Extent1]
    WHERE [Extent1].[CreatedAt] >= (DATEADD (day, -10, SysUtcDateTime()))

Но есть и неприятные новости – если в базовом классе есть коллекция, и ее нужно вычитывать, проблема остается. Вот пример:

var vehicles = context.Vehicles
    .AsNoTracking()
    .Where(v => v.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -10))
    .Select(v => new 
    { 
        Id = v.Id, 
        CreatedAt = v.CreatedAt, 
        Name = v.Name, 
        ServiceTickets = v.ServiceTickets 
    })
    .ToList();

И сгенерированный для него SQL:

SELECT 
    [Project1].[Id1] AS [Id], 
    [Project1].[Id2] AS [Id1], 
    [Project1].[Id3] AS [Id2], 
    [Project1].[Id] AS [Id3], 
    [Project1].[C1] AS [C1], 
    [Project1].[CreatedAt] AS [CreatedAt], 
    [Project1].[Name] AS [Name], 
    [Project1].[C2] AS [C2], 
    [Project1].[Id4] AS [Id4], 
    [Project1].[Comments] AS [Comments], 
    [Project1].[Vehicle_Id] AS [Vehicle_Id]
    FROM ( SELECT 
        [Extent1].[Id] AS [Id], 
        [Extent1].[CreatedAt] AS [CreatedAt], 
        [Extent1].[Name] AS [Name], 
        [Extent2].[Id] AS [Id1], 
        [Extent3].[Id] AS [Id2], 
        [Extent4].[Id] AS [Id3], 
        1 AS [C1], 
        [Extent5].[Id] AS [Id4], 
        [Extent5].[Comments] AS [Comments], 
        [Extent5].[Vehicle_Id] AS [Vehicle_Id], 
        CASE WHEN ([Extent5].[Id] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C2]
        FROM     [dbo].[Vehicles] AS [Extent1]
        LEFT OUTER JOIN [dbo].[Motorcycles] AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id]
        LEFT OUTER JOIN [dbo].[Trucks] AS [Extent3] ON [Extent1].[Id] = [Extent3].[Id]
        LEFT OUTER JOIN [dbo].[PassengerCars] AS [Extent4] ON [Extent1].[Id] = [Extent4].[Id]
        LEFT OUTER JOIN [dbo].[ServiceTickets] AS [Extent5] ON [Extent1].[Id] = [Extent5].[Vehicle_Id]
        WHERE [Extent1].[CreatedAt] >= (DATEADD (day, -10, SysUtcDateTime()))
    )  AS [Project1]
    ORDER BY [Project1].[Id1] ASC, [Project1].[Id2] ASC, [Project1].[Id3] ASC, [Project1].[Id] ASC, [Project1].[C2] ASC

Я создавал тикет для Entity Framework на эту тему: https://entityframework.codeplex.com/workitem/2814, но мне вежливо ответили, что в виду большой сложности и опасности все разломать, они это исправлять не будут.

В некоторых случаях, когда размер базы и/или количество объектов-наследников невелики, с этим можно жить. Если подобные запросы начинают ощутимо ухудшать производительность, нужно искать решения. Раз на уровне самого фреймворка проблему предотвратить нельзя, нужен обходной путь. Наиболее простой вариант здесь — дочитывать коллекции отдельными запросами, например:

//Создаем базовый запрос
var vehiclesQuery = context.Vehicles
    .AsNoTracking()
    .Where(v => v.CreatedAt >= DbFunctions.AddDays(DateTime.UtcNow, -10));
//Вычитываем объекты с помощью проекции на вспомогательный класс, игнорируя коллекции
var vehicles = vehiclesQuery
    .Select(v => new VehicleDto
    { 
        Id = v.Id, 
        CreatedAt = v.CreatedAt, 
        Name = v.Name
    })
    .ToList();
//Дочитываем элементы коллекции, относящиеся к любому из объектов, возвращаемых исходным запросом
var serviceTickets = context.ServiceTickets
    .AsNoTracking()
    .Where(s => vehiclesQuery.Any(v => v.Id == s.VehicleId))
    .ToList();
//Раскладываем элементы по соответствующим объектам
vehicles.ForEach(v => v.ServiceTickets
    .AddRange(serviceTickets.Where(s => s.VehicleId == v.Id)));

Универсального рецепта здесь нет, и приведенное выше решение может не дать выигрыша во всех случаях. Например, базовый запрос может оказаться достаточно сложным, и выполнять его по новой для каждой коллекции будет накладно. Попытаться обойти эту проблему можно через получение списка идентификаторов из результатов базового запроса, а потом использование его во всех дальнейших подзапросах. Но если результатов много, выигрыша может и не быть. К тому же, в этом случае следует помнить о том, что было сказано ранее о методе Contains, который явно напрашивается для поиска по идентификаторам.

Общий подход к решению проблемы я бы сформулировал так — если есть возможность не использовать Table Per Type маппинг, лучше его не использовать. В тех случаях, когда без него сложно обойтись, нужно попробовать варианты, описанные выше, и посмотреть, дают ли они выигрыш.

Дополнительная информация


Нюансы, связанные с производительностью, на которые следует обратить внимание при работе с Entity Framework (в том числе и описанные в статье) кратко описаны по этой ссылке: https://msdn.microsoft.com/en-us/data/hh949853.aspx. К сожалению, не для всех проблем указаны альтернативные решения, но информация все равно очень полезная. Также следует отметить, что как минимум пункт 4.3 на практике не подтверждается для Entity Framework 6.1.3.

Комментарии (21)


  1. rroyter
    01.11.2015 04:25
    -2

    Обалдеть, да тут столько подводных камней, что я бы не решился и вплавь пересечь этот залив. И что это все лишь из-за лени писать хранимые процедуры?


    1. kuber
      01.11.2015 07:57
      +13

      Лень тут не причем. Все зависит от того, что за проект у вас.
      Естественно, если у вас высоконагруженный проект с большой БД и сложной логикой запросов к БД, то Entity Framework вам не подойдет, но дело в том, что огромное количество разработчиков разрабатывает стандартные проекты с маленькими БД (до 1 млн. строк). И вот для этих проектов EF приносит кайф. Попробуйте Lynq и вы увидите какое удовольствие писать запросы в стиле lambda expression. Также никто не мешает там где это надо вызвать хранимую процедуру или обратится к СУБД на чистом SQL.


    1. gandjustas
      01.11.2015 14:10
      +6

      Чем помогут хранимые процедуры? На практике отказ от хранимых процедур и переход к генерации запросов, например с помощью ef, в разы увеличивает быстродействие и скорость написания.


      1. chumakov-ilya
        02.11.2015 13:51

        Не могли бы вы поделиться пруфлинком по вопросу быстродействия?


        1. gandjustas
          02.11.2015 14:27
          +1

          Не могу, потому что результаты замеров на системах заказчика под NDA.

          Но если знать немного SQL, то становится понятно, что linq-orm_ы могут построить проекцию (select) которая выбирает ровно то, что нужно для отображения. Тогда как в процедурах проекции будут широкие, ибо никто не хочет поддерживать десяток процедур.

          Только эта особенность давала 20%-30% прирост в среднем на всех выборках.

          А дальше для узких запросов можно удачно построить покрывающие индексы и ускорить запросы в разы.


          1. kuber
            03.11.2015 00:11
            -1

            >> Но если знать немного SQL, то становится понятно, что linq-orm_ы могут построить проекцию (select) которая выбирает ровно то, что нужно для отображения
            Все это прекрасно можно сделать и на SQL.

            >> Тогда как в процедурах проекции будут широкие, ибо никто не хочет поддерживать десяток процедур.
            Видимо, вам просто не повезло с разработчиком БД. Понятно, что если писать плохой код, то и работать он будет плохо. Кстати, код на EF тоже надо поддерживать.

            >> А дальше для узких запросов можно удачно построить покрывающие индексы
            А SQL эти индексы использовать не будет?


            1. gandjustas
              03.11.2015 00:37
              +2

              Все это прекрасно можно сделать и на SQL.

              Как например? Дублируя SQL в каждой процедуре? Или клеить строки на стороне базы?

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

              Видимо, вам просто не повезло с разработчиком БД. Понятно, что если писать плохой код, то и работать он будет плохо. Кстати, код на EF тоже надо поддерживать.
              Тут дело не в разработчике. Linq имеет средства декомпозиции запросов, поэтому на нем можно с помощью трех простых (точнее сказать примитивных) комбинаторов получить минимум 2^3=8 запросов.

              На уровне СУБД того же самого можно добиться только склейкой строк или размножением количества процедур. И то и другое руками делать невозможно.

              А SQL эти индексы использовать не будет?
              Даже если будет, то менее эффективно. Потому что покрывающий индекс работает идеально когда есть проекция с теми же полями, что в индексе. Но для случая процедур никто не будет создавать десятки процедур с разными проекциями и одинаковыми предикатами, чтобы индексы работали.

              А еще часто бывает, что универсальные процедуры с кучей параметров и обычные индексы не цепляют из за множества предикатов вида @p is null or fld = @p.


      1. kuber
        02.11.2015 20:03

        >> в разы увеличивает быстродействие
        С этим я не могу согласиться.
        T-SQL это могучая и крайне гибкая технология. Естественно, что для сложных запросов грамотно написанные хранимые процедуры будут намного более производительные, чем автоматически сгенерированный SQL код EF-ом, так как они могут в полной мере раскрыть все особенности и весь богатейший функционал СУБД MS SQL.
        Мы используем EF во всех своих новых проектах, но когда сталкиваемся с действительной необходимостью оптимизации производительности, то мы используем хранимые процедуры, но на проект таких узких мест у нас на пальцах можно пересчитать.
        А вот выгода в плане скорости разработки, уменьшения количества ошибок и отсутствие смены контекста между C#, Lynq и SQL, T-SQL при использовании EF приносят очень много позитивного в процесс разработки.


        1. gandjustas
          02.11.2015 21:47

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

          За счет чего?

          так как они могут в полной мере раскрыть все особенности и весь богатейший функционал СУБД MS SQL.

          Например?

          Мы используем EF во всех своих новых проектах, но когда сталкиваемся с действительной необходимостью оптимизации производительности, то мы используем хранимые процедуры, но на проект таких узких мест у нас на пальцах можно пересчитать.
          Мы все еще о запросах или о массовой обработке? При необходимости сразу обновить много данных процедуры рулят. Для запросов — тормозят. EF как раз для запросов и используется.


          1. kuber
            02.11.2015 23:16
            +1

            >> За счет чего?
            За счет того, что универсальное решение практически всегда проиграет по производительности специализированному. Обратите внимание на то, как автор решает «проблемы» EF? Правильно, он по сути редактирует SQL-запрос, и пытается привести его к адекватному виду.

            >> Например?
            Например, рекурсивные запросы.

            >> Мы все еще о запросах или о массовой обработке?
            Вы под запросом понимаете выборку (SELECT) данных? Если да, то о них в частности. На самом деле в наших проектах самые сложные запросы связаны именно с выборкой данных.


            1. gandjustas
              02.11.2015 23:40
              +1

              За счет того, что универсальное решение практически всегда проиграет по производительности специализированному.
              Именно. На практике как раз процедуры делают универсальными, покрывающими много сценариев, а linq генерирует специализированные запросы с проекциями, предикатами, постраничным разбиением, которые дают гораздо больше информации оптимизатору.

              Например, рекурсивные запросы.

              Рекурсивные запросы помогают быстродействию? Скорее наоборот. Лучше использовать hierarchyid и вычисляемые колонки или DDC. Для деревьев крайне не рекомендовал бы использовать CTE. А для графов быстрее получается весь граф втянуть в память приложения и работать с ним, чем писать CTE.

              Может быть в других базах не так, но в SQL Server я более одного раза повышал быстродействие путем отказа от CTE.


              1. kuber
                03.11.2015 00:01
                +1

                >> На практике как раз процедуры делают универсальными
                Мне кажется, что вам просто не повезло с разработчиком БД. Поверьте и на EF можно писать очень плохо. Вы думаете мало тех, кто не использует проекции вообще и высасывает все данные методом ToList()? Это скорее говорит о низкой квалификации и безалаберном отношении к своей работе, чем об используемых технологиях.

                >> Рекурсивные запросы помогают быстродействию?
                Вы попросили пример использования особенностей MS SQL. Вот он.


                1. gandjustas
                  03.11.2015 00:42

                  Поверьте и на EF можно писать очень плохо. Вы думаете мало тех, кто не использует проекции вообще и высасывает все данные методом ToList()? Это скорее говорит о низкой квалификации и безалаберном отношении к своей работе, чем об используемых технологиях.
                  Я не просто верю, я регуоярно зарабатываю на проектах, где правлю такие косяки.

                  Но вопрос изначально был в том, что процедуры помогут сделать запросы быстрее. А пришли к тому что квалифицированный специалист с ХП сделает быстрее, чем неквалифицированный с EF. Но с таким утверждением никто и не спорил.
                  Вы попросили пример использования особенностей MS SQL. Вот он.

                  Давайте я еще раз процитирую ваше сообщение:
                  T-SQL это могучая и крайне гибкая технология. Естественно, что для сложных запросов грамотно написанные хранимые процедуры будут намного более производительные, чем автоматически сгенерированный SQL код EF-ом, так как они могут в полной мере раскрыть все особенности и весь богатейший функционал СУБД MS SQL.

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


                  1. kuber
                    03.11.2015 00:56

                    Мне кажется, что можно сформулировать и доказать следующее:
                    Для любого запроса, который сгенерирован генератором SQL кода, найдется такой запрос, написанный на SQL (возможно в паре с T-SQL), который не будет уступать в производительности сгенерированному.
                    Что думаете?


                    1. gandjustas
                      03.11.2015 01:02
                      +1

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


                      1. kuber
                        03.11.2015 01:07
                        +1

                        Вот с этим я полностью согласен.
                        Entity Framework отлично подходит для своих задач.
                        Спасибо вам за диалог.


  1. x512
    01.11.2015 09:15
    +1

    все описанные проблемы также актуальны для EF7?


    1. kuber
      01.11.2015 10:37
      +3

      релиз EF7 ожидается в первом квартале 2016 г. Здесь можно посмотреть на некоторые новые возможности EF7:
      https://github.com/aspnet/EntityFramework/wiki/Roadmap

      EF7 это скорее не продолжение EF6, а переписанная чуть ли не с нуля ORM.


      1. x512
        01.11.2015 11:19
        +1

        Вы капитан очевидность. Именно потому что она переписана чуть ли не с нуля и возник вопрос актуальности проблем, описанных статье.


        1. kuber
          01.11.2015 12:47
          +2

          В любом случае надо дождаться релиза EF 7 и проверить. Кстати, я бы не назвал описанные в статье случаи проблемами, хотя бы потому, что автор тут же в статье привел варианты их решения и почти все решения базируются на возможностях EF т. е. это скорее тонкости работы с EF, чем проблемы.


        1. YuriyIvon
          02.11.2015 01:46
          +3

          Как уже написали выше — часть описанного в статье скорее не проблемы фреймворка, а нюансы, о которых нужно знать и учитывать при написании кода. Например, все, что связано с AsNoTracking и AutoDetectChangesEnabled — справедливо и для EF7 и так и останется. Очевидно, что трекинг и определение изменений нужны во многих сценариях, и что они вносят дополнительные вычислительные расходы. Просто нужно знать, когда их можно отключить.

          Проблемы с перекомпиляцией запроса для Contains, Take и Skip тоже актуальны для EF7, причем в нем пока еще нет перегруженых Take и Skip, принимающих лямбда-выражение. Т.е. на данный момент в нем все запросы, содержащие Take и Skip будут перекомпилироваться при каждом выполнении.

          Насчет остального — надо проверять, причем лучше всего уже на релизной версии.