.NET Tutorial
Junior6 мин чтенияВопрос 5 из 14

Lazy Loading vs Eager Loading vs Explicit Loading?

Модель для примеров

C#
public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;          // reference navigation
    public List<OrderItem> Items { get; set; } = [];         // collection navigation
}
public class OrderItem { public int Id { get; set; } public int ProductId { get; set; } public Product Product { get; set; } = null!; public int Qty { get; set; } }

По умолчанию EF Core не загружает навигационные свойства: после db.Orders.First() свойство Customer будет null, а Items — пустым списком (если связанные сущности не были загружены ранее в тот же контекст — тогда EF «подцепит» их через fix-up).

Три стратегии

БДEF CoreКодEager: IncludeLazy: обращение к свойствуExplicit: явный LoadOrders.Include(Items).ToList()SELECT orders JOIN items (1запрос)Orders.ToList()SELECT ordersorder.Items (в цикле)SELECT items WHEREorder_id = 1SELECT items WHEREorder_id = 2 ... (N запросов)Entry(order).Collection(Items).-Load()SELECT items WHEREorder_id = 1
БДEF CoreКодEager: IncludeLazy: обращение к свойствуExplicit: явный LoadOrders.Include(Items).ToList()SELECT orders JOIN items (1запрос)Orders.ToList()SELECT ordersorder.Items (в цикле)SELECT items WHEREorder_id = 1SELECT items WHEREorder_id = 2 ... (N запросов)Entry(order).Collection(Items).-Load()SELECT items WHEREorder_id = 1
Когда выполняются запросы

Eager loading

C#
var orders = await db.Orders
    .Where(o => o.CreatedAt >= from)
    .Include(o => o.Customer)                            // reference
    .Include(o => o.Items)                               // collection
        .ThenInclude(i => i.Product)                     // следующий уровень
    .Include(o => o.Items.Where(i => i.Qty > 0).OrderBy(i => i.Id))  // filtered include (EF Core 5+)
    .AsSplitQuery()                                      // отдельный запрос на каждую коллекцию
    .ToListAsync(ct);

Lazy loading

C#
// Вариант 1: прокси (пакет Microsoft.EntityFrameworkCore.Proxies)
builder.Services.AddDbContext<ShopDbContext>(o => o.UseNpgsql(conn).UseLazyLoadingProxies());

public class Order
{
    public int Id { get; set; }
    public virtual Customer Customer { get; set; } = null!;        // virtual — обязательно
    public virtual ICollection<OrderItem> Items { get; set; } = [];
}

var order = await db.Orders.FirstAsync();
var name = order.Customer.Name;          // здесь — скрытый SELECT customers WHERE id = ...

EF создаёт динамический класс-наследник (прокси), переопределяющий virtual-свойства: при первом get он выполняет запрос. Поэтому классы не должны быть sealed, а навигации — virtual.

Проблемы lazy loading:

  • N+1: цикл по 100 заказам с обращением к Customer = 101 запрос.
  • Синхронный I/O: ленивая загрузка выполняется синхронно — блокирует поток в async-коде.
  • Сериализация: System.Text.Json обходит навигации и лениво загружает весь граф (или падает на циклах).
  • После уничтожения контекста — исключение (или тихий null при отключённом предупреждении).

Explicit loading

C#
var order = await db.Orders.SingleAsync(o => o.Id == id, ct);

if (needItems)
    await db.Entry(order).Collection(o => o.Items).LoadAsync(ct);

await db.Entry(order).Reference(o => o.Customer).LoadAsync(ct);

// С фильтром и агрегатами — без загрузки всей коллекции
var bigItems = await db.Entry(order).Collection(o => o.Items).Query().Where(i => i.Qty > 10).ToListAsync(ct);
var count = await db.Entry(order).Collection(o => o.Items).Query().CountAsync(ct);

Полезно, когда решение «нужны ли связанные данные» принимается по ходу выполнения логики.

Проекция — чаще всего лучший выбор для чтения

C#
var dto = await db.Orders
    .Where(o => o.Id == id)
    .Select(o => new OrderDetailsDto(
        o.Id,
        o.Customer.Name,                                   // навигация внутри Select → JOIN, без Include
        o.Items.Select(i => new OrderItemDto(i.Product.Name, i.Qty)).ToList(),
        o.Items.Sum(i => i.Qty * i.Product.Price)))
    .SingleOrDefaultAsync(ct);
  • Загружаются только нужные столбцы.
  • Нет отслеживания, нет Identity Map — проекции в DTO не отслеживаются.
  • Include не нужен: навигации в Select транслируются в JOIN/подзапросы.

Сравнение

Eager (Include) Lazy Explicit Проекция (Select)
Когда запрос Сразу При обращении к свойству Когда вызвали Load Сразу
Число запросов Известно (1 или по одному на коллекцию) Непредсказуемо (N+1) Контролируется 1 (обычно)
Загружаемые данные Все столбцы сущностей Все столбцы Все / по фильтру Только нужные
Отслеживание Да (если не AsNoTracking) Да Да Нет
Подходит для Изменения агрегата, загрузка графа Десктоп-приложения, прототипы Условная загрузка Чтение для API/UI

нет

да

да

только иногда

избегать в веб-API

Будете изменять
сущности и сохранять?

Проекция Select в DTO

Связанные данные нужны
всегда?

Include / ThenInclude
(AsSplitQuery для нескольких коллекций)

Explicit loading

Lazy loading

нет

да

да

только иногда

избегать в веб-API

Будете изменять
сущности и сохранять?

Проекция Select в DTO

Связанные данные нужны
всегда?

Include / ThenInclude
(AsSplitQuery для нескольких коллекций)

Explicit loading

Lazy loading

Выбор стратегии загрузки

Вопросы на засыпку

Что такое relationship fix-up?

Если связанные сущности уже отслеживаются контекстом, EF автоматически заполняет навигационные свойства при загрузке новых сущностей. Пример: загрузили клиентов, потом заказы — order.Customer окажется заполнен без Include. Это может сбивать с толку при отладке.

Работает ли Include с AsNoTracking?

Да. Но без Identity Map одинаковые связанные сущности материализуются как разные объекты (например, один Customer для 100 заказов → 100 экземпляров). AsNoTrackingWithIdentityResolution() устраняет дубли без полного отслеживания.

Как включить lazy loading без прокси?

Внедрить ILazyLoader (или делегат Action<object, string>) в конструктор сущности и вызывать _lazyLoader.Load(this, ref _customer) в геттере. Сущность получает зависимость от EF — поэтому применяется редко.

Почему Include игнорируется в некоторых запросах?

Если запрос заканчивается проекцией (Select не в сущность), Include бессмыслен и игнорируется — данные определяются самой проекцией.

Итог

Eager loading делает загрузку явной и предсказуемой, lazy loading удобен, но прячет запросы и порождает N+1, explicit loading даёт контроль для условных сценариев. Для чтения в API лучше всего проекции в DTO, для изменения агрегатов — Include с AsSplitQuery при нескольких коллекциях.