Урок по треку C# и .NET 8 мин

EF Core без сюрпризов

DbContext, отслеживание изменений, N+1 и загрузка связей, миграции, транзакции и конкурентность.

EF Core это стандартный ORM в .NET. На собесе проверяют, понимаете ли вы, какой SQL получится: N+1, загрузка связей, отслеживание изменений, миграции и конкурентность.

DbContext

DbContext это единица работы: трекер изменений плюс доступ к DbSet<T>. Он не потокобезопасен и рассчитан на короткую жизнь: в ASP.NET Core один на запрос (AddDbContext регистрирует его как Scoped). Соединение берётся из пула ADO.NET на время операции, поэтому создавать контексты дёшево. Для фоновых задач IDbContextFactory<T>, для горячих путей AddDbContextPool.

IQueryable и момент выполнения

Запрос переводится в SQL при материализации: ToListAsync, FirstOrDefaultAsync, CountAsync, foreach.

// плохо: вся таблица в памяти
var adults = db.Users.ToList().Where(u => u.Age >= 18);

// хорошо: WHERE в SQL
var adults = await db.Users.Where(u => u.Age >= 18).ToListAsync(ct);

Метод, возвращающий IEnumerable<T> вместо IQueryable<T>, тоже переносит дальнейшие фильтры в память.

Загрузка связей и N+1

var blogs = await db.Blogs.ToListAsync();
foreach (var b in blogs)
    Console.WriteLine(b.Posts.Count);   // с ленивой загрузкой: запрос на каждый блог

Варианты:

  • Include(b => b.Posts).ThenInclude(p => p.Tags): связи в том же запросе;
  • AsSplitQuery(): по запросу на коллекцию, против декартова взрыва при нескольких Include;
  • проекция: Select(b => new BlogDto(b.Name, b.Posts.Count())), лучший вариант для чтения, считает в SQL;
  • явная загрузка Entry(b).Collection(x => x.Posts).LoadAsync().

Ленивая загрузка выключена по умолчанию, и это хорошо.

Отслеживание изменений

Каждая сущность в трекере имеет состояние: Added, Unchanged, Modified, Deleted, Detached. При загрузке EF сохраняет снимок значений, а SaveChanges через DetectChanges находит изменения и генерирует INSERT, UPDATE (только изменённых колонок), DELETE в одной транзакции.

  • AsNoTracking() для запросов только на чтение: без снимков, быстрее.
  • Remove у Added-сущности просто перестаёт её отслеживать.
  • Большой долгоживущий контекст тормозит: DetectChanges проходит все сущности.

Массовые операции без загрузки (EF Core 7+):

await db.Orders.Where(o => o.CreatedAt < cutoff).ExecuteDeleteAsync(ct);
await db.Products.Where(p => p.CategoryId == id)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.1m), ct);

Транзакции и конкурентность

SaveChanges сам транзакционный. Для нескольких сохранений await using var tx = await db.Database.BeginTransactionAsync();. При политиках повтора (EnableRetryOnFailure) ручные транзакции оборачивают в CreateExecutionStrategy().ExecuteAsync.

Оптимистичная конкурентность: свойство-токен ([Timestamp], rowversion в SQL Server, xmin в PostgreSQL). EF добавляет условие на старое значение в UPDATE, и при конфликте бросает DbUpdateConcurrencyException.

Миграции

dotnet ef migrations add AddOrderStatus
dotnet ef migrations script --idempotent -o migrate.sql
dotnet ef migrations bundle

В продакшене миграции применяют отдельным шагом деплоя (скрипт или bundle), а не Database.Migrate() при старте каждой реплики. EnsureCreated с миграциями несовместим. Опасные изменения (переименование колонки, удаление) делают в несколько релизов, чтобы старая и новая версии приложения работали с одной схемой.

Сырой SQL

FromSql($"... {param}") и ExecuteSql параметризуют интерполяцию. FromSqlRaw с конкатенацией строк это SQL-инъекция.

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

  • ToList() слишком рано.
  • N+1 и десятки одинаковых запросов в логе.
  • Отдавать сущности наружу вместо DTO.
  • Один DbContext на несколько параллельных задач.
  • InMemory-провайдер для тестов запросов.

Что спросят на собесе

  • Что такое N+1 и как его найти и исправить?
  • Зачем AsNoTracking?
  • Как EF Core понимает, какие поля изменились?
  • Как применять миграции в продакшене?
  • Как обработать одновременное редактирование записи?

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.