Многие, кто использует EF Core в работе в качестве ORM (Object-Relational Mapping) встречаются с множеством прелестей этого инструмента:

  • Достаточная гибкость

  • Скорость разработки

  • Возможность поддерживать Legacy

  • Написание хранимок и прочее...

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

Покопавшись дальше я увидел сообщение о том, что *100 записей были добавлены в ChangeTracker.

Странно, но я же просто открыл страницу и нафига мне тут отслеживать изменения?

После я сразу же полез в код и обнаружил, что в части проекта напрочь забыто про такую вещь как Tracking behavior

И тут ради интереса мне захотелось посмотреть влияние трэкинга на быстродействие запросов не формате "Так однозначно быстрее", а с пруфами и метриками. Ссылки на статьи прикреплю ниже*

Перейдем к результатам

Изначально я собрал проект на .NET Core и подключил пакеты и создал тестовую базу данн/ых на PostrgeSQL (ссылку на github прицеплю ниже).

код бенчмарка
public class Benchmark
{
    [Benchmark]
    public async Task<int> WithTracking()
    {
        using var dbContext = new AppDbContext();
        var products = await dbContext.Product.AsTracking().ToListAsync();
        return products.Count;
    }
    [Benchmark]
    public async Task<int> WithoutTracking()
    {
        using var dbContext = new AppDbContext();
        var products = await dbContext.Product.AsNoTracking().ToListAsync();
        return products.Count;
    }

    [Benchmark]
    public async Task<int> WithTrackingAndOrder()
    {
        using var dbContext = new AppDbContext();
        var products = await dbContext.Product.AsTracking().OrderBy(x => x.Name).ToListAsync();
        return products.Count;
    }


    [Benchmark]
    public async Task<int> WithoutTrackingAndOrder()
    {
        using var dbContext = new AppDbContext();
        var products = await dbContext.Product.AsNoTracking().OrderBy(x => x.Name).ToListAsync();
        return products.Count;
    }
}

Итак, 1-й набор данных на 100 записей:

Method

Mean

Error

StdDev

WithTracking

521.6 us

10.28 us

11.43 us

WithoutTracking

395.2 us

6.43 us

5.70 us

WithTrackingAndOrder

683.7 us

12.76 us

13.11 us

WithoutTrackingAndOrder

548.2 us

10.76 us

20.72 us

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

Далее - 1.000 записей в таблице

Method

Mean

Error

StdDev

WithTracking

2,193.3 us

38.75 us

36.25 us

WithoutTracking

833.0 us

12.46 us

10.41 us

WithTrackingAndOrder

3,707.3 us

55.82 us

46.61 us

WithoutTrackingAndOrder

2,073.3 us

26.42 us

24.71 us

Здесь мы видим уже существенный (быстрее более чем в 2 раза) прув от трэкинга.
Что дальше?

10.000 записей

Method

Mean

Error

StdDev

Median

WithTracking

30.820 ms

0.5955 ms

1.5895 ms

30.323 ms

WithoutTracking

7.939 ms

0.1398 ms

0.1308 ms

7.942 ms

WithTrackingAndOrder

42.891 ms

0.8493 ms

0.7944 ms

42.776 ms

WithoutTrackingAndOrder

22.248 ms

0.4247 ms

0.3765 ms

22.346 ms

В простом случае (без учета OrderBy) мы видим, что опция AsNoTracking работает в 4 раза быстрее. И в то же время AsNoTracking и OrderBy дают непропорциональные результаты результаты. Я думаю, что это связано с оптимизациями производительности, внедренными в EF Core и работой с коллекциями.

100.000 записей

Method

Mean

Error

StdDev

WithTracking

312.45 ms

5.936 ms

6.351 ms

WithoutTracking

81.35 ms

1.508 ms

1.411 ms

WithTrackingAndOrder

453.65 ms

8.072 ms

7.551 ms

WithoutTrackingAndOrder

208.51 ms

4.040 ms

4.149 ms

Как видите, результата очень схож и коррелирует с выборкой в 10.000 записей.

Ради интереса, 1млн записей

Method

Mean

Error

StdDev

WithTracking

3,511.5 ms

70.10 ms

122.77 ms

WithoutTracking

851.1 ms

13.34 ms

12.48 ms

WithTrackingAndOrder

4,083.4 ms

49.27 ms

43.68 ms

WithoutTrackingAndOrder

1,634.6 ms

31.82 ms

40.25 ms

Тенденция схожа и никаких коллизий тут не отмечено.

Выводы, кэп?

Мы обсудили и сравнили результаты с одним из параметров - AsNoTracking.
Как видно, этот параметр значительно влияет на эффективность запросов и обработку данных, и он работает в 4 раза быстрее на наборе данных из более чем 10 000 объектов.

Как жить?
1) Вы можете установить это значение по-умолчанию как AsNoTracking, и, при необходимости, когда вам потребуется отслеживать изменения в объектах, вы сможете получить объекты с помощью метода AsTracking

services.AddDbContext<DatabaseContext>(options =>
{
  options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
  ...
});

2) Использовать маппинг в DTO-модели при получении данных и избегать загрузку всех связанных сущностей и ненужных данных

public List<ProductDTO> GetProducts()
{
    return _context.Products
      .Select(x => _mapper.Map<ProductDTO>(x))
      .ToArray();
}

3) Избегать загрузки больших коллекций в память, которые планируется править.

Берите на заметку и быстрых решений!

Ссылки:

Контакты:

Комментарии (29)


  1. Vitimbo
    08.11.2023 11:56
    +1

    Разве C# программист не должен вбить в себя привычку прописывать AsNoTracking везде, кроме мест, где этого быть не должно?


    1. CrazyElf
      08.11.2023 11:56
      +1

      Я обычно никогда не прописывал, просто потому что обычно в "сыром" виде сущности EF в доменной модели не нужны. Их обычно тут же преобразуют в объекты домена, как-то аггрегируют и т.д.


      1. Vitimbo
        08.11.2023 11:56

        То есть через .Select(x => new Dto....) получаете новый объект? Разве в таком случае не следует также отключать отслеживание для быстродействия?


        1. microuser
          08.11.2023 11:56

          В таком случае объект ef даже не создаётся, поэтому и отключать нечего. А в общем имеет смысл делать две модели, одну для запросов на чтение без трекинга, и на запись с трекингом


          1. AgentFire
            08.11.2023 11:56

            Почему не создаётся? Там пулинг по дженерику?

            А, все,понял, акцент на слове ef.


        1. mushegovdima Автор
          08.11.2023 11:56

          в таком случае нет смысла отключать трэйкинг, ибо он и не будет использоваться (он работает только если напрямую доставать объекты классов из базы)


    1. Getequ
      08.11.2023 11:56

      Откуда программисту на С# вообще узнать о AsNoTracking если он работал только с NHibernate, а про EF слышит только на презенташках/новостях от MS ?

      Молодёж и подавно не знает об msdn


      1. Vitimbo
        08.11.2023 11:56

        Если человек пользуется технологией, но не знает, как она работает, то проблема явно не в трекинге или другом фреймворке. Молодежь также не знает, что такое nhibernate, поскольку все туториалы предлагают использовать ef. Если только не заниматься археологией. А понятние о трекинге всплывает довольно быстро.


  1. CrazyElf
    08.11.2023 11:56
    +4

    Что-то я не понял, в таблицах везде написано, что WithoutTracking дольше работает, чем WithTracking. Данные перепутаны местами?


    1. mushegovdima Автор
      08.11.2023 11:56
      -1

      Да, похоже на то... Переставлял для удобства анализа. Сейчас перезалью результаты)


  1. CrazyElf
    08.11.2023 11:56

    А по сути да, обычно редко когда делают коллекции прямо вот из сущностей EF. Делают из них DTO, а потом бизнес-объекты. А если нужно что-то заменить в базе, то заново читают из БД сущность с тем же id, что у той, которая в бизнес-объекте, и её уже изменяют и сохраняют обратно. Держать в памяти тонны сущностей EF в любом случае плохая практика. Ну, если только вы их не планируете так же массово изменять и писать обратно в БД.


  1. iamkisly
    08.11.2023 11:56
    +3

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

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

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

    Интерес в комментариях же из-за низкого порога вхождения в язык, и большого количества нахватавшихся по верхам неофитов.. я бы советовал прочесть тот же Entity Framework Core in Action, тем более есть отличный перевод от dotnet.ru


    1. mushegovdima Автор
      08.11.2023 11:56
      +1

      Интерес личный был именно в том, "насколько"?
      Запилил, поделился :)
      Мне кажется именно в этом цель сообщества


      1. MyraJKee
        08.11.2023 11:56

        На Хабре требования к качеству материала весьма высоки... Господа программисты тут оооочеень разборчивы... )


  1. Alekseyz
    08.11.2023 11:56

    Давно уже есть Queryable Extensions

    ProjectTo<ProductDTO>(configuration)

    вместо

    Select(x => _mapper.Map<ProductDTO>(x))


    1. mushegovdima Автор
      08.11.2023 11:56

      Давно есть, использую) Закинул тут для большей наглядности :)


    1. senglory
      08.11.2023 11:56

      А насколько это быстрее/лучше по памяти чем Dapper?


  1. sh-vasily
    08.11.2023 11:56

    Интересно было бы сравнить такой вариант по скорости с dapper


    1. iamkisly
      08.11.2023 11:56

      Вопрос в том как и что сравнивать.. билдить запрос из модели? a кэшировать expression tree? или в обоих случаях сырым запросом? С параметрами или конкатенацией? Ещё бы проконтролировать что БД один и тот же план запроса использует во всех случаях.


  1. lexxei_ru
    08.11.2023 11:56

    После spring приложений дали поддерживать проект на EF. Честно говоря это дикий ужас, кто эти люди, которые предлагают писать бэк на этом...


    1. mushegovdima Автор
      08.11.2023 11:56

      хах, знакомо :)
      В прошлом копался в NHibernate, но переезд был быстр и понятен.
      Тут можно развернуться по-сути, особенно если нужно "Побыстрее и еще вчера"


    1. iamkisly
      08.11.2023 11:56
      +4

      Могу сказать тоже самое про spring. Ощущения субъективны, так что поменьше пафоса)


    1. hVostt
      08.11.2023 11:56
      +3

      EF шикарный, просто лучший! NHibernate -- калька с Hibernate, вот это действительно был ужас, я с дрожью вспоминаю проекты на нём, очень тяжёлые и монструозные решения, но тогда и альтернатив особо не было. Сейчас же, на EF писать прям кайф. Я не знаю что там за "дикий ужас" вы узрели, но либо перед вами там так наговнокодили, что даже умудрились EF через одно место использовать, или у вас просто религиозные шоры, аля, спринг уан лав, остальное всё УГ — тут уже ничего не попишешь. Ведь что для настоящего инженера важно? Правильно, объективность в топку, цвет фломастеров наше всё :)


      1. lexxei_ru
        08.11.2023 11:56
        -1

        Да там понаписали костылей до меня, что проект на VisualStudio даже не собирается, только под Rider можно запустить. Тут в целом наверное претензия наверное не к EF, а в целом .net платформе, если не legacy проект выглядит ужасно. Но скорее всего это разработчики такие веселые были. Помимо спринга еще писал на Django. Там тоже как то всё намного понятнее


      1. lexxei_ru
        08.11.2023 11:56

        про Hibernate и в спринге хорошего сказать не могу - слишком много приносит ошибок, которые на первый взгляд кажутся неадекватными


  1. ColdPhoenix
    08.11.2023 11:56

    А ещё советую узнать про ExecuteUpdate и ExecuteDelete.


  1. Naves
    08.11.2023 11:56

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

    IEnumerable или IQueryable


    1. nronnie
      08.11.2023 11:56

      Наверное, с БД работали через первый (IEnumerable), а не через второй, и в итоге в приложение тянулась вся таблица, я прав? :))


  1. zerg903
    08.11.2023 11:56

    установить это значение по-умолчанию как AsNoTracking

    Если в коде есть разделение ответственности на команды и запросы, то можно выделить 2 интерфейса для контекста:

    public interface IDbReadOnly : IDisposable, IAsyncDisposable
    {
      IQueryable<Order> Orders { get; }
      IQueryable<Product> Products { get; }
      // ...
    
      IQueryable<T> Query<T>() where T : class, IModel;
    }
    
    public interface IDb : IDbReadOnly
    {
      void Add<T>(T entry) where T : class, IModel;
      void Remove<T>(T entry) where T : class, IModel;
      void Attach<T>(T entry) where T : class, IModel;
    
      Task SaveAsync(CancellationToken cancellationToken = default);
    }
    
    public class AppDb : DbContext, IDb
    {
      // ...
    }
    

    Соответственно, запросы используют IDbReadOnly, для которого на уровне DI контейнера включено AsNoTracking.