Связи, 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 и сервисы не соединить правильно.