Перейти к основному содержимому

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 на несколько запросов (иногда быстрее).
  • Сырой SQLFromSqlRaw, 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 jsonb888.


Оптимистичная блокировка

Два пользователя открыли одну запись. Без проверки версии победит тот, кто сохранил последним — изменения первого потеряются.

Оптимистичная блокировка — в строке есть поле версии (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 — повторный прогон безопасен.

Пример переименования NameTitle

  1. Добавить колонку Title, скопировать данные из Name.
  2. Выпустить код, который пишет в оба поля.
  3. Переключить чтение на Title.
  4. В следующем релизе удалить 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
Медленный SaveChangesBulk, ExecuteUpdate
Конфликт при одновременном редактированииRowVersion
Сложный тип в колонкеValue converter
Утечка памятиDbContext не Singleton; для чтения — AsNoTracking

Краткая шпаргалка

ЗадачаИнструмент
Только чтениеAsNoTracking()
Связанные таблицыInclude, ThenInclude
Массовый UPDATEExecuteUpdateAsync
Версия строкиIsRowVersion()
Безопасное переименование колонкиExpand–contract

См. также