EF Core — продвинутые темы
Продолжение EF Core — первая программа и обзора ORM. Здесь — темы, которые обычно всплывают в реальных проектах: лишние SQL-запросы, массовые изменения, типы в колонках, одновременное редактирование, безопасные миграции.
ORM (Object-Relational Mapping) — слой, который связывает классы C# с таблицами SQL. EF Core — реализация ORM от Microsoft для .NET.
Change Tracker и AsNoTracking
Change Tracker — внутренний механизм EF, который запоминает загруженные сущности и их изменения. При SaveChanges() EF сравнивает текущее состояние с исходным и формирует INSERT, UPDATE, DELETE.
| Режим | Когда использовать |
|---|---|
| Tracking (по умолчанию) | Редактирование сущности и сохранение |
AsNoTracking() | Только чтение — списки, отчёты, GET API |
AsNoTrackingWithIdentityResolution() | Чтение, но одна сущность с одним id — один объект в памяти |
var books = await db.Books.AsNoTracking().ToListAsync();
Для API, который только отдаёт JSON, tracking расходует память и повышает риск случайного SaveChanges() на read-only данных.
Подробнее о жизненном цикле DbContext в ASP.NET — 441, DI (Scoped).
Проблема N+1 и стратегии загрузки
N+1 — один SQL за список родителей и по одному запросу на каждого родителя за дочерние данные. При 100 пользователях это 101 запрос.
// Лишние запросы в цикле
var users = await db.Users.ToListAsync();
foreach (var u in users)
_ = u.Orders.Count;
// Один-два запроса с Include
var users = await db.Users.Include(u => u.Orders).ToListAsync();
// Проекция — только нужные поля
var dto = await db.Users
.Select(u => new { u.Name, OrderCount = u.Orders.Count })
.ToListAsync();
Стратегии
| Название | Как в EF | Комментарий |
|---|---|---|
| Eager (жадная) | .Include(), .ThenInclude() | Связанные данные сразу |
| Explicit (явная) | .Entry().Collection().LoadAsync() | Загрузка в выбранный момент |
| Lazy (ленивая) | Прокси при обращении к навигации | Скрытые запросы; в API осторожно |
Развёрнутая таблица — 44. Массовая загрузка — пакетная работа с данными.
Как найти N+1 — включите лог SQL в dev (LogTo, EnableSensitiveDataLogging) и посмотрите число SELECT на один HTTP-запрос.
Производительность запросов
- Проекция
Select— не загружайте всю сущность, если нужны два поля. AsSplitQuery()— при несколькихIncludeразбивает один тяжёлый JOIN на несколько запросов (иногда быстрее).- Сырой SQL —
FromSqlRaw,ExecuteSqlRawдля отчётов. - Логирование SQL — в dev обязательно при отладке производительности.
Сравнение EF и Dapper — 442, 46. LINQ к БД — 29.
Массовые операции (bulk)
SaveChanges() на тысячах строк отслеживает каждую сущность — это медленно и прожорливо по памяти.
| Подход | Объём | Примечание |
|---|---|---|
AddRange + один SaveChanges | Сотни–тысячи | Простой код |
ExecuteUpdate / ExecuteDelete (EF 7+) | Большие обновления без загрузки в память | Предпочтительно для массовых UPDATE |
| Сторонние bulk-пакеты | Очень большие объёмы | Дополнительная зависимость |
SqlBulkCopy, COPY в PostgreSQL | Максимальная скорость | Ручной SQL |
await db.Books
.Where(b => b.Year < 2000)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Archived, true));
Value converters
Value converter — правило "как значение C# хранится в колонке SQL".
Примеры:
- enum как строка вместо числа;
DateOnlyв колонкеdate;- список тегов как JSON.
builder.Entity<Order>()
.Property(o => o.Status)
.HasConversion<string>();
builder.Entity<Product>()
.Property(p => p.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null)!);
PostgreSQL jsonb — 888.
Оптимистичная блокировка
Два пользователя открыли одну запись. Без проверки версии победит тот, кто сохранил последним — изменения первого потеряются.
Оптимистичная блокировка — в строке есть поле версии (RowVersion, xmin). При SaveChanges EF проверяет, что версия не изменилась; иначе — DbUpdateConcurrencyException → HTTP 409 Conflict.
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public byte[] RowVersion { get; set; } = [];
}
modelBuilder.Entity<Product>()
.Property(p => p.RowVersion)
.IsRowVersion();
Миграции без простоя (expand–contract)
Миграция — версионирование схемы БД в коде (dotnet ef migrations add). В продакшене старый и новый код могут работать одновременно во время деплоя.
Практики
- Одна миграция — одно логическое изменение (проще откат).
- Сначала добавить колонку nullable, потом деплой кода, потом сделать NOT NULL.
- Не удалять колонку в том же релизе, где код перестал её читать.
dotnet ef migrations script— SQL для DBA.- Идемпотентные скрипты в CI — повторный прогон безопасен.
Пример переименования Name → Title
- Добавить колонку
Title, скопировать данные изName. - Выпустить код, который пишет в оба поля.
- Переключить чтение на
Title. - В следующем релизе удалить
Name.
Транзакции и изоляция — основы SQL, SQL Server.
Repository и Specification
Repository — слой над доступом к данным. Имеет смысл, если:
- запросы сложные и их нужно переиспользовать;
- в тестах подменяете хранилище.
Generic IRepository<T> над каждым DbSet без дополнительной логики часто только дублирует EF.
Specification — переиспользуемый фильтр Expression<Func<T, bool>> для запросов.
Структура solution — Clean Architecture.
Частые ошибки
| Симптом | Что проверить |
|---|---|
Десятки SELECT на один запрос | N+1, lazy load |
Медленный SaveChanges | Bulk, ExecuteUpdate |
| Конфликт при одновременном редактировании | RowVersion |
| Сложный тип в колонке | Value converter |
| Утечка памяти | DbContext не Singleton; для чтения — AsNoTracking |
Краткая шпаргалка
| Задача | Инструмент |
|---|---|
| Только чтение | AsNoTracking() |
| Связанные таблицы | Include, ThenInclude |
| Массовый UPDATE | ExecuteUpdateAsync |
| Версия строки | IsRowVersion() |
| Безопасное переименование колонки | Expand–contract |