EF Core
Как EF Core превращает LINQ в SQL и строки обратно в объекты, как отслеживает изменения, грузит связанные данные и живёт с миграциями в команде. Кандидата с опытом EF спрашивают не «что такое DbSet», а почему запрос тормозит, откуда N+1 и как выкатывать схему.
Перед этой темой: Коллекции и LINQ, Транзакции и блокировки.
Что такое EF Core и как он устроен: DbContext, DbSet, модель и перевод LINQ в SQL?
Заголовок раздела «Что такое EF Core и как он устроен: DbContext, DbSet, модель и перевод LINQ в SQL?»Что это
EF Core — ORM: библиотека, которая отображает классы на таблицы. Вы работаете с объектами, SQL генерирует EF.
Из чего состоит
DbContext— сессия работы с базой: выполняет запросы и сохраняет изменения.DbSet<T>— свойство контекста для запросов к таблице:db.Orders.Where(...).- Сущность — обычный класс с ключом. Свойство
IdилиOrderIdEF считает ключом по соглашению. - Провайдер — пакет под конкретную базу:
Microsoft.EntityFrameworkCore.SqlServer,Npgsql.EntityFrameworkCore.PostgreSQL.
Как пользоваться
- Унаследуйте контекст от
DbContextи объявитеDbSet<T>. - Запрос выполнится при
ToListAsync()илиFirstOrDefaultAsync(). - Изменения объектов сохранит
SaveChangesAsync().
Частая ошибка
Не задать длину строк. По умолчанию string на SQL Server становится nvarchar(max), и такой столбец не может быть ключом индекса.
Как устроено
- Модель. При первом использовании типа контекста EF строит модель: сущности, ключи, связи, имена таблиц и типы столбцов. Модель кешируется.
- Конфигурация. Приоритет: Fluent API в
OnModelCreating, затем атрибуты ([MaxLength],[Table]), затем соглашения (Id— ключ,CustomerId— внешний ключ). - Запрос. LINQ строит дерево выражений. При переборе EF ищет его в кеше запросов, при промахе переводит в SQL через провайдер.
- Материализация. Строки результата становятся объектами, сущности по умолчанию попадают в трекер изменений.
Когда что выбирать
| Способ конфигурации | Когда |
|---|---|
| Соглашения | всё, что им соответствует |
| Атрибуты | простые ограничения рядом со свойством: [MaxLength], [Precision] |
| Fluent API | связи и каскадное удаление, фильтрованные индексы, конвертеры значений, глобальные фильтры |
Ловушки
- Протекающая абстракция. Не всякое выражение переводится в SQL. С EF Core 3.0 непереводимый
Whereбросает исключение, а не выполняется в памяти. - Конфигурация вразнобой. Если атрибут и Fluent API противоречат друг другу, побеждает Fluent API, и по классу сущности этого не видно.
Правило
Сущности — простые классы, конфигурация — в классах IEntityTypeConfiguration<T> через ApplyConfigurationsFromAssembly, сгенерированный SQL — под контролем.
Под капотом
- Модель строится при первом обращении к типу контекста. Для сотен сущностей это заметная часть холодного старта, поэтому её генерируют заранее:
dotnet ef dbcontext optimize(compiled model). С EF Core 9 такая модель подхватывается безUseModel, если лежит в сборке контекста. Есть ограничения: например, не поддерживаются lazy-loading-прокси. - Конвейер запроса: дерево выражений → раскрытие навигаций → SQL-дерево провайдера → текст SQL и делегат материализации. Результат кешируется по форме дерева, захваченные переменные становятся параметрами.
- Сам
DbContextлёгкий: соединение берётся из пула ADO.NET на время команды и сразу возвращается, если нет открытой транзакции.
Компромиссы
- Продуктивность против контроля. Типобезопасные запросы, трекинг и миграции экономят код, но каждый неочевидный запрос надо проверить глазами.
- Семантика провайдера. Один LINQ на SQL Server и PostgreSQL даёт разный SQL: коллация SQL Server обычно нечувствительна к регистру, PostgreSQL — чувствительна. InMemory-провайдер этого не покажет, поэтому тестируют на настоящей базе.
- Модель против схемы. Ошибку маппинга компилятор не видит, она приходит как
SqlException: Invalid column name.
Как проверить в проде
- SQL и время команд пишет категория логов
Microsoft.EntityFrameworkCore.Database.Command. Значения параметров видны только сEnableSensitiveDataLogging, в проде его не включают. - Время первой операции после старта показывает цену построения модели.
Итог: EF Core — компилятор LINQ в SQL поверх общей модели; его ценность — продуктивность, цена — обязанность понимать генерируемый SQL.
using Microsoft.EntityFrameworkCore;
using var db = new ShopContext();var maxPrice = 100m;
// LINQ строит дерево выражений, провайдер SQL Server переводит его в SQLvar query = db.Products .Where(p => p.Price < maxPrice) .OrderBy(p => p.Name) .Select(p => new { p.Id, p.Name });
// ToQueryString не обращается к базе: только показывает SQLConsole.WriteLine(query.ToQueryString());
public class ShopContext : DbContext{ public DbSet<Product> Products => Set<Product>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");
// Fluent API: главнее атрибутов и соглашений protected override void OnModelCreating(ModelBuilder model) => model.Entity<Product>(e => { e.ToTable("Products", "sales"); // схема и имя таблицы e.Property(p => p.Name).HasMaxLength(200); // nvarchar(200), а не nvarchar(max) e.Property(p => p.Price).HasPrecision(18, 2); // decimal(18,2) });}
public class Product{ public int Id { get; set; } // ключ по соглашению public required string Name { get; set; } public decimal Price { get; set; }}Вывод (EF Core 10): переменная maxPrice стала параметром, константа попала бы в SQL как есть.
DECLARE @maxPrice decimal(18,2) = 100.0;
SELECT [p].[Id], [p].[Name]FROM [sales].[Products] AS [p]WHERE [p].[Price] < @maxPriceORDER BY [p].[Name]- Когда строится модель? — При первом использовании типа контекста, затем кешируется на весь процесс.
- Что главнее: атрибуты или Fluent API? — Fluent API; атрибуты главнее соглашений.
- Как увидеть SQL запроса? —
ToQueryString()без выполнения или логирование черезLogToиILogger. - Во что превращается
stringна SQL Server по умолчанию? — Вnvarchar(max); длину задаютHasMaxLengthили[MaxLength]. - Почему InMemory-провайдер плох для тестов запросов? — Он не реляционный: нет SQL, транзакций и семантики настоящей базы.
Как работает отслеживание изменений и SaveChanges?
Заголовок раздела «Как работает отслеживание изменений и SaveChanges?»Что это
Контекст следит за загруженными сущностями. Вы меняете свойства обычных объектов, а EF сам решает, какой SQL нужен.
Состояния сущности
| Состояние | Что сделает SaveChanges |
|---|---|
Added |
INSERT |
Unchanged |
ничего |
Modified |
UPDATE изменённых столбцов |
Deleted |
DELETE |
Detached |
ничего: контекст не знает об объекте |
Как пользоваться
- Загрузите сущность запросом — она
Unchanged. - Измените свойство — к сохранению она станет
Modified. AddиRemoveпереводят объект вAddedиDeleted.await db.SaveChangesAsync()отправит все изменения разом.
Частая ошибка
Загружать с отслеживанием данные только для показа. Для чтения пишите AsNoTracking(): запрос быстрее и не занимает память трекера.
Как устроено
- Запрос. Отслеживающий запрос материализует сущность и запоминает снимок её значений. Если сущность с таким ключом уже отслеживается, возвращается тот же объект без перезаписи свойств.
- Изменения.
DetectChangesсравнивает текущие значения со снимком. Его автоматически вызываютSaveChanges,EntryиChangeTracker.Entries. - Сохранение.
SaveChangesстроит команды, выполняет их в транзакции, записывает в сущности сгенерированные ключи и переводит их вUnchanged.
Когда что выбирать
| Режим | Когда |
|---|---|
| Отслеживание (по умолчанию) | будете менять и сохранять |
AsNoTracking() |
только чтение: списки, отчёты, GET в API |
AsNoTrackingWithIdentityResolution() (EF Core 5+) |
только чтение, но одна строка должна быть одним объектом |
Ловушки
- Дубликаты без трекинга. С EF Core 3.0
AsNoTrackingсоздаёт новый объект на каждое вхождение строки: заказы сInclude(o => o.Customer)получат столько копий клиента, сколько у него заказов. Updateдля DTO.db.Update(entity)помечаетModifiedвсе свойства всего графа, иUPDATEперепишет даже неизменённое.- Второй экземпляр с тем же ключом.
Attachдубликата даётInvalidOperationException: The instance of entity type ... cannot be tracked.
Правило
Читаете — AsNoTracking(). Меняете — загрузите сущность, измените свойства, вызовите SaveChanges.
Под капотом
- На каждую сущность трекер хранит запись: состояние, исходные значения, флаги изменённых свойств. Identity map — словарь «ключ → запись» для каждого типа.
DetectChangesобходит все отслеживаемые сущности. Его цена растёт линейно с их числом и платится при каждомSaveChanges.SaveChangesсортирует команды по зависимостям, пакетирует их и выполняет в транзакции. Если транзакция откатилась, состояния в трекере остаются прежними, и повторный вызов попробует снова.
Цена
- Снимок — копия значений каждой отслеживаемой сущности: память растёт с числом строк.
- Контекст в singleton или в длинном фоновом цикле копит сущности, и каждый
SaveChangesмедленнее предыдущего. Лечится коротким контекстом илиChangeTracker.Clear()между пачками.
Компромиссы
- Для сервисов, которые в основном читают, —
UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)в настройках иAsTracking()точечно. - DTO из API: загрузить сущность и применить
Entry(entity).CurrentValues.SetValues(dto)— лишнийSELECT, зато точныйUPDATE. ИлиAttachи отметить изменённые свойства вручную — безSELECT, но полнота на вас. - Атомарен только один вызов
SaveChanges. Несколько вызовов или смесь сExecuteUpdateобъединяет явная транзакция.
Как проверить в проде
- В отладке
ChangeTracker.DebugView.LongViewпоказывает состояния и значения всех сущностей контекста. - Метрики EF Core 9+ (meter
Microsoft.EntityFrameworkCore) и счётчикиdotnet-counters: активные контексты и частотаSaveChanges. Растущее число контекстов — утечка.
Итог: трекинг стоит памяти и CPU пропорционально числу сущностей, поэтому контекст короткий, чтение — без трекинга, а отключённые сущности обновляют явно.
using Microsoft.EntityFrameworkCore;
await using var db = new ShopContext();await db.Database.OpenConnectionAsync(); // база :memory: живёт, пока открыто соединениеawait db.Database.EnsureCreatedAsync();
var order = new Order { Status = "New" };db.Orders.Add(order);Console.WriteLine(db.Entry(order).State); // Addedawait db.SaveChangesAsync(); // INSERT, Id пришёл из базыConsole.WriteLine(db.Entry(order).State); // Unchanged
order.Status = "Paid"; // обычное присваиваниеConsole.WriteLine(db.Entry(order).State); // Modified
// Identity resolution: тот же объект, свойства не перезаписаныvar same = await db.Orders.SingleAsync(o => o.Id == order.Id);Console.WriteLine($"{ReferenceEquals(same, order)} {same.Status}"); // True Paid
// Без трекинга: новый объект с данными из базыvar copy = await db.Orders.AsNoTracking().SingleAsync(o => o.Id == order.Id);Console.WriteLine($"{ReferenceEquals(copy, order)} {copy.Status}"); // False New
await db.SaveChangesAsync(); // UPDATE "Orders" SET "Status" = @p0 WHERE "Id" = @p1
public class ShopContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite("Data Source=:memory:");}
public class Order{ public int Id { get; set; } public required string Status { get; set; }}- Перезапишет ли повторный запрос изменённую отслеживаемую сущность? — Нет, вернёт тот же объект с вашими изменениями.
- Чем
Attachотличается отUpdate? —AttachставитUnchanged,Update—Modifiedвсем свойствам графа. - Когда
DetectChangesвызывается сам? — ВSaveChanges,Entry,ChangeTracker.EntriesиHasChanges. - Как сбросить трекер, не создавая контекст заново? —
ChangeTracker.Clear()(EF Core 5+). - Что останется в трекере после неудачного
SaveChanges? — Прежние состояния: транзакция откатилась, изменения не приняты, вызов можно повторить.
Как загружать связанные данные и что такое проблема N+1?
Заголовок раздела «Как загружать связанные данные и что такое проблема N+1?»Что это
Навигационное свойство — ссылка на связанную сущность (order.Customer) или коллекция (order.Lines). Обычный запрос загружает только саму сущность, навигации остаются null или пустыми.
Как пользоваться
Include(o => o.Lines)— загрузить строки заказа в том же запросе.ThenInclude(l => l.Product)— следующий уровень: продукт каждой строки.Select(o => new { o.Id, LineCount = o.Lines.Count })— взять только нужные поля.
Что такое N+1
- Один запрос загружает N заказов.
- Код в цикле обращается к
order.Lines, и ленивая загрузка делает запрос на каждый заказ. - Итого N+1 запросов вместо одного.
Частая ошибка
Включить ленивую загрузку, «чтобы не думать». Код выглядит так же, а число запросов растёт вместе с данными.
Как устроено
| Способ | Как включить | Запросов |
|---|---|---|
| Жадная | Include, ThenInclude; с EF Core 5 — с фильтром внутри |
1 или по одному на коллекцию со split query |
| Явная | db.Entry(order).Collection(o => o.Lines).LoadAsync() |
+1 на вызов |
| Ленивая | пакет Microsoft.EntityFrameworkCore.Proxies, UseLazyLoadingProxies(), virtual-навигации |
+1 на первое обращение к каждой навигации |
Когда что выбирать
- Ответ API или отчёт — проекция в DTO через
Select. - Нужно изменить граф —
Includeтого, что будете менять. - Две и более коллекции одного уровня —
AsSplitQuery().
Ловушки
- Cartesian explosion.
Include(o => o.Lines).Include(o => o.Payments): заказ с 10 строками и 10 платежами даст 100 строк результата. Если режим не задан, EF пишет в логMultipleCollectionIncludeWarning. - Ленивая загрузка. Синхронный запрос в геттере свойства и N+1 в циклах и при сериализации в JSON: сериализатор обходит все навигации.
- Split query и пагинация. До EF Core 10 со
Skip/Takeсортировка должна быть уникальной, иначе запросы вернут несогласованные данные.
Правило
Для чтения — проекция, для изменения — Include нужного. Ленивую загрузку в веб-приложениях не включают.
Под капотом
- С EF Core 3.0
Includeколлекций — один запрос сLEFT JOINиORDER BYпо ключам; EF Core 2.x делал отдельные запросы. На этом переходе многие проекты и получили cartesian explosion, а явныйAsSplitQueryпоявился в EF Core 5.0. - Split query повторяет фильтр и сортировку корня в запросе каждой коллекции. Без MARS результаты предыдущих запросов буферизуются в памяти.
- Агрегат в проекции (
o.Lines.Sum(...)) становится коррелированным подзапросом: считает база, в приложение приходит одно число.
Цена
| Вариант | Чем платим |
|---|---|
| Ленивая загрузка | N round trip, синхронный I/O в геттере |
Один запрос с JOIN |
строк — произведение размеров коллекций, столбцы родителя дублируются |
| Split query | +1 round trip на коллекцию, согласованность только под SNAPSHOT или SERIALIZABLE |
Компромиссы
- Глобальный
UseQuerySplittingBehavior(SplitQuery)спасает от взрыва строк, но добавляет round trip каждомуIncludeи меняет согласованность всех запросов разом. Чаще его включают точечно. - Чем дальше база от приложения, тем дороже лишние round trip и тем выгоднее один запрос.
Как проверить в проде
- В трассировке OpenTelemetry N+1 выглядит как лесенка одинаковых
SELECTвнутри одного HTTP-запроса. MultipleCollectionIncludeWarningв логах и число прочитанных строк в плане выполнения выдают взрывJOIN.
Итог: N+1 бьёт по задержке, общий JOIN — по объёму, split query — по согласованности; проекция обходит все три, когда данные нужны для чтения.
using Microsoft.EntityFrameworkCore;
using var db = new ShopContext();
// Жадная загрузка: один запрос с LEFT JOIN; фильтр внутри Include — с EF Core 5var eager = db.Orders.Include(o => o.Lines.Where(l => l.Quantity > 0));
// Проекция: только нужные столбцы, агрегаты считает база, трекинга нетvar totals = db.Orders.Select(o => new{ o.Id, Lines = o.Lines.Count, Total = o.Lines.Sum(l => l.Quantity * l.Price)});Console.WriteLine(totals.ToQueryString());
// N+1: запрос за заказами и ещё по одному на каждый заказ — так работает ленивая загрузкаasync Task PrintLineCountsSlowly(){ foreach (var order in await db.Orders.ToListAsync()) { await db.Entry(order).Collection(o => o.Lines).LoadAsync(); // +1 запрос Console.WriteLine(order.Lines.Count); }}
public class ShopContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");}
public class Order{ public int Id { get; set; } public List<OrderLine> Lines { get; set; } = [];}
public class OrderLine{ public int Id { get; set; } public int OrderId { get; set; } public int Quantity { get; set; } [Precision(18, 2)] public decimal Price { get; set; }}SQL проекции (EF Core 10): два коррелированных подзапроса, из базы приходят три числа на заказ.
SELECT [o].[Id], ( SELECT COUNT(*) FROM [OrderLine] AS [o0] WHERE [o].[Id] = [o0].[OrderId]) AS [Lines], ( SELECT COALESCE(SUM(CAST([o1].[Quantity] AS decimal(18,2)) * [o1].[Price]), 0.0) FROM [OrderLine] AS [o1] WHERE [o].[Id] = [o1].[OrderId]) AS [Total]FROM [Orders] AS [o]- Почему N+1 трудно заметить? — Запросы спрятаны в геттерах и сериализаторе, виден только рост времени ответа.
- Что такое filtered include? —
Include(o => o.Lines.Where(...))с EF Core 5: загружается часть коллекции. - Чем опасен
AsSplitQueryсоSkipиTake? — Запросы могут увидеть разные данные; до EF Core 10 нужна уникальная сортировка. - Как грузится связь «один к одному» в split query? — Всегда через
JOINв том же запросе, разделяются только коллекции. - Работает ли ленивая загрузка у сущностей из
AsNoTracking? — С EF Core 8 да, раньше нет.
Как работать с миграциями в команде и при выкладке?
Заголовок раздела «Как работать с миграциями в команде и при выкладке?»Что это
Миграция — шаг изменения схемы. EF сравнивает модель с прошлым снимком и генерирует код: CreateTable, AddColumn, CreateIndex.
Как пользоваться
- Измените модель: добавьте свойство или сущность.
dotnet ef migrations add AddOrderCommentсоздаст файл миграции,.Designer.csи обновитShopContextModelSnapshot.cs.- Прочитайте сгенерированный код.
dotnet ef database updateприменит миграцию к локальной базе.- Закоммитьте все три файла.
Что лежит в базе
Таблица __EFMigrationsHistory — список применённых миграций. По ней EF понимает, что ещё не применено.
Частая ошибка
Править или удалять миграцию, уже применённую к общей базе. Она записана в истории, поэтому ошибку исправляют новой миграцией.
Как устроено
- Три файла. Миграция с
UpиDown,.Designer.csс моделью на её момент и общийModelSnapshot— модель после последней миграции. migrations addсравнивает текущую модель со снапшотом и генерирует разницу.migrations removeудаляет последнюю миграцию и откатывает снапшот. Безопасно, пока её не применили к общей базе.
Когда что выбирать
| Способ применения | Когда |
|---|---|
dotnet ef database update |
локальная разработка |
migrations script --idempotent |
ревью и запуск DBA, базы в разном состоянии |
migrations bundle (EF Core 6+) |
шаг CI/CD без SDK и исходников |
Ловушки
- Параллельные ветки. A и B добавили миграции в своих ветках; снапшот B не знает об изменениях A, а Git часто сливает снапшот без конфликта. Отмените слияние, уберите свою миграцию, влейте чужие изменения и создайте миграцию заново. Сортировать файлы и править снапшот руками нельзя.
- Переименование. EF не отличает его от удаления и добавления: сгенерирует
DropColumnиAddColumn, данные пропадут. Замените наRenameColumn. - Данные в миграции. Только
migrationBuilder.Sql(...)илиInsertData/UpdateData, неDbContextи классы сущностей: старая миграция должна работать, даже когда сущности изменились.
Правило
Сгенерированную миграцию читают как чужой PR, а применяют на прод отдельным шагом конвейера.
Под капотом
Migrate(),database updateи bundle читают__EFMigrationsHistoryи применяют недостающие миграции по порядку. С EF Core 9 перед этим берётся блокировка на уровне базы, и второй экземпляр ждёт, а не применяет те же миграции параллельно.- EF Core 9 оборачивал все ожидающие миграции в одну транзакцию, EF Core 10 вернул транзакцию на каждую миграцию. Часть DDL не транзакционна, и EF 9+ предупреждает, если такая операция смешана с другими в одной миграции.
- С EF Core 9
Migrate()бросает исключение, если модель расходится с последней миграцией (PendingModelChangesWarning) или если его обернули в свою транзакцию. - В EF Core 11 (выходит в ноябре 2026) снапшот будет хранить id последней миграции, и разошедшиеся ветки дадут конфликт слияния в Git.
Почему не Database.Migrate() на старте
- Приложению нужны права на DDL, хотя в проде ему хватает прав на данные.
- SQL никто не видит до выполнения, откат не подготовлен.
- Долгая миграция задерживает старт всех экземпляров, а оркестратор может перезапустить под посреди неё.
Компромиссы
- Скрипт даёт ревью и контроль DBA, но применяется чужими средствами — блокировка EF в нём не участвует.
- Bundle — один артефакт из CI с блокировкой и сидингом, но его SQL заранее не посмотреть. Запускают его с явными
ASPNETCORE_ENVIRONMENTи--connection. - Схема и код выкатываются в разное время, поэтому миграция совместима со старой версией приложения. Подробно — в теме Проектирование и эксплуатация.
Как проверить в проде
__EFMigrationsHistoryсверяют сdotnet ef migrations list.dotnet ef migrations has-pending-model-changes(EF Core 8+) в CI падает, если модель изменили без миграции.
Итог: миграции применяют отдельным шагом под отдельной учёткой; блокировка EF Core 9 защищает от гонки, но не заменяет ревью и совместимость схемы.
using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Migrations;
await using var db = new ShopContext();
// Тест в CI: модель изменили, а миграцию забыли (EF Core 8+, база не нужна)if (db.Database.HasPendingModelChanges()) throw new InvalidOperationException("Add a migration");
// Что ещё не применено к этой базе — по таблице __EFMigrationsHistoryforeach (var id in await db.Database.GetPendingMigrationsAsync()) Console.WriteLine(id); // 20261007120000_RenameCustomerName
// Создано dotnet ef migrations add RenameCustomerName и исправлено вручную.// Атрибуты [DbContext] и [Migration] лежат в файле .Designer.cspublic partial class RenameCustomerName : Migration{ protected override void Up(MigrationBuilder migrationBuilder) { // Сгенерировалось DropColumn("Name") + AddColumn("FullName"): данные пропали бы migrationBuilder.RenameColumn(name: "Name", table: "Customers", newName: "FullName");
// Данные — через SQL, а не через DbContext: старая миграция не должна зависеть от сущностей migrationBuilder.Sql("UPDATE Customers SET FullName = LTRIM(RTRIM(FullName))"); }
protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.RenameColumn(name: "FullName", table: "Customers", newName: "Name");}
public class ShopContext : DbContext{ public DbSet<Customer> Customers => Set<Customer>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");}
public class Customer{ public int Id { get; set; } public required string FullName { get; set; }}dotnet ef migrations has-pending-model-changes # CI: не забыли ли миграцию (EF Core 8+)dotnet ef migrations script --idempotent -o migrate.sql # скрипт для ревью и DBAdotnet ef migrations bundle --self-contained -r linux-x64 -o efbundle./efbundle --connection "$DEPLOY_CONNECTION" # шаг выкладки под учёткой с правами на схему- Что делать, если две ветки добавили миграции? — Отменить слияние, убрать свою, влить чужую и создать свою заново.
- Можно ли удалить применённую миграцию? — Не с общей базы: добавляют исправляющую миграцию.
- Чем идемпотентный скрипт отличается от обычного? — Проверяет
__EFMigrationsHistoryи применяет только недостающие миграции. - Что изменилось в
Migrate()в EF Core 9? — Блокировка от параллельного применения и исключение, если модель изменена без миграции. - Как поймать забытую миграцию до выкладки? —
dotnet ef migrations has-pending-model-changesв CI илиHasPendingModelChanges()в тесте.
Как сделать EF Core быстрым?
Заголовок раздела «Как сделать EF Core быстрым?»Что это
EF Core медленный не сам по себе, а когда заставляет базу делать лишнее: тянуть лишние столбцы и строки или выполнять много запросов.
Как пользоваться
- Проекция.
Select(o => new OrderRow(o.Id, o.Total))— в SQL только эти столбцы. AsNoTracking()— для данных только на чтение.- Фильтр до материализации.
WhereиTake— доToListAsync(), иначе они сработают в памяти. - Массовые изменения (EF Core 7+).
db.Orders.Where(...).ExecuteDeleteAsync()— одинDELETEвместо загрузки и удаления по одной. - Асинхронные методы в веб-приложениях:
ToListAsync,SaveChangesAsync.
Частая ошибка
Загружать сущности целиком ради двух полей для списка. Из базы приходят все столбцы, включая большие тексты, и всё попадает в трекер.
Как устроено
- Чтение. Проекция и
AsNoTrackingубирают лишние столбцы, снимки и identity map. Проекция в DTO не отслеживается вовсе. - Запись.
SaveChangesпакетирует команды: на SQL Server по умолчанию до 42 операторов за round trip (MaxBatchSize). С EF Core 7 вставки используютOUTPUTи стали заметно быстрее. - Массовые операции.
ExecuteUpdateAsyncиExecuteDeleteAsyncвыполняют одинUPDATEилиDELETEпо LINQ-фильтру сразу при вызове. - Накладные расходы.
AddDbContextPoolпереиспользует контексты,EF.CompileAsyncQueryубирает разбор дерева выражений.
Когда что выбирать
| Задача | Инструмент |
|---|---|
| Список, отчёт, ответ API | Select в DTO |
| Изменить несколько сущностей по бизнес-правилам | загрузить, изменить, SaveChanges |
| Изменить тысячи строк по условию | ExecuteUpdate, ExecuteDelete |
| Вставить сотни тысяч строк | SqlBulkCopy или COPY в Npgsql, не EF |
Ловушки
ExecuteUpdateмимо трекера. Загруженные сущности не узнают об изменении, и следующийSaveChangesперезапишет его старыми значениями. Concurrency token он не проверяет, транзакцию на несколько вызовов не открывает.- Триггеры на SQL Server. С EF Core 7 сохранение через
OUTPUTпадает на таблицах с триггерами. Таблицу помечаютHasTrigger(...)или, с EF Core 8,UseSqlOutputClause(false).
Правило
Сначала смотрите SQL и число команд на операцию, потом оптимизируйте.
Под капотом
- Выполнение запроса начинается с поиска в кеше по форме дерева выражений. При промахе EF транслирует запрос и компилирует материализатор; в здоровом приложении доля попаданий быстро доходит до 100 %.
AddDbContextPoolдержит до 1024 контекстов (по умолчанию) и сбрасывает их состояние при возврате.OnConfiguringвызывается один раз, поэтому данные запроса вроде tenant id передают через свою фабрику. С пулом соединений ADO.NET он не связан.EF.CompileAsyncQueryвозвращает потокобезопасный делегат; ограничения — одна модель и простые скалярные параметры.
Цена
В бенчмарке Microsoft пул сократил простой запрос примерно с 700 до 350 мкс и аллокации с 50 до 5 КБ, компилированный запрос сэкономил около 100 мкс. На фоне сети и работы базы это важно только на горячих путях.
Компромиссы
- Константы в SQL дают базе точный план, параметры — переиспользование плана.
EF.Constant(EF Core 8.0.2+) иEF.Parameter(EF Core 9+) меняют выбор EF для конкретного значения. EnableThreadSafetyChecks(false)убирает проверку параллельного доступа к контексту — только после тестов, которые доказали её ненужность.
Как проверить в проде
- Категория логов
Microsoft.EntityFrameworkCore.Database.Command— SQL и время каждой команды. - Метрики EF Core 9+ (meter
Microsoft.EntityFrameworkCore) илиdotnet-counters monitor --counters Microsoft.EntityFrameworkCore: запросы в секунду, попадания в кеш запросов, активные контексты. - Горячий путь меряют BenchmarkDotNet до и после.
Итог: сначала форма SQL и round trip, потом трекинг, и только на горячих путях — пул контекстов и компилированные запросы.
using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Infrastructure;using Microsoft.Extensions.Logging;
// Пул контекстов без DI; в ASP.NET Core — AddDbContextPoolvar factory = new PooledDbContextFactory<ShopContext>( new DbContextOptionsBuilder<ShopContext>() .UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True") .LogTo(Console.WriteLine, LogLevel.Information) // SQL и время каждой команды .Options);
await using var db = factory.CreateDbContext();
// Чтение: два столбца, без трекингаvar page = await db.Orders .Where(o => o.Status == "Paid") .OrderBy(o => o.Id) .Select(o => new OrderRow(o.Id, o.Total)) .Take(50) .ToListAsync();
// Массовое изменение: один UPDATE без загрузки сущностей (EF Core 7+), вернёт число строкvar expired = await db.Orders .Where(o => o.Status == "New" && o.CreatedAt < DateTime.UtcNow.AddDays(-30)) .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Expired"));
// Горячий запрос: дерево выражений разобрано один разvar order = await ShopContext.OrderById(db, 42);
public record OrderRow(int Id, decimal Total);
public class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options){ public DbSet<Order> Orders => Set<Order>();
public static readonly Func<ShopContext, int, Task<Order?>> OrderById = EF.CompileAsyncQuery((ShopContext db, int id) => db.Orders.FirstOrDefault(o => o.Id == id));}
public class Order{ public int Id { get; set; } public required string Status { get; set; } public DateTime CreatedAt { get; set; } [Precision(18, 2)] public decimal Total { get; set; }}- Почему
ExecuteUpdateнельзя смешивать с отслеживаемыми изменениями? — Трекер о нём не знает, иSaveChangesперезапишет строки старыми значениями. - Сколько команд
SaveChangesотправляет за раз на SQL Server? — До 42 по умолчанию, настраиваетсяMaxBatchSize. - Чем пул контекстов отличается от пула соединений? — Первый переиспользует объекты
DbContext, второй — соединения ADO.NET; они независимы. - Что ломает кеш запросов EF? — Константы в динамически построенных деревьях: новая форма на каждое значение.
- Когда нужен compiled model? — Когда модель из сотен сущностей заметно замедляет первый запрос после старта.
EF Core, Dapper или чистый ADO.NET: когда что выбирать?
Заголовок раздела «EF Core, Dapper или чистый ADO.NET: когда что выбирать?»Что это
| Инструмент | Что делает |
|---|---|
| ADO.NET | SqlConnection, SqlCommand, DbDataReader: SQL и чтение столбцов вручную |
| Dapper | выполняет ваш SQL и раскладывает столбцы по свойствам объекта |
| EF Core | генерирует SQL из LINQ, отслеживает изменения, ведёт миграции |
Зачем три
- EF Core экономит код, где много сущностей, связей и изменений.
- Dapper удобен, когда SQL сложный и его хочется контролировать: отчёты, поиск.
- ADO.NET нужен для того, чего нет в обёртках:
SqlBulkCopyдля массовой вставки, потоковое чтение больших BLOB черезCommandBehavior.SequentialAccess.
Частая ошибка
Склеивать SQL из строк: "... WHERE Name = '" + name + "'". Это SQL-инъекция; значения передают только параметрами — и в Dapper, и в EF.
Сырой SQL в EF Core
| API | Что возвращает |
|---|---|
FromSql (EF Core 7+, раньше FromSqlInterpolated) |
сущности модели; если SQL компонуемый, поверх можно Where и Include |
Database.SqlQuery<T> |
скаляры (EF Core 7+) и любые DTO (EF Core 8+) |
Database.ExecuteSql |
число затронутых строк для команд и процедур |
Версии ...Raw принимают готовую строку — для имён столбцов, которые нельзя параметризовать. С EF Core 10 анализатор предупреждает о склейке строк в них. Вызов процедуры на SQL Server не компонуется: после него сразу AsAsyncEnumerable().
Время жизни и потоки
- Два одновременных запроса через один контекст дают
InvalidOperationException: A second operation started on this context before a previous operation completed. AddDbContextрегистрирует контекст как scoped: один на HTTP-запрос. В singleton, фоновых задачах и Blazor Server —IDbContextFactory<T>иawait usingна операцию.
Когда что выбирать
- Команды с бизнес-правилами — EF и
SaveChanges. - Тяжёлые отчёты —
SqlQuery<T>или Dapper на соединении контекста. - Массовая загрузка —
SqlBulkCopyилиCOPYв Npgsql.
Компромиссы
| EF Core | Dapper, ADO.NET | |
|---|---|---|
| SQL | генерирует EF, нужно проверять | пишете сами |
| Изменения | трекинг, пакеты, concurrency tokens | вручную |
| Схема | модель и миграции | отдельный инструмент: DbUp, SSDT |
Concurrency tokens
- Свойство с
[Timestamp](на SQL Server —rowversion, в Npgsql —uintповерх системногоxmin) или[ConcurrencyCheck]попадает вWHEREкаждогоUPDATEиDELETEс исходным значением. - Ноль затронутых строк —
DbUpdateConcurrencyException. Как разрешить конфликт, решает бизнес-логика: перечитать (GetDatabaseValuesAsync), слить, повторить или вернуть клиенту 409. ExecuteUpdateтокены не проверяет: условие пишут вWhereсами и проверяют число строк.
Что нового в EF Core 9 и 10
- EF Core 9: блокировка миграций,
UseSeeding, автоматический compiled model,EF.Parameter, метрики черезSystem.Diagnostics.Metrics. - EF Core 10 (LTS до ноября 2028):
LeftJoinиRightJoin, именованные фильтры сIgnoreQueryFilters(["SoftDelete"]),ExecuteUpdateAsyncс обычной лямбдой, complex types в JSON и типjsonSQL Server 2025, векторный поиск. - Что заметно при обновлении до 10:
ids.Contains(...)переводится в отдельные параметры с дополнением до «круглого» числа вместоOPENJSON, имена параметров стали короче (@city) — база перекомпилирует почти все планы. EF дописываетApplication Nameв строку подключения без этого параметра: если Dapper в том жеTransactionScopeоткрывает своё соединение с исходной строкой, они попадут в разные пулы, и транзакция может стать распределённой.
Итог: EF Core 10 закрывает многие причины уходить в сырой SQL; Dapper и ADO.NET остаются для ручного SQL и массовых операций, а конфликты правок решают токены конкурентности.
// NuGet: Microsoft.EntityFrameworkCore.SqlServer, Dapperusing Dapper;using Microsoft.EntityFrameworkCore;
await using var db = new ShopContext();var since = new DateTime(2026, 1, 1);
// FromSql: сущности модели; {since} станет параметром, сверху достраивается LINQvar paid = await db.Orders .FromSql($"SELECT * FROM Orders WHERE CreatedAt >= {since}") .Where(o => o.Status == "Paid") .AsNoTracking() .ToListAsync();
// SqlQuery: DTO вне модели (EF Core 8+)var totals = await db.Database.SqlQuery<CustomerTotal>($""" SELECT CustomerId, SUM(Total) AS Total FROM Orders WHERE CreatedAt >= {since} GROUP BY CustomerId """).ToListAsync();
// Dapper на соединении контекста: свой SQL, маппинг по именам столбцовvar top = await db.Database.GetDbConnection().QueryAsync<CustomerTotal>( "SELECT TOP (10) CustomerId, SUM(Total) AS Total FROM Orders GROUP BY CustomerId ORDER BY Total DESC");
public record CustomerTotal(int CustomerId, decimal Total);
public class ShopContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");}
public class Order{ public int Id { get; set; } public int CustomerId { get; set; } public required string Status { get; set; } public DateTime CreatedAt { get; set; } [Precision(18, 2)] public decimal Total { get; set; }}using System.ComponentModel.DataAnnotations;using Microsoft.EntityFrameworkCore;
await using var db = new ShopContext();var order = await db.Orders.SingleAsync(o => o.Id == 42);order.Status = "Shipped";
try{ // UPDATE ... WHERE [Id] = @p AND [RowVersion] = <значение при загрузке> await db.SaveChangesAsync();}catch (DbUpdateConcurrencyException ex){ var entry = ex.Entries.Single(); var current = await entry.GetDatabaseValuesAsync() // null — строку уже удалили ?? throw new InvalidOperationException("Order was deleted");
// Здесь «побеждает последний»: берём свежий токен и сохраняем свои значения entry.OriginalValues.SetValues(current); await db.SaveChangesAsync();}
public class ShopContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");}
public class Order{ public int Id { get; set; } public required string Status { get; set; } [Timestamp] public byte[] RowVersion { get; set; } = []; // rowversion; в Npgsql — uint (xmin)}- Чем
FromSqlотличается отFromSqlRaw? — Первый превращает интерполяцию в параметры, второй берёт строку как есть. - Можно ли достроить LINQ поверх вызова процедуры? — На SQL Server нет: процедура не компонуется, после неё переходят в память.
- Почему нельзя
Task.WhenAllдвух запросов на одном контексте? — Контекст не потокобезопасен; нужны два контекста. - Как
rowversionработает как concurrency token? — База меняет его при каждом обновлении, EF сравнивает исходное значение вWHERE. - Что первым переписать на Dapper при проблемах со скоростью? — Ничего вслепую: сначала логи SQL, потом точечно самые тяжёлые запросы.