← Назад ко всем статьям

Dapper и EF Core: практическое сравнение производительности в .NET

Опубликовано

Версия этой статьи в научном журнале доступна здесь: Dapper vs EF Core: A Practical Performance Comparison in .NET

Релиз бенчмарка с исходными материалами доступен здесь: efcore-vs-dapper v1.0.0

Выбор технологии доступа к данным в .NET-приложении — не просто вопрос личных предпочтений. От него зависят задержка выполнения, объём выделяемой памяти, нагрузка на сборщик мусора, удобство сопровождения, а иногда и стоимость инфраструктуры при высокой нагрузке.

Во многих корпоративных .NET-системах выбор сводится к двум популярным подходам:

  • Entity Framework Core — полноценный ORM с LINQ-запросами, отслеживанием изменений, миграциями и высокоуровневой моделью хранения данных; он позволяет быстрее разрабатывать приложения.
  • Dapper — лёгкий микро-ORM, который оставляет разработчика ближе к SQL и быстро преобразует строки результата в объекты, почти не добавляя абстракций.

Обычно это сводят к фразе: «EF Core удобнее, Dapper быстрее».

Но насколько велика разница и в каких сценариях она действительно существенна?

Чтобы ответить на этот вопрос, мы сравнили EF Core и Dapper с PostgreSQL, .NET и BenchmarkDotNet в нескольких реалистичных сценариях: чтение, JOIN, обновления и параллельные запросы.

Тестовое окружение

Бенчмарк выполнялся на PostgreSQL 17.6 с использованием .NET 10 и BenchmarkDotNet. База данных пересоздавалась в контролируемом окружении, а тестовые данные генерировались детерминированно с фиксированным начальным значением генератора случайных чисел.

Цель состояла не в измерении отдельной синтетической микрооперации, а в оценке реалистичного полного пути доступа к данным:

  • построение запроса;
  • обращение к базе данных и получение ответа;
  • преобразование строк результата;
  • сопоставление данных с объектами;
  • выделение памяти.

Тестовая схема описывала небольшой интернет-магазин с таблицами products, orders и order_items.

Схема базы данных с таблицами products, orders и order_items

Рисунок 1. Схема базы данных с таблицами products, orders и order_items.

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

В сценариях чтения EF Core использовал запросы без отслеживания изменений, чтобы исключить лишние накладные расходы. Отдельно тестировались скомпилированные запросы EF Core.

При проверке исходного кода для этого исследования мы также обнаружили и сообщили о лишнем преобразовании nullable-значения в асинхронном пути выполнения запросов Dapper. Это наблюдение относится к реализации и не учитывалось при оценке результатов бенчмарка.

Dapper GitHub Issue #2184: Unused value in QueryAsync

Почему важно выделение памяти

Задержка важна, но в управляемых средах выполнения, таких как .NET, это не единственная значимая метрика.

Если слой доступа к данным выделяет больше памяти на каждый запрос, эту память затем должен освободить сборщик мусора. При устойчивой высокой нагрузке это может повысить частоту сборок мусора и ухудшить задержку в худшем случае.

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

Поэтому бенчмарк измерял и время выполнения в микросекундах на операцию, и объём памяти, выделенной управляемой средой, в КБ или МБ на операцию.

Простое чтение: небольшое, но заметное преимущество Dapper

В сценарии поиска одного товара по ключу Dapper показал меньшее время выполнения и меньший объём выделенной памяти по сравнению с базовым вариантом EF Core.

Скомпилированные запросы EF Core ускорили путь чтения, но полностью не устранили различие в выделении памяти.

Сравнение времени выполнения SelectOneProduct и выделения памяти

Рисунок 2. Время выполнения SelectOneProduct и выделение памяти для Dapper, EF Core и скомпилированных запросов EF Core.

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

Более интересные результаты появляются с ростом сложности запросов.

Массовое чтение: с ростом объёма данных разрыв сокращается

При выборке большого числа товаров относительная разница между Dapper и EF Core уменьшалась по мере роста общего времени выполнения.

Dapper всё ещё оставался быстрее в бенчмарке, но преимущество было менее заметным, чем в более сложных сценариях.

Рост времени выполнения SelectNProducts в зависимости от числа выбранных записей

Рисунок 3. Рост времени выполнения SelectNProducts при увеличении числа выбранных записей.

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

Запросы с большим числом JOIN: Dapper выходит вперёд

Самая заметная разница проявилась в запросах с большим числом JOIN.

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

Именно здесь EF Core приходится выполнять больше работы: преобразовывать LINQ, создавать сущности, обрабатывать связи и поддерживать внутреннее состояние. Даже без отслеживания изменений в нём остаётся больше уровней абстракции, чем при прямом сопоставлении результата SQL с объектами.

Dapper, напротив, выполняет явно заданный SQL и сопоставляет строки с простыми объектами, используя меньше внутренней инфраструктуры.

Сравнение времени выполнения SelectNOrders для запросов с большим числом JOIN

Рисунок 4. Время выполнения SelectNOrders для Dapper, EF Core и скомпилированных запросов EF Core при разных значениях ItemsPerOrder.

В характерном сценарии с большим числом JOIN Dapper выполнил запрос примерно на 51,2 % быстрее.

Это не означает, что EF Core плох. Крупные графы объектов и чтение с большим числом JOIN — как раз тот тип нагрузки, при котором становятся заметны накладные расходы абстракций.

Сложность запроса и ускорение

Чтобы нагляднее показать связь между объёмом данных и сложностью запроса, мы построили тепловую карту ускорения.

Коэффициент ускорения рассчитывался так:

время EF Core / время Dapper

Таким образом, значение 2,0 означает, что в данном сценарии EF Core выполнялся примерно вдвое дольше Dapper.

Тепловая карта ускорения для разных объёмов данных и значений ItemsPerOrder

Рисунок 5. Коэффициент ускорения для разных объёмов данных и значений ItemsPerOrder.

Тепловая карта делает закономерность заметнее: преимущество Dapper обычно растёт вместе со сложностью запроса, особенно когда требуется соединить и преобразовать больше строк.

Параллельное чтение: выделение памяти становится важнее

Бенчмарк также включал сценарий чтения с высокой степенью одновременности и уровнем параллелизма 128.

В этом случае время выполнения EF Core и Dapper было почти одинаковым: преимущество Dapper составило лишь около 0,5 %.

Однако по выделению памяти картина оказалась иной.

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

Сравнение выделения памяти при параллельной работе с DOP 128

Рисунок 6. Выделение памяти при параллельной выборке при DOP = 128.

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

Сводка результатов

В бенчмарке использовался комбинированный индекс производительности, учитывающий время выполнения и выделение памяти. Он отдавал предпочтение подходам, которые были не только быстрее, но и экономнее расходовали память.

СценарийПреимущество Dapper по времениИндекс DapperИндекс EF Core со скомпилированными запросами
Простое SelectOne8,4 %1,51,3
SelectOne с фильтром16,4 %1,661,43
SelectN, 1000 записей6,7 %1,161,03
SelectN с JOIN51,2 %2,280,97
Простое UpdateOne31,6 %1,971,28
Сложное UpdateOne48,3 %2,51,3
Параллельный запрос, DOP = 1280,5 %1,121,0

Наибольшее преимущество Dapper показал в выборках с большим числом JOIN, сложных обновлениях и параллельных нагрузках, чувствительных к объёму выделяемой памяти.

Что это означает для архитектуры

Вывод не в том, что нужно «всегда использовать Dapper».

Правильнее сформулировать так:

Выбирайте подход к доступу к данным в соответствии с характером нагрузки.

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

Dapper — хороший выбор, когда в приложении есть участки, чувствительные к задержке, крупные модели для чтения, запросы с большим числом JOIN, высокая нагрузка на память или SQL, форму которого необходимо строго контролировать.

На практике во многих системах уместны оба подхода.

Например:

  • Использовать EF Core для административных CRUD-операций, внутренних инструментов и бизнес-процессов, где важнее сопровождаемость.
  • Использовать Dapper для критичных по скорости путей чтения, отчётных запросов, высоконагруженных конечных точек и сложного SQL, когда нужен полный контроль над формой запроса.

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

Скомпилированные запросы EF Core помогают, но лишь частично

Скомпилированные запросы EF Core уменьшили накладные расходы в некоторых сценариях чтения, но принципиально не изменили профиль выделения памяти.

Дело в том, что компиляция запроса — лишь одна из составляющих стоимости EF Core.

Остаются и другие затраты:

  • создание сущностей;
  • обработка связей;
  • настройка отслеживания изменений;
  • управление состоянием;
  • накладные расходы абстракций.

Скомпилированные запросы полезны, но это не волшебный переключатель, превращающий EF Core в Dapper.

Dapper быстрее, но не бесплатен

Dapper даёт больше контроля, но вместе с ним приходит и дополнительная ответственность.

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

В EF Core многие из этих задач берёт на себя фреймворк. В Dapper они остаются ответственностью команды.

Поэтому архитектурный компромисс очевиден:

Dapper может уменьшить накладные расходы во время выполнения, а EF Core — трудозатраты на разработку.

Выбор зависит от того, какой из этих видов затрат важнее для вашей системы.

Итоговый вывод

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

Скомпилированные запросы EF Core улучшили часть сценариев чтения, но в протестированной конфигурации не привели к существенному сокращению выделения памяти.

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

Если главным ограничением является скорость разработки, EF Core обычно станет разумным выбором по умолчанию.

Если главными ограничениями являются давление на память, стабильность задержек или производительность сложного SQL, Dapper заслуживает серьёзного внимания.

Наиболее практичная архитектура часто строится не на противопоставлении EF Core и Dapper.

Она использует EF Core там, где помогает абстракция, и Dapper там, где важен контроль.