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

    В процессе работы над проектом веб-портала, я исследовал возможности улучшить производительность, и наткнулся на небольшую статью про микро-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. Краткое описание, как использовать, там присутствует. Буду рад, если кого то заинтересует решение.
    Share post

    Similar posts

    Comments 26

      +4
      Какой sql получился из двух запросов?
      А нельзя было написать функцию (inline TVF) в sql и дергать её из контекста с материализацией?
      О чем статья вообще?
        0
        Статья о том, что материализация в EF работает медленно, очевидно производится много действий, нужных для функционала EF. Для поставленной задачи, предполагавшей лишь собрать некоторую статистику по базе и отобразить, этот функционал не был нужен, соотвественно материализация «в обход» EF дала очень хороший прирост. Сам запрос никак не менялся при этом. С функциями, как и с хранимыми процедурами, и вообще с написанием какого либо sql это никак не пересекается.
          0
          Вызовите AsNoTracking() на датасете (если Вам не нужно кеширование\трекание изменений) и получите еще прибавку в производительности: по моим подсчетам раза в 2-3 (на 10к сущностей).
            0
            Потестировал немного, Сейчас запрос(группа запросов) немного другой, чуть полегче стал, после внесения некоторых изменений, полегче, чем тогда, когда я писал это решение. Время исполнения full EF: 2600ms, AsNoTracking: 570ms, IMaterialize: 430ms
            Остается вопрос — как сделать, чтоб Distinct заработал?
              0
              А просто Distinct() не работает? На сколько я понимаю, EF реализует Identity Map, так что каждая сущность загрузится только один раз (с одним Primary Key) и стандартный EqualityComparer, который сравнивает референсы, должен сделать свою работу.

              Если не работает или Вам нужен Distinct по проекциям, то можно попробовать вот такие методы:
              Code
              List<Person> distinctPeople = allPeople
                .GroupBy(p => p.PersonId)
                .Select(g => g.FirstOrDefault())
                .ToList();
              


              List<Person> distinctPeople = allPeople
                .GroupBy(p => new {p.PersonId, p.FavoriteColor} )
                .Select(g => g.FirstOrDefault())
                .ToList();
              


                0
                Объекты полученные с использованием AsNoTracking не попадают в Identity Map, cоответственно ReferenceEquals не работает.
                Вручную писать такие методы для более 300 сущностей — не вариант, даже если нужны будут меньше половины. Кроме этого, есть сущности не имеющие Identity, то есть нужно перечислить все поля.

                Пока что я склоняюсь к тому, чтобы либо генерировать IEqualityComparer, либо partial.

                И кстати, не лучше ли было написать вот так?
                 allPeople
                  .ToDictionary(p => p.PersonId, p => p)
                  .Values.ToList();
                

                  0
                  Это часть запроса. Сгенерируется sql, который вытащит первую из уникальных строчек.
                  В общем, я бы аггрегировал информацию в базе и вытаскивал уже готовые уникальные значения.
                    0
                    Зачастую производительность достигается варьированием нагрузки. Если запрос слишком сложный, и нагружает базу, то часть вычислений можно перенести в память, на портал, или соответственно наоборот.
                    Тут есть две стратегии, Union на базе либо Distinct в памяти, обе имеют право на жизнь, и не хотелось бы просто так лишаться одной из них. Собственно partial классы сгенерировать несложно, раз уж нельзя чем нибудь из EF воспользоваться
                      0
                      Зачастую производительность достигается варьированием нагрузки.

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

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

                      Только в двух случаях такое «варьирование нагрузки» помогает:
                      1) Логика невыразима в терминах TSQL, например рекурсии или moving average
                      2) База очень маленькая (до 1ГБ), тогда выгоднее всосать все в память приложения и использовать навигационный доступ между объектами.
                        0
                        Из примеров — есть случай, когда нужно сделать группировку по двум полям и выбрать максимум по id, т.е. отсеять повторения, заранее известно, что повторений меньше 5%. Опять же, порталов несколько, а база одна.

                          0
                          И, кстати, Union это тоже такой же случай. Два разнородных запроса, достающие одну и ту же сущность, помещенные в Union работают в разы, вплоть до 10 раз медленнее, чем выполненные по отдельности и дистинктнутые в памяти.
                            0
                            Хотя нет, тут опять вмешалась тормозная материализация в EF. Когда запрос не включает в себя Union, имена полей в датасете совпадают с именами полей в таблицах и работают т.н. precompiled views. Что возвращает обратно к IMaterialize.

                            В Management Studio точность замеров какая то низкая, погрешность плавает, но тем не менее даже так видно, что Union работает где то на треть медленнее.
                      0
                      Покажи схему и скажи что надо получить. Коллективный разум позволит родить запрос.
              +1
              А где замеры, статистика, графики?
                +2
                Есть штука, называется linq2db. По сути тот же Dapper, но с LINQ-провайдером в комплекте. Делает автор Bltoolkit. Существенно шустрее EF работает.
                  0
                  Никаких замеров в инернетах я не нашел. У вас есть? Поделитесь.
                    0
                    Можете считать, что по производительности оно как Bltoolkit (тот же автор, по сути просто очистка bltoolkit от лишнего хлама и груза обратной совместимости), а он в своё время по бенчмаркам лидировал (см. вторую диаграмму, которая Performance).
                      0
                      Этож старье. Там ef еще первой версии.
                        0
                        Так прирост за счёт большей легковесности никуда не делся. Если есть время, можете потестировать на свежем EF, набор тестов тут.
                          0
                          По моим замерам там прирост на десятые доли микросекунд на объект. Этож сколько надо материализовать чтобы тормоза были заметны? Если в секунду материализуешь более миллиона объектов, то скорее всего что-то не так делаешь и проблема вовсе не в EF или другом ORM.
                            0
                            Ну надо прогнать бенчмарки на актуальных версиях и тогда уже смотреть.
                    0
                    Раз уж вы в теме, можно вопрос?
                    Работал с BLToolkit, всем доволен, всем рекомендую. LINQ никогда не использовал ввиду ненадобности, но я знаю, что к BTL что-то такое прикручивалось. Тогда в чём отличие LINQ2DB от BLT?
                      0
                      linq2db разрабатывается активно, а в BLToolkit только баги иногда чинят. И вообще всем рекомендуют мигрировать со второго на первое.
                        0
                        Почти аргумент :) Ладно, уже скачал — пробую. То есть я так понял, LINQ — наше всё? Может, есть что-то такое на SQL, чего нельзя выразить в LINQ?
                          0
                          Дофига такого, но для этого можно и SQL написать.
                    0
                    Забавно, я работаю с автором PetaPoco на одном проекте, расскажу ему про вашу статью.

                    Only users with full accounts can post comments. Log in, please.