Связи, Include и производительность

Связи, Include и производительность: AsNoTracking и борьба с N+1.

Суть: у сущностей бывают связи (у пользователя — много заказов). EF Core умеет их загружать через Include. А для производительности read-only запросов используют AsNoTracking() — он отключает трекинг и ускоряет чтение.

Реальные данные связаны: пользователь и его заказы, заказ и его позиции. Неправильная загрузка связей — главный источник тормозов в EF Core. Разберём, как делать правильно.

Навигационные свойства и Include

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Order> Orders { get; set; } = new();  // навигация
}

// Жадная загрузка связанных заказов одним запросом
var users = await _db.Users
    .Include(u => u.Orders)
    .AsNoTracking()
    .ToListAsync();

Include говорит EF подгрузить связанные заказы в том же запросе (через JOIN). Без него user.Orders будет пустым или вызовет дополнительный запрос.

Проблема N+1

БЕЗ Include (lazy/ручная подгрузка в цикле):
  1 запрос: получить 100 пользователей
  + 100 запросов: на каждого подгрузить заказы
  = 101 запрос (N+1)

С Include:
  1 запрос с JOIN -> пользователи и заказы сразу

Как работает под капотом

По умолчанию EF Core отслеживает каждую загруженную сущность: хранит её копию, чтобы при SaveChanges понять, что изменилось. Для чистого чтения это лишняя работа и память. AsNoTracking() отключает трекинг: объекты возвращаются «отсоединёнными», EF их не запоминает — меньше памяти, быстрее запрос. Минус: изменения таких объектов SaveChanges не подхватит (их надо явно Attach). Поэтому правило простое: читаете для отображения — AsNoTracking; собираетесь менять — оставляйте трекинг.

Частые ошибки

  • N+1 запросов. Обращение к навигации в цикле без Include равно десяткам лишних запросов. Профилируйте SQL.
  • AsNoTracking плюс попытка сохранить. Изменения отсоединённого объекта молча не сохранятся.
  • Include всего подряд. Жадно тянуть все связи раздувает запрос; грузите только нужное, лучше проецируйте в DTO.

Best practices

  • Для read-only списков и страниц используйте AsNoTracking() — заметный прирост на больших выборках.
  • Связи грузите осознанно: Include для нужных, или Select с проекцией только требуемых полей.
  • Включите логирование SQL и проверяйте число запросов — так ловят N+1 на раннем этапе.

Стратегии загрузки связей

EF Core предлагает несколько способов подтянуть связанные данные. Жадная загрузка (eager) через Include/ThenInclude — связи грузятся вместе с основной сущностью одним запросом (JOIN или split query). Явная загрузка (explicit) — вы подгружаете связь по требованию вызовом Entry(...).Collection(...).LoadAsync(). Ленивая загрузка (lazy) — связь грузится автоматически при первом обращении к навигации; удобно, но коварно: именно она чаще всего порождает скрытые N+1. Самый предсказуемый и рекомендуемый для веб-API подход — eager loading либо проекция в DTO.

Проекция через Select часто лучше Include: вы запрашиваете ровно нужные поля (например имя пользователя и количество заказов), и EF строит компактный SQL без вытягивания целых связанных таблиц. Меньше данных по сети, меньше памяти, нет лишнего трекинга. Для эндпоинтов чтения, отдающих DTO, проекция — обычно оптимальный выбор.

Трекинг, N+1 и профилирование

Change tracking — это цена за удобство записи: EF хранит снимок каждой отслеживаемой сущности, чтобы вычислять изменения. Для чтения это лишняя работа, поэтому AsNoTracking() на read-only запросах ощутимо экономит память и ускоряет материализацию, особенно на больших выборках. Платой служит то, что изменения таких объектов не сохранятся без явного Attach — но для отображения это и не нужно. Можно даже задать QueryTrackingBehavior.NoTracking по умолчанию на уровне контекста и включать трекинг точечно там, где пишете.

Проблема N+1 — самый частый перформанс-баг в EF Core: код в цикле обращается к навигационному свойству, и на каждый элемент уходит отдельный запрос, превращая 1 запрос в 1+N. Лечится она жадной загрузкой Include или проекцией. Чтобы ловить такие вещи рано, в Development включают логирование SQL и смотрят на число и форму запросов: если на один эндпоинт уходят десятки одинаковых SELECT — это сигнал. Привычка поглядывать на сгенерированный SQL отличает того, кто «использует ORM», от того, кто им управляет.

Итог: Include загружает связи одним запросом и убирает N+1, а AsNoTracking ускоряет чтение. Дальше — раздел про внедрение зависимостей, без которого EF и сервисы не соединить правильно.

Проверьте себя
1. Когда стоит применять AsNoTracking()?
AКогда собираемся менять и сохранять объекты
BДля read-only запросов: отключает трекинг, экономит память и ускоряет чтение
CВсегда, даже при сохранении
DТолько в миграциях
2. Что такое проблема N+1 и как её решить?
AЛишний один запрос, решается перезапуском
BПодгрузка связей в цикле даёт 1+N запросов; решается жадной загрузкой через Include
CОшибка компиляции
DПроблема миграций