Entity Framework и производительность

в 13:37, , рубрики: .net, performance, метки:

В процессе работы над проектом веб-портала, я исследовал возможности улучшить производительность, и наткнулся на небольшую статью про микро-ORM Dapper, который был написан авторами проекта StackOverflow.com. Изначально их проект был написан на Linq2Sql, а теперь все критичные к производительности места переписаны с использованием означенного решения.
Недостаток этого, а также других подобных решений, которые я успел посмотреть, в том, что они уж очень незначительно помогают облегчить процесс разработки, предоставляя по большому счету лишь материализацию, скрывая работу с непосредственно ADO.Net. SQL запросы же нужно писать руками.

Linq2Entities синтаксис же располагает к более «чистому коду», позволяя как тестирование кода, так и его переиспользование. Кроме того, при изменении в базе данных, сразу после обновления контекста, компилятор сгенерирует ошибки, во всех местах где используется удаленное или переименованное поле, изменившаяся структура связей между таблицами подсветит те места, где используются соответсвующие navigation properties.

Но статья не о том, насколько EF ускоряет разработку, и не о том, что не очень хорошо иметь часть запросов написанных на linq, а часть сразу на sql. Здесь я приведу решение, позволяющее совместить EF-сущности и Linq2Entities запросы с одной стороны и «чистую производительность» ADO.Net с другой. Но сначала немного предыстории. Все, кто с такими проектами работал, как я полагаю, сталкивались с тем, что per-row вызовы работают весьма медленно. И многие, вероятно, пытались оптимизировать это, написав огромный запрос и втиснув в него все, что только можно. Это работает, но выглядит очень страшно — код метода огромен, его трудно поддерживать и невозможно тестировать. Первый этап решения, который я опробовал, это материализация всех нужных сущностей, каждой отдельным запросом. А соединение/преобразование их в доменную структуру происходит раздельно с материализацией.
Поясню на примере. Нужно отобразить список страховых полисов, первичный запрос, выглядит примерно так:

int clientId = 42;
var policies = context.Set<policy>().Where(x => x.active_indicator).Where(x => x.client_id == clientId); 

Далее, для отображения необходимой информации, нам нужны зависимые, или как их еще можно назвать, «дочерние» сущности.

var coverages = policies.SelectMany(x => x.coverages);
var premiums = coverages.Select(x => x.premium).Where(x => x.premium_type == SomeIntConstant);

Сущности, связанные посредством NavProps, также можно подгрузить посредством Include, но с этим возникают свои трудности, проще(и производительнее, об этом далее) оказалось сделать как в означенном примере.
Эта переделка сама по себе не дала такого уж прироста производительности, относительно исходного всеобъемлющего запроса, но упростила код, позволила разбить на более мелкие методы, сделать код более притным и привычным взгляду.

Производительность пришла следующим шагом, когда запустив профайлер SQL сервера, я обнаружил, что два запроса из 30 выполняются в 10-15 раз дольше остальных. Первый из этих запросов был таким

var tasks = workflows.SelectMany(x => x.task)
                     .Where(x => types.Contains(x.task_type))
                     .GroupBy(x => new { x.workflow_id, x.task_type})
                     .Select(x => x.OrderByDescending(y => y.task_id).FirstOrDefault());

Как выяснилось, EF генерирует очень неудачный запрос, и всего лишь передвинув GroupBy с последнего места на первое, я приблизил скорость выполнения этих запросов к остальным, получив около 30-35% уменьшения итогового времени исполнения.

var tasks = context.Set<task>
                   .GroupBy(x => new { x.workflow_id, x.task_type})
                   .Select(x => x.OrderByDescending(y => y.task_id).FirstOrDefault())
                   .Join(workflows, task => task.workflow_id, workflow => workflow.workflow_id, (task, workflow) => task)
                   .Where(x => types.Contains(x.task_type));

На всякий случай скажу, что Join в этом запросе эквивалентен SelectMany в предыдущем.
Найти и устранить подобную огреху в недрах огромного запроса проблематично, на грани невозможного. И через Include такое тоже не реализовать.

Возвращаясь в начало статьи, к микро-ORM, хочу сразу сказать, что подобный подход возможно оправдан не во всех сценариях. В нашем нужно было загрузить порцию данных из БД, сделать некоторые преобразования и подсчеты и отправить клиенту в браузер, посредством JSON.
В качестве прототипа решения, я попробовал реализовать материализацию через PetaPoco, и был сильно впечатлен тестовым результатом, разница в во времени материализации целевой группы запросов составила 4.6х (756ms против 3493ms). Хотя правиьнее было бы сказать, что я был разочарован производительностью EF.
По причинам строгих настроек в StyleCop, использовать PetaPoco в проекте не вышло, да и чтобы приспособить его под задачу пришлось влезать в него и вносить изменения, поэтому созрела идея написать свое такое решение.
Решение полагается на то, что при генерации запросов, EF в запросе укажет имена полей для датасета, соответсвующие именам свойств объектов, которые он сгенерировал для контекста. Альтернативно, можно полагаться на порядок следования этих полей, что также работает. Чтобы извлечь запрос и параметры из запроса, используется метод ToObjectQuery, а уже на результирующем объекте используются метод ToTraceString и свойство Parameters. Далее следует простой цикл чтения, взятый из MSDN, «Изюминкой» решения являются материализаторы. PetaPoco эмитирует код материализатора в runtime, я же решил сгенерировать код для них с помощью T4 Templates. За основу взял файл, который генерирует сущности для контекста, считывая при этом .edmx, использовал из него все вспомогательные классы, и заменил непосредственно генерирующий код.
Пример сгенерированного класса:

    public class currencyMaterialize : IMaterialize<currency>, IEqualityComparer<currency>
    {
        public currency Materialize(IDataRecord input)
        {
            var output = new currency();        
            output.currency_id = (int)input["currency_id"];
            output.currency_code = input["currency_code"] as string;
            output.currency_name = input["currency_name"] as string;
            return output;
        }
    
    	public bool Equals(currency x, currency y)
        {
            return x.currency_id == y.currency_id;
        }
    
        public int GetHashCode(currency obj)
        {
            return obj.currency_id.GetHashCode();
        }
    }

Код, который эмитирует PetaPoco, условно идентичен этому, что в том числе подтвержаются одинаковым временем исполнения.
Как видно, класс также реализует интерфейс IEqualityComparer, из чего уже должно быть понятно, что на объектах, материализованных таким образом, обычное сравнение ReferenceEquals уже не работает, в отличие от объектов, которые материализует EF, и для того, чтобы сделать в памяти Distinct, и нужно было такое дополнение.

Результат изысканий я оформил в виде Item Template и опубликовал в галерее Visual Studio. Краткое описание, как использовать, там присутствует. Буду рад, если кого то заинтересует решение.

Автор: VladVR

Источник

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


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