Еще один взгляд на Entity Framework: производительность и подводные камни

в 14:13, , рубрики: .net, entity framework, метки: , ,

Ни для кого не секрет, что адаптация Entity Framework проходит очень медленно. Огромное количество компаний продолжают использовать Linq2Sql и не планируют менять его на что-то новое в обозримом будущем, несмотря на то, что EF – официально рекомендуемая Microsoft технология доступа к БД, а Linq2Sql уже почти не поддерживается.

Тех, кто всё еще сомневается, можно ли использовать EF (и особенно – code first) на реальных проектах, приглашаю под кат.

Введение

Во-первых, если у вас за плечами уже пара выполненных проектов с использованием EF – дальше можно не читать. Вряд ли вы узнаете что-то новое.

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

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

Пост предполагает хотя бы общее знакомство с Entity Framework Code First, SQL и вообще принципами ORM.

Актуальной версией EF на момент написания является EF5. Для получения максимальной производительности был использован .NET 4.5. Все, сказанное ниже, относится в первую очередь к Code First с автоматической генерацией БД (который активно рекламируется самими сотрудниками Microsoft). Сервер БД – LocalDb (обычно работает медленнее, чем полноценный MS SQL Server).

Тестовый код доступен на GitHub.

Модель данных

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

У каждого пользователя (Account) есть несколько папок с письмами (MessageFolder). В каждой папке хранится несколько цепочек писем (MessageThread), при этом одна цепочка может находиться в множестве папок одновременно (ThreadsInFolders). Каждая цепочка состоит из множества писем. При этом цепочки шарятся между аккаунтами, письма – нет.

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

Она же в коде

public class Account
    {
        public int Id { get; set; }
        [Required]
        public string Name { get; set; }
    }

    public class Message
    {
        public int Id { get; set; }
        public bool IsRead { get; set; }
        public DateTime Date { get; set; }
        public string Text { get; set; }

        [Required]
        public virtual MessageThread Thread { get; set; }
        [Required]
        public virtual Account Owner { get; set; }
        [Required]
        public virtual Account Sender { get; set; }
        [Required]
        public virtual Account Receiver { get; set; }
    }

    public class MessageThread
    {
        public int Id { get; set; }
        [Required]
        [StringLength(150)]
        public string Subject { get; set; }

        public virtual ICollection<MessageFolder> Folders { get; set; }
    }

    public class MessageFolder
    {
        public int Id { get; set; }
        public string Name { get; set; }
        [Required]
        public Account Owner { get; set; }

        public virtual ICollection<MessageThread> Threads { get; set; }
    }

Чтение данных

Самым главным в любой ORM-системе является чтение данных. Тут у EF проблем нет – запросы к БД ходят весело и задорно, поддерживается ленивая загрузка. Все загруженные объекты попадают во внутренний кэш контекста, затем могут быть запрошены напрямую из него (только через метод Find).

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

context.Messages.AsNoTracking().OrderBy(f => f.Id).Take(count)
                .Include(f => f.Owner).Include(f => f.Sender).Include(f => f.Receiver)
                .ToList();

SQL

SELECT TOP (100) 
[Extent1].[Id] AS [Id], 
[Extent1].[IsRead] AS [IsRead], 
[Extent1].[Date] AS [Date], 
[Extent1].[Text] AS [Text], 
[Extent2].[Id] AS [Id1], 
[Extent2].[Name] AS [Name], 
[Extent3].[Id] AS [Id2], 
[Extent3].[Name] AS [Name1], 
[Extent4].[Id] AS [Id3], 
[Extent4].[Name] AS [Name2]
FROM    [dbo].[Message] AS [Extent1]
INNER JOIN [dbo].[Account] AS [Extent2] ON [Extent1].[Owner_Id] = [Extent2].[Id]
INNER JOIN [dbo].[Account] AS [Extent3] ON [Extent1].[Sender_Id] = [Extent3].[Id]
INNER JOIN [dbo].[Account] AS [Extent4] ON [Extent1].[Receiver_Id] = [Extent4].[Id]
ORDER BY [Extent1].[Id] ASC

AsNoTracking отключает слежение EF за получаемыми объектами (они не будут кэшированы); Include – подгружает указанные связанные сущности (navigation property/свойства навигации) через генерацию, чаще всего, INNER JOIN; остальное – знакомый всем LINQ.

Самый интересный момент здесь – Include. Строго говоря, окончательное осознание того, что вам может понадобиться, происходит на уровне презентации, поэтому самое логичное расположение всех Include – там, либо на соседнем уровне (например, контроллеров в MVC). Фактически это означает, что вам придется протаскивать IQueryable<T> через всю структуру приложения, постоянно следя, чтобы никто не использовал методы, несовместимые с Linq2Entities, иначе это выльется в исключение. С классической архитектурой (репозитории-сервисы-контроллеры) такой подход мало приятен. Возможно, организация домена через запросы или extension methods к IQueryable поможет с этим, но я не пробовал.

Любой запрос кроме Find ходит к БД. Find делает это лишь в том случае, если сущность с необходимыми ключевыми полями не была найдена в локальном кэше контекста. То есть, из двух вызовов context.Messages.Find(1) на одном контексте, лишь первый из них выльется в запрос к БД. То же касается загрузки связанных сущностей. Например, в приведенном ниже коде владелец сообщения будет получен из кэша.

context.Accounts.Load(); //аккаунты были загружены в кэш где-нибудь в другом месте
var m = context.Messages.First();
Console.WriteLine(m.Owner.Name);

SQL

SELECT 
[Extent1].[Id] AS [Id], 
[Extent1].[Name] AS [Name]
FROM [dbo].[Account] AS [Extent1]
GO

SELECT TOP (1) 
[c].[Id] AS [Id], 
[c].[IsRead] AS [IsRead], 
[c].[Date] AS [Date],
[c].[Text] AS [Text], 
[c].[Thread_Id] AS [Thread_Id], 
[c].[Owner_Id] AS [Owner_Id], 
[c].[Sender_Id] AS [Sender_Id], 
[c].[Receiver_Id] AS [Receiver_Id]
FROM [dbo].[Message] AS [c]

Вполне логичный context.Messages.Include(f=>f.Owner).First() для избегания ленивой загрузки здесь бы лишь снизил производительность. Вывод: не стоит городить Include без предварительного изучения SQL-запросов, отправляемых к серверу.

Кроме того, если по условию задачи необходимо, чтобы в результаты попадали добавленные, но еще не сохраненные в БД данные, необходимо будет вручную проводить запрос к context.Set<T>().Local, а затем объединять его с данными, полученными из БД. Либо вызывать SaveChanges заранее. Еще один аргумент в пользу короткого времени жизни контекстов. Тут полная аналогия с Linq2Sql.

По поводу производительности: первый приведенный в этой части запрос для 1000 записей выполняется EF примерно в 2-3 раза медленнее, чем Linq2Sql. Однако, API загрузки намного приятнее, чем аналогичное в Linq2Sql.

Добавление данных

Добавление данных реализовано довольно просто. Любая добавленная через метод Add сущность просто добавляется во внутренний кэш контекста со статусом Added. При последующем вызове SubmitChanges проходит валидация, для всех подобных сущностей генерируются INSERT и они добавляются в БД. Все связанные navigation property, которые не были явно добавлены в контекст, так же принимаются как Added и добавляются в БД (что может служить источником довольно внезапных багов).

Основные советы по добавлению:

  1. При добавлении множества данных стоит отключить ValidateOnSaveEnabled и AutoDetectChangesEnabled. Это значительно поднимет производительность.
  2. Не стоит добавлять в одном контексте слишком много записей. Поскольку все они перед сохранением кэшируются в нем, большое количество объектов очень быстро вызывают проблемы с GC и количеством потребляемой памяти. В среднем, лучше пересоздавать контекст после каждых 100 записей.

Более подробно можно почитать на StackOverflow.
В плане производительность EF примерно равен, а то и обгоняет (после оптимизаций) Linq2Sql. Хотя, повторюсь, я не большой знаток Linq2Sql.

Модификация и удаление данных

Эти операции традиционно являются слабым место .NET ORM-ок. Главной проблемой является отсутствие поддержки Bulk-операций. То, что в SQL легко выражается через один запрос, ORM может сделать за тысячу.
Допустим, необходимо пометить все сообщения в цепочке как прочитанные. В EF это будет выглядеть подобным образом:

var messages = context.Messages.Where(f=>f.Thread.Id == threadId);
foreach (var m in messages)
    m.IsRead = true;
context.SaveChanges();

SQL

SELECT 
[Extent1].[Id] AS [Id], 
[Extent1].[IsRead] AS [IsRead], 
[Extent1].[Date] AS [Date], 
[Extent1].[Text] AS [Text], 
[Extent1].[Thread_Id] AS [Thread_Id], 
[Extent1].[Owner_Id] AS [Owner_Id], 
[Extent1].[Sender_Id] AS [Sender_Id], 
[Extent1].[Reciever_Id] AS [Reciever_Id]
FROM [dbo].[Message] AS [Extent1]
WHERE [Extent1].[Thread_Id] = @p__linq__0

update [dbo].[Message]
set [IsRead] = @0
where ([Id] = @1)
--(N раз)

Во-первых, это вызовет загрузку всех модифицируемых записей в контекст, а затем генерацию отдельного запроса для каждой из них. Большое количество сущностей, несомненно, обрадует сборщик мусора, а большое количество запросов – сервер БД. Кроме того, существуют некоторые проблемы с валидацией, которые будут описаны ниже. Удаление организуется похожим образом и имеет те же проблемы с производительностью.

Причины отсутствия множественных update/delete лично мне непонятны. И не только мне, поэтому существуют подобные мануалы и сторонние библиотеки.

С EntityFramework.Extended приведенный выше код будет выглядеть следующим образом:

var messages = context.Messages.Where(f=>f.Thread.Id == threadId);
messages.Update(f => new Message {IsRead = true});

SQL

UPDATE [dbo].[Message] SET 
[IsRead] = @p__update__0 
FROM [dbo].[Message] AS j0 INNER JOIN (
SELECT 
[Extent1].[Id] AS [Id]
FROM [dbo].[Message] AS [Extent1]
WHERE [Extent1].[Thread_Id] = @p__linq__0
) AS j1 ON (j0.[Id] = j1.[Id])

Код выполняет всего один запрос к базе данных. Обратите внимание, что нет вызова SaveChanges – SQL выполняется сразу в обход контекста. Это может привести к некоторым неочевидным багам, когда контекст будет содержать данные о какой-либо записи, которая в действительности будет удалена/изменена.

Кроме того, библиотека генерирует не самый эффективные запросы, внутри довольно сильно опирается на dynamic и косвенно генерирует неимоверное количество исключений внутри фрэймворка (их видно, например, в IntelliTrace), тем самым представляя значительную базовую стоимость одного запроса. Пример в таблице ниже (EntityFramework без валидации).

Способ Модификация 10 записей, мс Модификация 10000 записей, мс
EntityFramework 8 935
EntityFramework.Extended 28 83

В общем случае, использование EntityFramework.Extended оправдано для большого количества данных, при небольшом лучше обойтись без него. Но всё это микрооптимизации.

Коллекции как navigation property

От заказчика приходит просьба – давайте показывать возле имени каждой папки сообщений количество цепочек в ней. «Без проблем!» отвечает программист и быстро добавляет следующий код:

var count = folder.Threads.Count();

Как же хорошо, что существуют navigation properties, думает он, пока не видит генерируемый SQL.

SQL

SELECT 
[Extent2].[Id] AS [Id], 
[Extent2].[Subject] AS [Subject]
FROM  [dbo].[ThreadsInFolders] AS [Extent1]
INNER JOIN [dbo].[MessageThread] AS [Extent2] ON [Extent1].[MessageThread_Id] = [Extent2].[Id]
WHERE [Extent1].[MessageFolder_Id] = @EntityKeyValue1

Что в EF, что в Linq2Sql, вызов Count(), Any(), да и вообще любого запроса на коллекции, используемой как navigation property, приводит в полной загрузке этой коллекции в память. Причины можно понять, но об этой «фиче» зачастую забывают. Корректный запрос, считающий количество на уровне БД, выглядит так:

var count = context.Threads.Count(f => f.Folders.Any(e => e.Id == folder.Id)));

SQL

SELECT 
[GroupBy1].[A1] AS [C1]
FROM ( SELECT 
	COUNT(1) AS [A1]
	FROM [dbo].[MessageThread] AS [Extent1]
	WHERE  EXISTS (SELECT 
		1 AS [C1]
		FROM [dbo].[ThreadsInFolders] AS [Extent2]
		WHERE ([Extent1].[Id] = [Extent2].[MessageThread_Id]) AND ([Extent2].[MessageFolder_Id] = @p__linq__0)
	)
)  AS [GroupBy1]

Но он требует, чтобы цепочки имели ссылку на папки, в которых они хранятся, что, с точки зрения нашей архитектуры, немного «не православный ООП».

Даты

Недавно на одном из наших проектов обнаружился интересный баг – на одном из серверов падало кэширование любых картинок, хранящихся в БД.

Исключение генерировалось на следующей строке:

Response.Cache.SetLastModified(lastUpdated);

где lastUpdated – дата последней модификации картинки, получаемая прямо из БД.
В процессе дебага выяснилось, что ASP.NET MVC внутри себя вызывал на полученной дате ToUniversalTime(), и затем падал на проверке:

if (utcDate > DateTime.UtcNow) {
                throw new ArgumentOutOfRangeException("utcDate"); 
            }

Причина бага довольно проста – при сохранении в БД, DateTime теряет значение свойства Kind и при получении данных из БД получает DateTimeKind.Undefined (логичное значение, придуманное как раз для таких случаев, хотя лично я считаю хранение чего-либо кроме UTC в БД моветоном). Если вы используете даты только в собственном, полностью контролируемом коде, то это не имеет особого значения (поскольку большинство операций не учитывают DateTimeKind), но любой вызов ToUniversalTime() или ToLocalTime() сдвинет это время в зависимости от локальных настроек системы. Так, наше кэширование падало только если часовой пояс был GMT — N.

Подобное поведение характерно как для EF, так и для Linq2Sql и, строго говоря, никак не настраивается. Существуют разнообразные способы решения проблемы, но пользователям code first придется туго. Решение через подписку на событие ObjectMaterialized может отсрочить уничтожение контекста (если оно производится не руками) и привести к совсем неочевидным багам. Есть еще возможность использовать простенький враппер вокруг DateTime, и маппить его в таблицу как ComplexType, но придется наслаждаться всеми прелестями даты как reference type.

RequiredAttribute

Мой любимый подводный камень и главная причина моего разочарования в EF.

Напомню, что в сущности письма свойства Owner, Sender, Receiver и Thread помечены как Required. Во-первых, это указывает генератору БД на то, что поля должны быть помечены как NOT NULL, а во-вторых, включает на них внутреннюю валидацию EF.

Что, по-вашему, выполнит следующий код при включенной валидации на только что созданном контексте?

var message = context.Messages.Find(1);
message.IsRead = true;
context.SaveChanges();

Если вы решили, что он отметит письмо как прочитанное — вы ошиблись. Появится DbEntityValidationException.

Замечательный RequiredAttribute выдает ошибку валидации, ведь все помеченные им поля были равны null, а выполнять их ленивую загрузку он не умеет. Проблема обсуждалась, например, тут.

Путей решения несколько: можно подгружать все значения заранее (сложно), написать свой атрибут (велосипедно), использовать try-catch вокруг SaveChanges и подгружать все связи в catch (медленно), отключать валидацию, когда надо (опасно), а можно просто отказаться от RequiredAttribute и определить NOT NULL через Fluent API. Последнее, правда, сменит тип исключения, выбрасываемого при попытке добавить новую запись с кривыми данными, отключит валидацию на уровне кода, да и внесет довольно неочевидное поведение в модификацию существующих записей (любая установка Required свойств в null просто не будет попадать в UPDATE запрос). В любом случае, если эта проблема обнаружилась довольно поздно в цикле разработке, приятной она не окажется.

Выводы (tl;dr)

Можно ли использовать EF для чего-то кроме блога с одним посещением в месяц? Решать вам. Всё не так радужно, как Microsoft пытается нам рассказать и любой более-менее нагруженный проект потребует значительных усилий по оптимизации и исправлению недостатков самого EF.

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

Есть ли преимущества у EF по сравнению с Linq2Sql? Решать, опять же, вам. Для меня главным преимуществом является возможность автогенерации БД через code first (он и правда очень приятен), но количество его ограничений на данный момент не поддается счету. Нет адекватной поддержки представлений, хранимых процедур и функций (должны поправить в EF6). Невозможно адекватно использовать в коде автоматически сгенерированную таблицу для связи много-ко-многим (можно определить её в коде самостоятельно, но это значительно убавит удобство работы с собственными классами) и т.д. Идеальных ORM-средств не бывает и EF – не исключение.

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

Автор: tp7

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js