Подводные камни Entity Framework и производительность

При работе с 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.

© Habrahabr.ru