Start Debugging

Что такое скомпилированная модель в EF Core 11 и когда стоит её включать

Скомпилированная модель - это сгенерированный C#-код, который воссоздаёт вашу модель EF Core без запуска OnModelCreating. Замеры на EF Core 11 RC1: загрузка модели сокращается с 714 мс до 215 мс при 500 сущностях, но путь через MSBuild (заменивший EFOptimizeContext) медленнее, устаревшие модели ломаются молча, а фильтры запросов блокируют генерацию.

Короткий ответ: скомпилированная модель - это исходный код на C#, который генерирует dotnet ef dbcontext optimize (или MSBuild-пакет Microsoft.EntityFrameworkCore.Tasks). Он строит вашу модель EF Core напрямую, вместо того чтобы при первом использовании DbContext запускать конвенции и OnModelCreating. Включать её стоит, когда первый запрос в свежем процессе стоит вам реальных денег или задержки (serverless, автомасштабируемые контейнеры, CLI-утилиты, десктопные приложения) и в модели порядка 100 типов сущностей или больше, а также при публикации с Native AOT, где она обязательна. В EF Core 11 свойство EFOptimizeContext удалено: включение теперь делается через EFScaffoldModelStage. Для веб-API на 20 таблиц, который запускается раз в неделю, поддержка скомпилированной модели того не стоит.

Все числа и сообщения об ошибках ниже получены на Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.1.26425.128 и dotnet-ef 11.0.0-rc.1.26425.128 с .NET 11 RC1 SDK (11.0.100-rc.1.26425.128), в Release-сборках, на MacBook с процессором Apple silicon. Одна перекрёстная проверка выполнена на EF Core 10.0.12 с SDK 10.0.302.

Что делает EF Core при первом использовании DbContext

Создание DbContext обходится дёшево. Дорогая часть происходит при первом обращении к DbContext.Model: запрос, Add, SaveChanges или чтение Model напрямую. В этот момент EF Core прогоняет конвейер конвенций по каждому достижимому типу сущности, находит свойства и связи через рефлексию, применяет вашу конфигурацию из OnModelCreating, проверяет результат и затем преобразует изменяемую модель в RuntimeModel только для чтения. Результат кешируется для каждого типа контекста (точнее, для каждого ключа кеша модели) на всё время жизни процесса, так что платить приходится один раз на процесс.

Скомпилированная модель пропускает этот конвейер. Вместо обнаружения модели EF Core создаёт экземпляры сгенерированных классов, которые вызывают RuntimeModel.AddEntityType, AddProperty, AddKey, AddForeignKey и так далее с уже готовыми ответами. Вот фрагмент того, что dotnet ef dbcontext optimize выдал для одной сущности в моём тестовом проекте:

// <auto-generated /> by dotnet-ef 11.0.0-rc.1.26425.128
var runtimeEntityType = model.AddEntityType(
    "Bench.Entity1",
    typeof(Entity1),
    baseEntityType,
    propertyCount: 8,
    navigationCount: 1,
    foreignKeyCount: 1,
    unnamedIndexCount: 2,
    keyCount: 1);

var name = runtimeEntityType.AddProperty(
    "Name",
    typeof(string),
    propertyInfo: typeof(Entity1).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
    fieldInfo: typeof(Entity1).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
    maxLength: 200);

Значение maxLength: 200 пришло из HasMaxLength(200) в OnModelCreating. Эта конфигурация теперь зашита в сгенерированный код. В этом весь смысл подхода, и это же источник всех подводных камней, о которых речь пойдёт дальше.

Команда записывает по одному файлу <Entity>EntityType.cs на каждый тип сущности и ещё три файла: <Context>Model.cs (наследник RuntimeModel со статическим Instance), <Context>ModelBuilder.cs и <Context>AssemblyAttributes.cs. Последний содержит строку, благодаря которой EF Core находит модель без каких-либо изменений в вашем коде:

[assembly: DbContextModel(typeof(BenchContext), typeof(BenchContextModel), ProviderName = "Microsoft.EntityFrameworkCore.Sqlite")]

Начиная с EF Core 9, скомпилированная модель в той же сборке, что и контекст, обнаруживается автоматически через этот атрибут. Вызов optionsBuilder.UseModel(BenchContextModel.Instance) нужен только тогда, когда скомпилированная модель лежит в другой сборке или когда вы хотите выбирать между несколькими скомпилированными моделями во время выполнения.

Замеряем, что это даёт

Страница Microsoft Learn о скомпилированных моделях говорит, что они помогают “applications with large models”, то есть “hundreds to thousands of entity types”. Для принятия решения это слишком расплывчато, поэтому я замерил сам. Python-скрипт сгенерировал модели с 10, 100 и 500 типами сущностей. У каждой сущности по восемь скалярных свойств, допускающий null внешний ключ на предыдущую сущность, уникальный индекс и вызов HasMaxLength, чтобы у конвенций была реальная работа. Программа измеряет время первого обращения к модели и первого запроса в свежем процессе:

// .NET 11, C# 14, EF Core 11.0.0-rc.1.26425.128, Microsoft.EntityFrameworkCore.Sqlite
using System.Diagnostics;
using Bench;
using Microsoft.EntityFrameworkCore;

var sw = Stopwatch.StartNew();
using var ctx = new BenchContext();
var model = ctx.Model;                                     // first touch: build or load the model
var modelMs = sw.Elapsed.TotalMilliseconds;
var n = ctx.Set<Entity1>().Where(e => e.IsActive).Count(); // first query
var firstQueryMs = sw.Elapsed.TotalMilliseconds;

Console.WriteLine($"{model.GetType().Name},{model.GetEntityTypes().Count()}," +
    $"model={modelMs:F0}ms,firstQuery={firstQueryMs:F0}ms," +
    $"OnModelCreating={BenchContext.ModelCreatingCalls}");

BenchContext.ModelCreatingCalls - статический счётчик, который увеличивается внутри OnModelCreating, так что каждый запуск доказывает, каким путём он пошёл. База данных - заранее созданный файл SQLite. В таблице медианы по семи холодным запускам процесса на каждую строку:

Типов сущностейЗагрузка модели, построена во время выполненияЗагрузка модели, скомпилированнойПервый запрос, во время выполненияПервый запрос, скомпилированнаяРазмер сборки, во время выполнения / скомпилированная
10185 ms65 ms277 ms171 ms23 KB / 43 KB
100280 ms92 ms383 ms213 ms158 KB / 342 KB
500714 ms215 ms868 ms401 ms888 KB / 1.8 MB

Выделяются две вещи. Во-первых, выигрыш реален уже при 10 типах сущностей, около 110 мс: заметная доля затрат во время выполнения приходится на JIT-компиляцию самого конвейера конвенций, а не только на его прогон для каждой сущности. Во-вторых, выигрыш масштабируется: при 500 типах сущностей скомпилированная модель экономит полсекунды на каждом холодном старте. OnModelCreating вызывался ноль раз в каждом запуске со скомпилированной моделью и один раз в каждом запуске без неё.

Важны ли 110 мс - вопрос к продукту. Для приложения ASP.NET Core за балансировщиком нагрузки, которое прогревается до приёма трафика, это неважно. Это важно для AWS Lambda или тарифа Azure Functions Consumption, где холодный старт виден пользователю, для dotnet tool, который работает две секунды, и для десктопного или MAUI-приложения, где первый экран ждёт запроса.

Куда делся EFOptimizeContext в EF Core 11

В EF Core 9 появилась интеграция с MSBuild в пакете Microsoft.EntityFrameworkCore.Tasks, которая заново генерирует скомпилированную модель во время сборки или публикации, так что она не может разойтись с кодом. В EF Core 9 и 10 её включали через EFOptimizeContext=true, а затем выбирали этап через EFScaffoldModelStage и EFPrecompileQueriesStage.

EF Core 11 удалил EFOptimizeContext (dotnet/efcore#35079). Два свойства этапов теперь работают сами по себе, а установка старого свойства ломает сборку. При PublishAot, равном true, генерация во время публикации включена по умолчанию. Без AOT вот эквивалент прежнего явного включения в EF Core 11:

<!-- .NET 11, EF Core 11.0.0-rc.1.26425.128 -->
<PropertyGroup>
  <EFScaffoldModelStage>build</EFScaffoldModelStage>
  <EFPrecompileQueriesStage>none</EFPrecompileQueriesStage>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-rc.1.26425.128" PrivateAssets="all" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Tasks" Version="11.0.0-rc.1.26425.128" PrivateAssets="all" />
</ItemGroup>

Сгенерированные файлы попадают в obj/<Configuration>/<TFM>/ как *.g.cs и добавляются в компиляцию, так что в систему контроля версий ничего не попадает. Допустимые значения этапа: build, publish и любое другое (по соглашению none) для отключения. Справочник по задачам MSBuild перечисляет DbContextName, EFTargetNamespace, EFOutputDir и EFNullable для более тонкой настройки. Если вы обновили проект со старым свойством и сборка взорвалась, эту ошибку и предшествовавшую ей ошибку исчерпания памяти разбирает статья про PublishAot и EFOptimizeContext.

Так стоит ли включать путь через MSBuild? Для Native AOT да: скомпилированная модель и предкомпилированные запросы вам всё равно нужны, а их перегенерация при публикации - самый надёжный способ держать их синхронными. Для обычного JIT-приложения мои замеры говорят нет, по двум причинам.

Путь через MSBuild генерирует более медленную модель в стиле AOT

Документация MSBuild отмечает, что интеграция “will always generate additional code in the compiled model that’s required for NativeAOT”. На практике это означает дополнительный файл <Entity>UnsafeAccessors.g.cs на каждый тип сущности: 203 сгенерированных файла для моей модели из 100 сущностей вместо 103. Этот код не бесплатен на старте. Та же модель на 100 сущностей, та же машина:

Источник скомпилированной моделиЗагрузка моделиПервый запрос
dotnet ef dbcontext optimize92 ms213 ms
dotnet ef dbcontext optimize --nativeaot231 ms394 ms
EFScaffoldModelStage=build235 ms393 ms
Без скомпилированной модели280 ms383 ms

Модель, сгенерированная через MSBuild, загружается всего примерно на 45 мс быстрее, чем вообще без скомпилированной модели, а первый запрос быстрее не стал. Результат обычного CLI загружается в 2.5 раза быстрее. Если вы не публикуете с AOT, код для NativeAOT - чистые накладные расходы.

Чистая сборка падает, а повтор молча пропускает генерацию

Вторая причина - проблема порядка сборки, с которой я столкнулся и на 11.0.0-rc.1, и на 10.0.12. Пакет Tasks подключает генерацию к TargetsTriggeredByCompilation, который выполняется сразу после CoreCompile, но задача OptimizeDbContext загружает сборку из bin/, а эта папка заполняется позже в процессе сборки. Из чистой копии репозитория, без папки bin, первая сборка падает:

Optimizing DbContext...
Microsoft.EntityFrameworkCore.Tasks.targets(105,5): error : File '.../bin/Debug/net11.0/Bench.dll' not found.
Build FAILED.

Повторный dotnet build затем сообщает Build succeeded, потому что шаг компиляции актуален и генерация пропускается. Получившееся приложение работает без скомпилированной модели: мой тест вывел RuntimeModel и OnModelCreating=1. Только после изменения исходного файла следующая сборка вывела Optimizing DbContext... и создала BenchContextModel. В CI, где каждая сборка начинается с нуля, это красная сборка при каждом запуске. На момент написания я не нашёл в dotnet/efcore задачи, которая отслеживала бы эту проблему, так что проверьте заметки о выпуске, когда выйдет 11.0 GA.

Путь через CLI: сгенерировать один раз и закоммитить

Для JIT-приложения лучше прежняя схема: запустить CLI, закоммитить результат и перегенерировать при изменении модели.

# .NET 11 SDK, dotnet-ef 11.0.0-rc.1.26425.128
dotnet tool install --global dotnet-ef --version 11.0.0-rc.1.26425.128
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels

На уже собранном проекте генерация заняла от 1.3 до 2.3 секунды для моих трёх размеров модели. Стоит знать две детали: проект должен быть предварительно восстановлен (иначе будет Unable to retrieve project metadata), а если контекст настраивается в другом стартовом проекте, передайте --startup-project или добавьте IDesignTimeDbContextFactory<T>.

Риск пути через CLI - расхождение (drift), и оно хуже, чем можно подумать по документации. В статье о прогреве от апреля говорилось, что EF Core обнаруживает устаревшую скомпилированную модель и выбрасывает исключение. Проверка на EF Core 11 RC1 показывает обратное. Я сгенерировал скомпилированную модель, затем добавил свойство Sku в одну сущность и переименовал столбец через HasColumnName("DisplayName"), не перегенерируя модель:

// .NET 11, EF Core 11.0.0-rc.1.26425.128, compiled model generated BEFORE these changes
Console.WriteLine(ctx.Set<Entity2>().Select(e => e.Name).ToQueryString());
// With the stale compiled model:   SELECT "t"."Name" FROM "T2" AS "t"
// Without any compiled model:      SELECT "t"."DisplayName" FROM "T2" AS "t"

Ни исключения, ни предупреждения: устаревшая модель сгенерировала SQL со старым именем столбца. Новое свойство Sku действительно вызвало ошибку, но только когда запрос его использовал, и с общим сообщением “The LINQ expression could not be translated … Translation of member ‘Sku’ on entity type ‘Entity0’ failed”, которое указывает совсем не на настоящую причину.

Решение - сделать расхождение падением CI. Генератор детерминирован, кроме одной строки: GUID modelId в <Context>ModelBuilder.cs, который меняется при каждом запуске. Git может игнорировать эту строку:

# .NET 11 SDK, dotnet-ef 11.0.0-rc.1.26425.128, git 2.30+
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels
git diff --exit-code -I 'modelId:' -- CompiledModels

Если кто-то изменил модель, не перегенерировав её, diff будет непустым и задача упадёт. Это та же идея, что и проверка на неприменённые миграции, и она укладывается в тот же шаг CI.

Чего скомпилированная модель не умеет

На странице Learn есть список ограничений, но он частично устарел. Проверено на EF Core 11 RC1:

Правило принятия решения, которое работает

Если свести замеры и ограничения вместе:

  1. Публикуете с Native AOT? Она вам нужна. Задайте PublishAot, подключите Microsoft.EntityFrameworkCore.Tasks и позвольте публикации сгенерировать модель и предкомпилированные запросы. Без неё первый запрос выбросит “Model building is not supported when publishing with NativeAOT”, как разобрано в версии этой ошибки для MAUI на iOS.
  2. Холодные старты видны пользователю (serverless, контейнеры со scale-to-zero, CLI-утилиты, десктопные приложения) и нет фильтров запросов? Используйте путь через CLI, закоммитьте результат и добавьте проверку расхождения в CI. Ожидайте экономию примерно 100 мс на малых моделях и 500 мс на 500 типах сущностей. Хорошо сочетается с другими приёмами из статьи о сокращении времени холодного старта для AWS Lambda на .NET 11.
  3. Долгоживущий сервер, который прогревается до приёма трафика? Пропустите. Прогрев при старте, обращающийся к DbContext.Model, даёт тот же результат для пользователя без сгенерированного кода, который нужно поддерживать.
  4. Использовали EFOptimizeContext из EF Core 9 или 10 для JIT-приложения? При переходе на EF Core 11 подумайте о том, чтобы удалить интеграцию с MSBuild, а не переводить её в EFScaffoldModelStage=build. Вы получите более быструю модель из CLI и избежите падения на чистой сборке.

Модель - лишь половина стоимости первого запроса. Каждая новая форма LINQ-запроса тоже транслируется и компилируется один раз на процесс; если доминирует несколько горячих запросов, вторую половину атакуют скомпилированные запросы EF Core для горячих путей.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад