Хочу рассказать о Lazy Loading в Entity Framework и почему использовать его надо с осторожностью. Сразу скажу, что я предполагаю что читатель использует Entity Framework или хотя бы читал про него.

Что такое Lazy Loading?


Lazy loading это способность EF автоматически подгружать из базы связанные сущности при первом обращении к ним. Например, рассмотрим класс Trade:

    public class Trade
    {
        public int Id { get; set; }

        public virtual Counterparty Buyer { get; set; }
        public virtual Counterparty Seller { get; set; }
        public decimal Qty { get; set; }
    }

При первом обращении к свойствам Buyer или Seller они будут автоматически загружены из базы. Технически это реализовано через создание экземпляров прокси классов-наследников класса Trade. У прокси класса обращение к свойствам переопределено и содержит логику по загрузке данных из базы. Соответственно что бы механизм работал свойства должны быть виртуальными.

Что это дает?


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

А что же в этом плохого?


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

Нарушает согласованность данных.


Рассмотрим следующий пример:

public class Order
{
	public int Id {get;set;}	
	public virtual IList<OrderLine> OrderLines {get;set;}
}

public string GetOrderInfo(int orderId)
{
	using(var context = new Context())
	{
		var order = context.Orders.First(x=>x.orderId == 1);
		
		// some logic
		
		var info = string.Join(",", order.OrderLines.Select(x=>x.Name));
				
		return $"{order.Id} {info}";
	}
}

Представим что в промежутке между загрузкой заказа из базы и обращением к свойству OrderLines содержимое заказа было изменено. В результате мы получим содержимое заказа на текущий момент, а не на момент загрузки самого заказа из базы.

Может ухудшать производительность.


Как же так, спросите вы, он же предназначен для ускорения?? В теории да, а вот на практике мне не раз приходилось решать проблемы, появившиеся в результате неграмотного использования данной фичи. Простой пример:

//Рассмотрим метод загружающий информацию о сделке
public string GetTradeDescription(int tradeId)
{
	using (var context = new Context())
	{
		var trade = context.Trades.First(x => x.Id == tradeId);

		var result =  $"{trade.Id}:{trade.Buyer.Name}-{trade.Seller.Name}:{trade.Qty}";
		 
		return result;
	}
}

Вопрос, сколько запросов будет отправлено в базу? Правильно, 3. В данном случае, возможно, это и не очень то страшно.

Но давайте представим что теперь мы хотим получать информацию сразу по произвольному количеству сделок. Модифицируем наш метод:

public List<string> GetTradeDescription(params int[] tradeIds)
{
	using (var context = new Context())
	{ 
		var trades = context.Trades.Where(x => tradeIds.Contains(x.Id));

		var result = trades 
			.AsEnumerable()
			.Select(trade => $"{trade.Id}:{trade.Buyer.Name}-{trade.Seller.Name}:{trade.Qty}")
			.ToList(); 

		return result;
	}
}

В результате количество запросов возрастет пропорционально количеству запрашиваемых сделок. Представьте что при запросе информации по сотне сделок в базу полетит >100 запросов, хотя хватило бы и одного. При этом подобные ошибки не так то просто отловить на этапе разработки/ревью и подобный код может выстрелить уже в боевой среде.

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

Текучая абстракция


При использовании lazy loading наши POCO объекты перестают быть POCO, ведь теперь мы работаем с прокси которые хранят ссылку на контекст и сами ходят в базу. Что будет если пользоваться этими объектами вне контекста который их загрузил?

public void DoSomeLogic(int tradeId)
{
	var trade = LoadTrade(tradeId);
	
	Console.WriteLine(trade.Buyer.Name);// Oops, здесь мы получим ошибку The ObjectContext instance has been disposed and can no longer be used for operations that require a connection
}

private Trade LoadTrade(int tradeId)
{
	using (var context = new Context())
	{ 
		var trade = context.Trades.First(x => tradeId == x.Id);
		return trade;
	}
} 

Заключение


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

Есть альтернативный вариант, оставить lazy loading включенным но строго следить за тем что бы все навигационные свойства не были виртуальными (за тем редким исключением где руки чешутся очень необходим lazy loading).

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


  1. Varim
    31.08.2017 00:34
    +1

    В результате мы получим содержимое заказа на текущий момент, а не на момент загрузки самого заказа из базы.
    Расшифруйте эту фразу подробнее, пожалуйста.


    1. microuser Автор
      31.08.2017 00:43

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


      1. Varim
        31.08.2017 00:50
        +1

        Так оберните в одну транзакцию, да еще с каким нибудь «повышенным» уровнем изоляции, например Repeatable read.
        Еще, например, почему бы не сделать что бы в

        using(var context = new Context())

        new Context() не стартовал бы себе транзакцию, либо не стартовать транзакцию еще как нибудь?


  1. Varim
    31.08.2017 00:39
    +1

    При этом подобные ошибки не так то просто отловить на этапе разработки/ревью и подобный код может выстрелить уже в боевой среде.
    У меня в привычке всегда использовать SQL Profiler и тогда не приходится ломать голову над правильным составлением linq запросов, сразу видно если что то нужно поправить.


    1. microuser Автор
      31.08.2017 00:49

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


      1. marshinov
        31.08.2017 20:22

        И вы это очень быстро увидите, если используете miniProfiler


  1. Varim
    31.08.2017 00:44

    оставить lazy loading включенным но строго следить за тем что бы все навигационные свойства не были виртуальными
    Как тогда будет работать lazy loading?


    1. microuser Автор
      31.08.2017 00:46

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


  1. Tantrido
    31.08.2017 00:54

    Вопрос, сколько запросов будет отправлено в базу? Правильно, 3.
    Непонятно: а как правильно написать запрос, чтобы он не ухудшал производительность? Может на таких маленьких примерах Lazy Loading и не нужен, а что если нужно загрузить 100-400 тыс. записей в какой-нибудь combobox или auto-complete, и чтобы ничего не тормозило, что тогда делать? Есть реальный пример?


    1. Varim
      31.08.2017 01:10
      -1

      Два варианта.
      1) .Take(n).Skip(m); — то есть пейджинг.
      2) .Select(x=> {x.Name, un = x.User.Name}).ToList().Select(x=>x.Name+" "+un);
      синтаксис не помню, с головы привел, смысл в том что сначала тянем из базы массово нужную проекцию полей, а потом делаем конкатенацию строк.


    1. Varim
      31.08.2017 01:21

      если нужно загрузить 100-400 тыс. записей
      тогда Lazy Loading точно не нужен!
      Но это не значит что нужно убрать virtual у свойств, просто используйте Select() и явную загрузку данных (ToList(), ToArray()...).
      А уже после явной загрузки данных, делайте в памяти что хотите.


    1. mayorovp
      31.08.2017 10:05
      +3

      Чтобы ничего не тормозило — надо загружать нужные данные заранее:


      var trades = context.Trades
          .Where(x => tradeIds.Contains(x.Id))
          .Include(x => x.Buyer)
          .Include(x => x.Seller);


      1. Varim
        31.08.2017 13:00
        -2

        щас лень проверять, но думаю если по trades вызвать

        foreach(var t in trades)
        без ToList(), то будут множественные запросы к таблице Trades, которые будут тормозить систему.


        1. mayorovp
          31.08.2017 13:11
          +1

          И в чем же вы видите принципиальную разницу между foreach(var t in trades) и вызовом ToList()?


          Для справки: реализацию ToList() можно посмотреть на referencesource. И используемый конструктор можно найти там же.


        1. BratSin
          31.08.2017 14:02

          Профилировщик показывает один запрос с where in…


      1. kemsky
        31.08.2017 15:46

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


        1. mayorovp
          31.08.2017 16:29

          Это что же за инклюды у вас? И сколько это получилось — "несколько"?


          1. kemsky
            31.08.2017 20:27

            Обычные 4-6 инклюдов, какой-то системы в этом я не нашел, но поведение зависит от числа инклюдов. Причем генерация может занимать до минуты времени. Вот типичный пострадавший: https://stackoverflow.com/questions/686554/why-is-entity-framework-taking-30-seconds-to-load-records-when-the-generated-que


        1. BratSin
          31.08.2017 16:38

          А не пробовали скомпилированные запросы? ссылка


          1. kemsky
            31.08.2017 20:30

            Не так просто использовать compiled query и это не решит проблему с тормозами, которые первый раз будут все равно.


            1. BratSin
              01.09.2017 10:13
              +1

              Первый раз, да. И это не серебряная пуля, да. Но в некоторых сценариях использования может помочь. Например, если у вас серверное приложение, редко выгружаемое из памяти.
              Хотя, конечно, в идеале, хотелось бы, чтоб EF быстрее справлялся с .Include. Те числа, которые вы приводите, это уже за гранью )


  1. Varim
    31.08.2017 01:05

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

    Представим что в промежутке между загрузкой заказа из базы и обращением к свойству OrderLines содержимое заказа было изменено в базе, в другой транзакции.

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

    Если нет транзакции, а был изменен OrderLines, то получим несогласованные данные между Order и OrderLines.

    В общем эти два предложения надо раскрыть подробнее.


    1. Varim
      31.08.2017 01:15

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


  1. Varim
    31.08.2017 01:39

    Я не увидел предлагаемого вами решения.


    1. Veikedo
      31.08.2017 08:06
      -3

      Не использовать lazy loading, абстагироваться от персистенса и может даже перейти на dapper.


      1. Varim
        31.08.2017 09:58

        абстагироваться от персистенса
        Для решения какой проблемы относящейся к этой статье?


        1. Veikedo
          31.08.2017 12:43
          -1

          Для устранения протечки абстракции


  1. Odrin
    31.08.2017 12:43
    +1

    При использовании lazy loading наши POCO объекты перестают быть POCO

    Мне кажется, что использование entity только в «рамках» созданного контекста — это первое, чему учит любой туториал по EF. И за пределы слоя доступа к бд (обычно это отдельный проект?) эти объекты никогда не выходят, а мапятся на POCO.

    Вопрос, сколько запросов будет отправлено в базу? Правильно, 3.

    Опять же, оператор «Include» в любом туториале в разделе о lazy loading'е всегда упоминается. Подобные ошибки — это незнание инструмента, а не недостаток EF.


    1. mayorovp
      31.08.2017 12:49
      +2

      EF Code First рекламировался как способный понимать в качестве Entity любые POCO, а не только специально подготовленные классы. Так что вылезают за пределы слоя доступа к БД они часто.


  1. AJIEX
    31.08.2017 14:02

    Сталкивался с такой же проблемой, большое количество запросов в базу за одиночными записями.
    Индикатором, того, что EF выполняет запросы по Lazy Loading как правило является @EntityKeyValue1.
    Во первых можно сделать «eager loading»: как сказал уважаемый mayorovp добавить INCLUDE
    Во вторых можно управлять на уровне контекста: Context.Configuration.LazyLoadingEnabled = false;

    Вот тут объясняется про Lazy Loading и как с этим можно бороться.


    1. kemsky
      31.08.2017 15:47
      +1

      Очень хотелось бы иметь возможность контролировать lazy/eager выборочно, но увы, можно только на уровне контекста задать поведение для всех.


  1. lisper
    01.09.2017 09:47

    Статья верна для EF версии 6 включительно. В Entity Framework Core lazy loading отсутствует. Видимо Microsoft согласны с автором в его выводах)