Showing posts with label NHibernate. Show all posts
Showing posts with label NHibernate. Show all posts

Monday, April 20, 2009

NHibernate – где узнать больше?

Я вот довольно много пишу про Entity Framework и LINQ to SQL, но мало про другие ORM, особенно про NHibernate. Исправляюсь.

Ни для кого ни секрет, что несмотря на то, что NHibernate – это очень мощный ORM, он достаточно сложен в освоении. Как модно сейчас говорить, у него слишком крутая кривая обучения (learning curve), на которую многим тяжело забраться, особенно в условиях сжатых сроков и рисков. И несмотря на то, что у наших друзей из java-community O/R Mappers в целом, и Hibernate в частности пользуются популярностью уже давно, бОльшая часть .NET-программистов не пользовались этим продуктом, а многие даже и не знали о нем до выхода аналогов от Microsoft: LINQ to SQL и Entity Framework. К сожалению, .NET-сообщество программистов слишком часто не обращает внимания на то, что исходит не от Microsoft, или не доверяет этому, поэтому до недавнего времени NHibernate был интересен лишь очень небольшому проценту посвященных.

К слову, во многом это именно наша локальная проблема. В других странах существует так называемое ALT.NET-сообщество (в основном, опенсорсной направленности), которые проводят свои конференции и встречи, обсуждая на них альтернативные инструменты и подходы к разработке приложений, отличающиеся от инструментов и подходов Microsoft, тем самым рекламируя их и продвигая. Многие из лидеров этого сообщества выступают с критикой продуктов от Microsoft, являясь по своей сути оппозицией в терминах, понятных нашей стране :) К слову сказать, благодаря этой “оппозиции” мы имеем достаточно серьезное движение Microsoft навстречу сообществу, более серьезное внимание к нуждам программистов, открытые исходные коды и целые проекты, идущие по open-source лицензиям.

Но это, скорее всего, вы и так уже знали… А заметка эта на самом деле о том, где узнать больше о NHibernate. К сожалению, на данный момент существует лишь одна книжка по NHibernate: NHibernate in Action, которая до нашей страны дойдет, уверен, не скоро. Однако, есть довольно много альтернативных источников информации, которые, надеюсь, будут вам полезны:

http://www.nhibernate.org/ – официальный сайт, где есть полная документация

http://nhforge.org/ – ресурс, посвященный NHibernate, на котором есть Getting Started Guide, блоги, wiki, и еще много чего полезного

http://blogs.hibernatingrhinos.com/nhibernate/Default.aspx – NHibernate FAQ, фактически блог, который ведут разные авторы, много полезной информации

http://ayende.com/Blog/category/510.aspx – категория NHibernate в блоге Oren Eine, одного из контрибьюторов (разработчиков) NHibernate, который в последнее время постит очень много информации по маппингу – на мой взгляд, самой высокой ступеньке в кривой обучения NHibernate

http://summerofnhibernate.com/ – видео обучалки (скринкасты) по NHibernate, для тех, у кого восприятие визуальной информации происходит лучше, чем восприятие текстовой, или кому просто нужно быстро разобраться на простых примерах

http://www.codeproject.com/KB/architecture/NHibernateBestPractices.aspx – NHibernate best practices при разработке ASP.NET приложений

http://www.codeproject.com/KB/database/Nhibernate_Made_Simple.aspx – еще одна интересная статья с CodeProject.com, вкратце обо всем

http://www.theserverside.net/tt/articles/showarticle.tss?id=NHibernate – аналогично предыдущей, только с другого ресурса

Также я вам советую посмотреть следующие полезности, разработанные для NHibernate:

http://sourceforge.net/projects/nhcontrib – NH Contrib, много полезных компонентов

http://code.google.com/p/sharp-architecture/ - Sharp Architecture – фреймворк для разработки веб-приложений с использованием ASP.NET MVC и NHibernate, рекомендуется для тех, кому нужны шаблоны использования NH с MVC, и кто хочет использовать готовый каркас приложения

http://nhplugin.lieser-online.de/ – поддержка NH в ReSharper, здесь добавить просто нечего

И, напоследок, еще парочка постов, похожих на мой, в которых вы найдете эти и другие полезные ссылки:

http://www.theserverside.net/news/thread.tss?thread_id=46642

http://www.thefreakparade.com/2008/07/so-you-want-to-learn-nhibernate-part-1-of-1-the-links/

Удачи в изучении NH.

Sunday, February 22, 2009

Немного о проектировании: паттерны из мира ORM

Наверняка, каждый программист слышал о паттернах (шаблонах) проектирования, читал классику, и с той или иной периодичностью использует паттерны в повседневной работе для того, чтобы сделать дизайн своего приложения более понятным и масштабируемым. Если вы когда-нибудь пробовали использовать Linq to SQL, Entity Framework, NHibernate или другие ORM, то вы, конечно же, заметили, что у них есть много общего. L2S и EF с некоторого расстояния вообще выглядят, как близнецы-братья, да и остальные ORM тоже недалеко ушли. Вы думаете, это совпадение? Вовсе нет. Просто все они строятся с использованием одних и тех же паттернов проектирования.

Примечание. Хотел бы отметить, что использовать мы будем терминологию небезызвестного Мартина Фаулера, который является общепризнанным авторитетом в дизайне приложений, и его каталог "архитектурных" паттернов.

Начнем с того, что каждый ORM имеет в своем составе некий "менеджер" (например, контекст в L2S и EF, или сессию в NH), который является неким логическим фасадом (Facade) к базе данных. Этот фасад, как правило, "понимает" определенный маппинг объектов приложения на таблицы базы данных, "умеет" принимать на вход простые и сложные запросы на получение объектов, знает, "какие" изменения объектов и "как" сохранить в базе данных, и многое другое. Рассмотрим, какие паттерны работают внутри и рядом с ним.

Unit of Work

Unit of Work - наверно, самый популярный паттерн модификации данных. Он описывает следующую стратегию работы с данными:

  1. Получили объекты из ORM
  2. Изменили какое угодно их количество
  3. Сказали ORM сохранить данные в базу (или откатиться)
  4. ORM сохранил все изменения одним махом (или откатился)

Даже если вы никогда в жизни в глаза не видели ORM, то знакомо, не правда, ли? Конечно, именно по такому же принципу работает старая-добрая пара DataAdapter - DataSet в plain ADO.NET. И точно так же работают как контексты L2S и EF, так и сессия NH. Unit of Work работает благодаря тому, что он хранит в себе ссылки на все объекты, которые были созданы/получены через него. Именно поэтому без дополнительных телодвижений вам не удастся заставить контекст EF сохранить объект, который был изменен в рамках другого контекста, а потом приаттачен к текущему. Контекст просто не знает, какие изменения были сделаны в объекте, т.к. это не забота объекта "знать" о своих изменениях, а забота окружения, коим и является для объекта контекст/сессия.

Паттерн Unit of Work полезно использовать и в других случаях. Например, когда вам необходимо дать пользователю возможность изменить определенные данные приложения в памяти, а потом нажатием на одну кнопку сохранить их в базу данных. Или не сохранить.

Active Record

В противовес предыдущему паттерну, паттерн Active Record определяет, что объект сам знает, как себя извлечь из базы данных и сохранить изменения туда же. То есть объекту больше не нужны внешние "менеджеры", он сам в состоянии за себя постоять.

В целом, использование этого паттерна остается достаточно спорным, т.к. объект в таком случае "знает" о способе сохранения себя в базе, и поэтому крепко связан с ней, нарушая при этом Persistence Ignorance. Кроме того, если подобный объект содержит бизнес-логику, то он еще и нарушает Single Responsibility Principle. С другой стороны, у Active Record есть свои преимущества и сторонники. Из широко распространенных ORM паттерн ActiveRecord используется как минимум в Castle ActiveRecord (кто бы мог подумать :)). Да и Ruby-on-Rails, насколько я слышал, его уважает.

Identity Map

Identity Map - это паттерн, который дает возможность загрузить лишь один экземпляр объекта в память. То есть, если при первом обращении к определенному объекту ORM "поднимает" экземпляр этого объекта из базы, но все последующие запросы, которые будут приводить к получению этого объекта (даже если этот объект будет частью списка других объектов), будут всего лишь получать ссылку на уже созданный объект, а не его копию. Работает это благодаря тому, что внутри ядра ORM есть реализация этого паттерна. Контекст/сессия, перед тем, как материализовать (создать) объект с определенным Id, сначала просматривают это хранилище на предмет существования объекта, и если он не найден, регистрируют новый материализованный объект в хранилище.

Что это нам дает, как программистам. Прежде всего, если мы работаем с одним и тем же контекстом/сессией, то мы можем легко сравнивать объекты по ссылке (==). Кроме того, и что более важно, если мы уже начали изменять объект, а потом вдруг он появился в результате какой-то вспомогательной выборки, то мы можем быть уверены, что это один и тот же измененный объект (конечно, если вы напрямую не сказали при выборке, что вам необходим "чистый" объект). Ну, и напоследок, как side effect, мы получаем небольшое кеширование в рамках контекста/сессии, что позволяет уменьшить затраты на повторную материализацию объекта (весьма немаленькие). Естесственно, объекты, полученные из разных конекстов/сессий, не обладают подобными свойствами, что также необходимо понимать и учитывать.

Identity Field

Упомянув Identity Map, нельзя обойти стороной Identity Field. Этот небольшой паттерн, по сути, всего лишь говорит о том, что каждый объект содержит в себе уникальный ключ, который позволяет ORM правильно идентифицировать объекты у себя внутри. В частности, в EF такую функцию выполняет класс EntityKey, который состоит из названия сущности и значений первичного ключа.

Как вы уже догадались, Identity Field интенсивно используется для решения задач, описанных предыдущими паттернами.

Query Object

Паттерн Query Object реализует в себе запрос к данным. У Фаулера написано, что этот объект должен сам знать, как превратить себя в SQL-запрос, но очень часто паттерн рассматривается более широко, описывая прежде всего способ составления сложных запросов к ORM.

В NH есть механизм запросов Criteria, который можно отнести к реализации этого паттерна. В L2S и EF эту функцию выполняет язык запросов LINQ, который также можно назвать его реализацией (хотя и с большой натяжкой).

Lazy Load

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

Lazy Load в том или ином виде реализован практически во всех известных мне ORM для улучшения производительности. Ведь не всегда ваш объект Заказ должен загружать сразу и список Продуктов - они могут просто не понадобится в данной транзакции, так зачем тратить на это время и ресурсы?

Repository

Я не мог пройти мимо этого паттерна, хотя на самом деле он не реализован ни в L2S, ни в EF, ни в NH, ни в AR. (Возможно, реализован в других ORM, подскажите, если знаете.) Не мог пройти, потому что паттерн Repository очень часто используется при работе с NH (1 и 2), да и примеры для других ORM уже подоспели, например, для L2S (1 и 2).

С простой точки зрения, этот паттерн определяет фасад, который предназначен для того, чтобы быть промежуточным слоем между доменной моделью (бизнес-логикой) и источником данных. Весь код приложения за пределами репозитория работает с базой данных (или другим источником данных) через репозиторий. Однако, если смотреть на него с точки зрения пуристов DDD, то репозиторий - это уже нечто другое. Всех желающих погрузиться в нирвану DDD отправляю к следующему источнику, который достаточно неплохо описывает Repository с точки зрения DDD. Не заблудитесь только там :)

Заключение

Предлагаю пока что ограничится данными паттернами, т.к. они являются основными паттернами поведения ORM. Тех же, кто хочет узнать больше, отправляю к Фаулеру, в каталоге которого описаны еще с десяток других паттернов, связанных с другими аспектами работы Data Access в целом, и ORM в частности.

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

Thursday, November 13, 2008

Сравнение производительности .NET ORM: Часть 2. Генерация SQL-запросов

И снова здравствуйте. В первой части серии я постарался рассмотреть производительность L2S, EF и AR/NH по сравнению с классическим ADO (тестовое приложение можно найти здесь). NET и друг с другом. Для этого были написаны специальные тесты, но замеры мы производили на достаточно высоком уровне, не выделяя в результатах базу данных. Как я и обещал, во второй части я бы хотел опуститься ниже, в базу, и сравнить запросы, генерируемые нашими подопытными. Должен сказать, что я был несколько удивлен полученными результатами, но обо всем по порядку.

Способ сравнения

Способ сравнения я выбрал до предела простой. У нас уже есть написанный код для получения продуктов по заказчику. Это не ахти какой сложный запрос, конечно, но тем не менее что-то мы из него получим. При этом у нас есть два варианта получения данных: одним запросом с джойнами (тест CustomerProducts) или при помощи простых операций вроде получения сначала заказчика, потом его заказов, потом деталей заказа и уж в самом конце – продуктов, то есть при помощи пачки запросов (тест CustomerProductsComplex).

Значит, заряжаем SQL Server Profiler, запускаем наши тесты, вытаскиваем пойманные SQL-запросы, садим их в Management Studio, включаем “Include Actual Execution Plan”. Я сделал 4 теста. В первом сравниваются запросы из теста CustomerProducts для всех четырех способов доступа к данным. В последующих трех я сравнивал попарно запросы для теста CustomerProducts и для теста CustomerProductsComplex для каждого из ORM отдельно (для классического ADO.NET я не делал этот тест).

Какова цель этих тестов? Цель первого теста – сравнить качество генерируемого SQL-кода по сравнению с конкурентами и самым простым SQL-запросом, написанным вручную и получающим эти же данные. Цель остальных тестов – выяснить, какой способ получения данных, через один или несколько запросов, выгоднее и насколько.

Итак, поехали.

Сюрпризы

Уже на проведении первого же теста (того, где должен быть один результирующий запрос) меня ждало несколько интересных открытий:

  1. L2S зачем-то сгенерировал два запроса вместо одного. Первый запрос у них вытаскивает заказчика, а второй уже выполняет реальную работу. Я проверил свой код – вроде бы там простой LINQ-запрос, который должен превратиться в один SQL-запрос. Более того, такой же LINQ-запрос в EF действительно превращается в один SQL-запрос. Что ж, сами напросились, L2S будет тестироваться с двумя запросами, тем более что первый запрос по сравнению со вторым выполняется почти моментально, поэтому какая разница.
  2. AR/NH пошел еще дальше, чем L2S. Я ожидал, что написанный мною HQL-запрос выльется в один SQL-запрос. Не срослось. NH сгенерировал и выполнил 23 запроса. Но, присмотревшись к природе этих запросов, я увидел, что это запросы к таблице Supplier, которая в тесте никак не участвует, но зато есть в моем маппинге. Я понял, что, по всей видимости, не отключил lazy loading, поэтому просто в очередной раз отметил свое незнание AR/NH, исправил свойство Supplier класса Product на SupplierID, а атрибут [BelongsTo] поменял на [Property], после чего просто перезапустил тесты и получил корректные SQL-запросы. Замечание: Да, вы правильно подумали, результаты общих тестов для AR из предыдущей части тоже учитывают эти дополнительные запросы, но исходя из того, что я увидел в результатах выполнения тестов, мы не получили здесь особого прироста производительности.
  3. Я наконец-то убедился, что L2S и EF писали разные люди :) Даже несмотря на то, что я специально сделал LINQ-запросы в L2S и EF идентичными, генерируемые SQL-запросы отличаются. Более того, они отличаются не только текстом, но и производительностью, что вы увидите в результатах.

Анализ результатов

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

В первом тесте я получил следующие результаты:

Классический ADO.NET – 14%
LINQ 2 SQL – 2 + 48 = 50%
Entity Framework – 22 %
Active Record (NHibernate) – 14%

Вот и еще один сюрприз: запрос L2S оказался существенно медленнее запроса EF, не говоря уже про победителей – NH и наш контрольный запрос. Зато NH превзошел все ожидания. HQL очень чисто трансформировался в SQL, его план выполнения в точности совпал с планом выполнения контрольного запроса. План выполнения EF тоже неплох, а вот L2S намудрил. Да, здесь есть важный момент: это всего лишь ОДИН тест, на основании которого не стоит судить об ORM в целом. Вполне возможно, есть запросы, где намудрит EF или даже NH. В любом случае, среднее время выполнения этих запросов намного меньше результирующего времени, учитывающего дополнительные расходы на построение запроса и материализацию результата (в среднем где-то 50-150 мс согласно профайлеру). И тут уже L2S несомненно опережает EF и AR/NH с большим отрывом.

Во втором тесте я сравнил сложный запрос с джойнами и набор простых запросов для L2S:

Сложный запрос с джойнами – 25%
Набор простых запросов – 75%

Как видите, один запрос выиграл, что неудивительно. Однако, в целом, можно сказать, что с точки зрения базы данных потери на lazy loading не такие уж и большие.

Третий тест был посвящен EF:

Сложный запрос с джойнами – 18%
Набор простых запросов – 72%

Как видите, результаты похожи до безобразия.

И, наконец, в четвертом тесте я сравнил запросы для NH:

Сложный запрос с джойнами – 6%
Набор простых запросов – 94%

Вот уж где для увеличения производительности стоит отказаться от удобных переборов коллекций в пользу HQL, так это здесь :)

Выводы

Какие выводы мы можем сделать по результатам этих тестов?

  1. Прежде всего, если у вас есть возможность выполнить один сложный запрос – выполняйте, не бегайте по коллекциям и не инициализируйте их отдельно, это немного сэкономит вам драгоценное время. Особенно это касается ситуаций, где bottleneck находится в базе данных, а не внутри ORM.
  2. Похоже, оптимизация запросов в L2S сделана немного хуже, чем в EF. Я слышал, что в EF team привлекли очень толковых специалистов по оптимизации запросов, так что, возможно, это результат их работы.
  3. Как показывает практика, генерируемые запросы достаточно неплохи по сравнению с контрольным, так что основная проблема в производительности ORM находится все-таки за пределами базы данных, а именно внутри ORM, в алгоритмах генерации SQL-запроса, работы с контекстом и материализации данных.
  4. Я протестировал LINQ-запросы для L2S и EF. Было бы еще интересно посмотреть, как с заданием справится eSQL (Entity SQL) для EF. Если у кого-то есть опыт – поделитесь.
  5. Аналогично я хотел бы узнать у специалистов насчет HQL в NH. Насколько я понял, в NH есть еще и альтернативный способ создавать сложные запросы через джойны – при помощи критериев. Какие у них плюсы и минусы?

Бонус

Ну, и напоследок бонус тем, кто дочитал до этого места :) Собственно, сгенерированные сложные запросы с джойнами для NH, EF и L2S. Не пугайтесь их, они не такие страшные, как кажутся на первый взгляд :)

NHibernate:

exec sp_executesql N'select product2_.ProductID as ProductID4_, product2_.ProductName as ProductN2_4_, product2_.UnitPrice as UnitPrice4_, product2_.SupplierId as SupplierId4_ from [Order Details] orderdetai0_ inner join Orders order1_ 
on orderdetai0_.OrderID=order1_.OrderID inner join Products product2_ on orderdetai0_.ProductID=product2_.ProductID where (product2_.ProductID=product2_.ProductID )AND(order1_.OrderID=order1_.OrderID )AND(order1_.CustomerID=@p0 
)',N'@p0 nvarchar(5)',@p0=N'BERGS'

Entity Framework:

exec sp_executesql N'SELECT 
[Project3].[CustomerID] AS [CustomerID], 
[Project3].[C1] AS [C1], 
[Project3].[C3] AS [C2], 
[Project3].[OrderID] AS [OrderID], 
[Project3].[C2] AS [C3], 
[Project3].[ProductID] AS [ProductID], 
[Project3].[ProductName] AS [ProductName], 
[Project3].[QuantityPerUnit] AS [QuantityPerUnit], 
[Project3].[UnitPrice] AS [UnitPrice], 
[Project3].[UnitsInStock] AS [UnitsInStock], 
[Project3].[UnitsOnOrder] AS [UnitsOnOrder], 
[Project3].[ReorderLevel] AS [ReorderLevel], 
[Project3].[Discontinued] AS [Discontinued], 
[Project3].[CategoryID] AS [CategoryID], 
[Project3].[SupplierID] AS [SupplierID]
FROM ( SELECT 
    [Extent1].[CustomerID] AS [CustomerID], 
    1 AS [C1], 
    [Project2].[OrderID] AS [OrderID], 
    [Project2].[ProductID] AS [ProductID], 
    [Project2].[ProductName] AS [ProductName], 
    [Project2].[SupplierID] AS [SupplierID], 
    [Project2].[CategoryID] AS [CategoryID], 
    [Project2].[QuantityPerUnit] AS [QuantityPerUnit], 
    [Project2].[UnitPrice] AS [UnitPrice], 
    [Project2].[UnitsInStock] AS [UnitsInStock], 
    [Project2].[UnitsOnOrder] AS [UnitsOnOrder], 
    [Project2].[ReorderLevel] AS [ReorderLevel], 
    [Project2].[Discontinued] AS [Discontinued], 
    [Project2].[C1] AS [C2], 
    [Project2].[C2] AS [C3]
    FROM  [dbo].[Customers] AS [Extent1]
    LEFT OUTER JOIN  (SELECT 
        [Extent2].[OrderID] AS [OrderID], 
        [Extent2].[CustomerID] AS [CustomerID], 
        [Project1].[ProductID] AS [ProductID], 
        [Project1].[ProductName] AS [ProductName], 
        [Project1].[SupplierID] AS [SupplierID], 
        [Project1].[CategoryID] AS [CategoryID], 
        [Project1].[QuantityPerUnit] AS [QuantityPerUnit], 
        [Project1].[UnitPrice] AS [UnitPrice], 
        [Project1].[UnitsInStock] AS [UnitsInStock], 
        [Project1].[UnitsOnOrder] AS [UnitsOnOrder], 
        [Project1].[ReorderLevel] AS [ReorderLevel], 
        [Project1].[Discontinued] AS [Discontinued], 
        [Project1].[C1] AS [C1], 
        1 AS [C2]
        FROM  [dbo].[Orders] AS [Extent2]
        LEFT OUTER JOIN  (SELECT 
            [Extent3].[OrderID] AS [OrderID], 
            [Extent4].[ProductID] AS [ProductID], 
            [Extent4].[ProductName] AS [ProductName], 
            [Extent4].[SupplierID] AS [SupplierID], 
            [Extent4].[CategoryID] AS [CategoryID], 
            [Extent4].[QuantityPerUnit] AS [QuantityPerUnit], 
            [Extent4].[UnitPrice] AS [UnitPrice], 
            [Extent4].[UnitsInStock] AS [UnitsInStock], 
            [Extent4].[UnitsOnOrder] AS [UnitsOnOrder], 
            [Extent4].[ReorderLevel] AS [ReorderLevel], 
            [Extent4].[Discontinued] AS [Discontinued], 
            1 AS [C1]
            FROM  [dbo].[Order Details] AS [Extent3]
            LEFT OUTER JOIN [dbo].[Products] AS [Extent4] ON [Extent3].[ProductID] = [Extent4].[ProductID] ) AS [Project1] ON [Extent2].[OrderID] = [Project1].[OrderID] ) AS [Project2] ON [Extent1].[CustomerID] = 
[Project2].[CustomerID]
    WHERE [Extent1].[CustomerID] = @p__linq__1
)  AS [Project3]
ORDER BY [Project3].[CustomerID] ASC, [Project3].[C3] ASC, [Project3].[OrderID] ASC, [Project3].[C2] ASC',N'@p__linq__1 nvarchar(5)',@p__linq__1=N'BERGS'

LINQ 2 SQL:

exec sp_executesql N'SELECT [t0].[CustomerID]
FROM [dbo].[Customers] AS [t0]
WHERE [t0].[CustomerID] = @p0',N'@p0 nvarchar(5)',@p0=N'BERGS'

exec sp_executesql N'SELECT [t2].[ProductID], [t2].[ProductName], [t2].[SupplierID], [t2].[CategoryID], [t2].[QuantityPerUnit], [t2].[UnitPrice], [t2].[UnitsInStock], [t2].[UnitsOnOrder], [t2].[ReorderLevel], [t2].[Discontinued], (
    SELECT COUNT(*)
    FROM [dbo].[Order Details] AS [t3]
    INNER JOIN [dbo].[Products] AS [t4] ON [t4].[ProductID] = [t3].[ProductID]
    WHERE [t3].[OrderID] = [t0].[OrderID]
    ) AS [value]
FROM [dbo].[Orders] AS [t0]
LEFT OUTER JOIN ([dbo].[Order Details] AS [t1]
    INNER JOIN [dbo].[Products] AS [t2] ON [t2].[ProductID] = [t1].[ProductID]) ON [t1].[OrderID] = [t0].[OrderID]
WHERE [t0].[CustomerID] = @x1
ORDER BY [t0].[OrderID], [t1].[ProductID]',N'@x1 nchar(5)',@x1=N'BERGS'

Monday, November 10, 2008

Сравнение производительности .NET ORM: Часть 1. Выборки данных

Сегодня я хотел бы начать серию заметок о сравнении производительности Entity Framework, LINQ to SQL, Active Record (NHibernate) и классического ADO.NET. Я еще не знаю, во что выльется эта идея, и к чему мы придем в результате, но основными целями этого сравнения я бы назвал:

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

Сразу скажу, что, несмотря на то, что я уже почти год использую Entity Framework, я не буду стараться его выгораживать. Откровенно говоря, сейчас мы в нашем проекте подошли к той границе, за которой bottleneck начинается уже в самом Entity Framework, а я пока и близко не в восторге от производительности нашей системы. Поэтому я постараюсь быть максимально объективным в данном вопросе, а если все же где-нибудь буду говорить чушь, вы меня поправите.

Изначально я хотел выяснить производительность EF и L2S, а также сравнить их с классическим ADO.NET, т.к. последний на данный момент является наиболее быстрым способом доступа к базе данных. Естественно, любая ORM будет медленнее. Потом меня эта идея настолько увлекла, что я для полноты картины добавил еще и Active Record, который, как известно, базируется на NHibernate. Еще один важный момент: я вижу NHibernate впервые в жизни, поэтому: а) не судите строго тот код, который я с горем пополам в нем написал, б) если у вас есть советы и комментарии, как по коду, так и по улучшению производительности – буду счастлив услышать :)

Все, довольно вступлений. Итак, для начала я покажу результаты моих тестов read-запросов. В следующих заметках серии я постараюсь уделить время генерируемым запросам, а также CUD-операциям. Пока же ограничимся выборками.

Используемые тесты

Использовал я всеми любимую базу Northwind, в частности таблицы Customers, Orders, Order Details и Products. Для каждого из способов доступа к данным я сделал 8 простых тестов:

Тест Описание Цель
Инициализация Создание контекста, инициализация, чтение строки подключения Получить время инициализации, возможно, оптимизировать его
Получение всех заказов Простая выборка всех строк из таблицы Orders Самый простой запрос, в то же время можно сравнить время материализации, т.к. там больше 800 записей
Многократное получение всех заказов Выборка всех строк из таблицы Orders 3 раза Сравнить степень кеширования запросов и их результатов
Получение всех продуктов одного заказчика Один сложный запрос через 3 таблицы Сравнить эффективность генерации SQL-скриптов, материализация минимальна
Многократное получение всех продуктов одного заказчика Тот же запрос 3 раза То же самое + кеширование
Получение всех продуктов одного заказчика (сложная версия) Пошаговое получение того же результата, что и в предыдущем случае. Берем коллекцию, проходимся foreach'ем, берем связанные записи и т.д. Сравнить данный способ обращения к данным с предыдущим
Многократное получение всех продуктов одного заказчика (сложная версия) То же самое 3 раза То же самое + кеширование
Микс: получение заказов + продуктов заказчика Синтетический тест, эмулирующий разные запросы Посмотреть, как влияют результаты выполнения одного запроса на следующий
Тест более серьезной нагрузкой Все предыдущие запросы последовательно за один присест Попробовать нагрузить базу большим количеством последовательных запросов, и посмотреть, что получится

Код я здесь приводить не буду, а лучше дам вам ссылочку, где можно скачать приложение, чтобы глянуть запросы и код и надавать автору по голове за него посоветовать мне, что и как можно улучшить :) Для запуска приложения вам понадобится база Northwind, VS2008 SP1 (там есть EF и L2S), а также установленный Active Record (скачать можно здесь). Не забудьте сконфигурировать строки подключения к базе и параметры Active Record в классе ActiveRecordProvider (да, знаю, что за такое нужно отбивать руки, но для текущей задачи такой реализации с головой).

Пара слов перед тем, как показать результаты. Тестировал я достаточно просто, вручную запуская один тест за другим, но в то же время я старался учесть кеширование запросов в SQL Server'е. Не знаю, насколько мне это удалось, но я старался запускать каждый тест отдельно вразнобой, и с некоторыми временными интервалами. К сожалению, тесты с базой данных – это такая вещь, которую бывает трудно воспроизвести, результаты варьируются, но, надеюсь, показанные числа дадут хотя бы какое-то общее представление о порядках. В любом случае, вы можете скачать исходники, установить L2S, EF, AR (если еще не установлены) и поиграться сами. Машинка у меня дома относительно небыстрая (Athlon 64 3200+ с 3 гигами мозгов), поэтому делайте скидку и на это.

Анализ результатов

Итак, результаты в миллисекундах:

Test Classic L2S EF NH/AR
Initialization 15 78 101 410
Orders 125 172 1078 1062
OrdersMultiple 187 218 1093 1187
CustomerProducts 109 234 1328 828
CustomerProductsMultiple 125 281 1344 1156
CustomerProductsComplex 140 464 1453 937
CustomerProductsComplexMultiple 203 511 1515 1109
Mixed 140 296 1265 1187
All 187 515 1656 1546

Что сразу бросается в глаза, так это тотальное отставание EF и AR от L2S (в 3-6 раза), а также то, насколько быстро L2S работает с базой – в среднем всего лишь где-то в 2 раза медленнее. При ближайшем рассмотрении можно сделать вывод, что AR работает быстрее, чем EF, по крайней мере, в запросах, возвращающих небольшое количество данных. У меня есть подозрение, что AR медленнее материализует объекты, чем EF (запрос по Orders – самый простой, но в то же время самый большой по количеству данных), но это нужно взять выборку побольше, чтобы проверить. При этом инициализация контекста у EF происходить почти так же быстро, как и у L2S, а AR тут немного отстает, хотя это и не страшно. Что еще важно: все ORM отлично справляются с кешированием повторных запросов: 3 запроса выполняются ненамного дольше, чем один. Связано это, прежде всего, с тем, что они кешируют не только материализованные объекты, чем существенно экономят время, но и, в случае EF – деревья промежуточных запросов (CQT, Canonical Query Tree). Так что одинаковые и даже похожие запросы выполняются намного быстрее. Также показателен последний тест: он дает возможность увидеть, что каждый запускаемый поодиночке тест все-таки несет определенный накладные расходы, потому что один общий тест лишь очень ненамного превышает самый медленный из своих компонентов. А это говорит о том, что НЕ НУЖНО создавать контексты на каждый запрос (это касается, в первую очередь L2S и EF), как бы вас этому ни учили в умных статьях. Если хотите добиться хорошей производительности – старайтесь использовать контексты как можно дольше, но в разумных пределах. Мы вот у себя нашли этот самый разумный предел – контекст живет в пределах обработки одной страницы, то есть контекст создается на каждый HttpRequest и умирает вместе с ним. В сложных случаях многостраничных диалогов мы продлеваем время жизни контекста, перемещая его временно в сессию.

Теперь пара слов о том, почему же мы видим такие результаты. Ну, во-первых, L2S у нас завязан лишь на один SQL Server и, если я не ошибаюсь, не строит никаких промежуточных CQT, чтобы общаться с тысячей различных провайдеров, как это делает EF. Во-вторых, EF – это не просто ORM, способная работать с разными базами данных и на лету подменять их без изменения кода. EF также обладает различными продвинутыми фичами маппинга, которых как минимум нет в L2S (не буду говорить про NH, ибо не знаю всех его возможностей). В-третьих, судя по проанализированным мною сгенерированным SQL-запросам, здесь особой разницы между ORM нет (подробнее об этом мы поговорим в следующей заметке), поэтому сюда особо рыть не стоит. И, в-четвертых, EF еще только-только зарелизился, думаю, мы еще увидим более быстро работающие версии.

Советы по использованию подопытных

Итак, определенные советы по использованию этих ORM (в широком смысле, L2S – это тоже ORM) можно дать уже сейчас. Если вам нужно написать простенькое приложение – используйте L2S – это САМЫЙ быстрый способ. Накидали табличек в базу, сгенерировали модель 2-мя кликами мыши – и вуаля, можно работать. Для более серьезных приложений уже нужно смотреть внимательнее. С точки зрения скорости написания кода равных L2S и EF нет. При всем уважении, AR/NH пока медленнее за счет того, что нет тула по созданию и поддержке модели. Если вам нужны продвинутые возможности маппинга, вы делаете разработку в рамках DDD или нужна поддержка различных баз данных – смотрите в сторону AR/NH и EF. Тут уже что больше по душе. Если же у вас на первом месте производительность и вы работаете лишь с MS SQL Server – тогда вам, наоборот, нужно смотреть либо в сторону L2S, либо вообще в сторону классического ADO.NET. Однако, в случае с классическим ADO.NET время разработки, а также последующей поддержки Data Access Layer увеличивается не просто в разы, а на порядки. Да, и пусть вас не смущают результаты запросов секунда и выше – как можно заметить, реально занимают время первые запросы, а остальные идут очень быстро и по накатанной. Более того, могу сказать по опыту, что где-то для 50-60% приложений производительности EF и AR/NH должно хватить с головой. Так что, если у вас не rocket science, не стоит заморачиваться.

Все это напоминает классическую проблему менеджмента проектов: можно выбрать одновременно лишь два пункта из трех: либо мы уменьшаем время разработки и набор функциональности, но тогда увеличиваем стоимость, либо другие вариации на тему. Или другой пример: выбор спальника :) Спальник может быть либо легким и теплым, но дорогим, либо легким и дешевым, но тогда холодным, либо теплым и дешевым, но тогда тяжелым. Выбирайте ;)

Надеюсь, что в свете того, что у нас теперь появляется все больше выбора, я смог пролить хотя бы немного света на производительность различных способов обращения к данным. Если у кого-то уже сейчас есть комментарии по улучшению производительности той или иной ORM – пожалуйста. Мои тесты покрывают лишь дефолтные настройки.

Полезные ссылки:

Тестовый проект для сравнения производительности L2S, EF и AR/NH между собой и с классическим ADO.NET

Exploring the Performance of the ADO.NET Entity Framework - Part 1
Exploring the Performance of the ADO.NET Entity Framework - Part 2
ADO.NET Entity Framework Performance Comparison
Entity Framework Instantiation Times with a 120-Table Database
Linq to SQL vs NHibernate Part 1: What do they have in common?