Сегодня про EF Core migrations и один очень удобный вызов, который локально экономит время, а в production может связать старт приложения, DDL-блокировки и rollback в одну неприятную историю. Читать далее
Привет, Хабр! Меня зовут Павел, я ведущий разработчик. Сегодня без Kafka, но снова про место, где прод обычно становится честнее документации: миграции базы данных.
Есть очень удобный код:
var app = builder.Build();
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
await app.RunAsync();
Локально выглядит прекрасно. Приложение стартует, EF Core сам применяет миграции, разработчик доволен, база вроде тоже не возражает.
А потом это попадает в прод. В Kubernetes поднимаются три pod’а, каждый просыпается с мыслью “я сейчас наведу порядок в схеме”, база берет DDL-блокировку, readiness не проходит, релиз нервно смотрит в мониторинг. Где-то рядом человек открывает вкладку с вакансиями, просто чтобы успокоиться.

EF Core migrations в проде
Сам метод не злой. Он полезен в локальной разработке, тестовом окружении, маленьких внутренних сервисах и одноразовых утилитах.
Проблема начинается, когда runtime-migration становится частью обычного production startup.
Первый риск — несколько инстансов. Если сервис масштабируется, миграцию могут попытаться применить сразу несколько процессов. Начиная с EF Core 9 появился механизм блокировки миграций, который защищает от части проблем конкурентного применения. Но он не делает сам подход бесплатным: остальные pod’ы все равно ждут, старт приложения связан с DDL, а релиз зависит от состояния БД.
Второй риск — права. Чтобы приложение применяло миграции, его пользователь в БД должен уметь менять схему. В проде это часто лишнее доверие к коду, который должен обслуживать запросы, а не заниматься ремонтом асфальта под колесами.
Третий риск — непосмотренный SQL. В миграции на C# все может выглядеть невинно:
migrationBuilder.AddColumn<string>(
name: "Comment",
table: "Orders",
nullable: false,
defaultValue: "");
А в базе это уже конкретная DDL-операция с блокировками, перепроверкой данных и неприятным вопросом: “а сколько строк в Orders?”

Несколько инстансов стартуют миграцию
У Microsoft в документации по EF Core прямо перечислены ограничения применения миграций приложением в production: конкурентный запуск, лишние права, отсутствие возможности заранее проверить SQL и риск проблем при откате.
Рекомендуемые варианты спокойнее:
сгенерировать SQL-скрипт и применить его контролируемо;
использовать migration bundle;
запускать миграцию отдельным шагом pipeline или отдельным Kubernetes Job;
держать только один исполнитель миграции;
не давать обычному приложению DDL-права в production.
То есть приложение должно стартовать как приложение. А не как маленький DBA с доступом к болгарке.
DDL живет по своим правиламСамая неприятная часть миграций в проде: приложение думает объектами, а база думает таблицами, индексами и блокировками.
Например, в PostgreSQL обычный CREATE INDEX блокирует записи в таблицу. Для больших таблиц часто нужен:
migrationBuilder.Sql(
"""
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_orders_created_at
ON orders(created_at);
""",
suppressTransaction: true);
CONCURRENTLY позволяет строить индекс без блокировки обычных INSERT, UPDATE, DELETE, но у него есть цена: операция идет дольше и не может выполняться внутри transaction block. Поэтому в EF Core приходится явно подавлять транзакцию для такого SQL.
То же самое с ограничениями и колонками. Добавить колонку, заполнить ее, поставить NOT NULL, удалить старую колонку — это не всегда одна миграция. В проде это часто несколько релизов.

DDL-операции и production-риски
Expand/contract вместо “сломать и починить”Самый скучный и поэтому полезный подход — делать схему совместимой с несколькими версиями приложения.
Допустим, надо переименовать status в state.
Плохой вариант:
Переименовать колонку.
Выложить код.
Надеяться, что старые pod’ы уже умерли.
Хороший вариант:
Шаг | Что делаем |
|---|---|
Expand | Добавляем новую nullable-колонку |
Dual write | Новый код пишет и в |
Backfill | Пачками переносим старые данные |
Switch read | Код начинает читать |
Contract | После всех проверок удаляем |
Это медленнее, чем “давайте одним ALTER”. Зато при rollback старый код еще понимает схему, а новый код не падает на старых данных.

Expand/contract rollout
Как я бы делал в нормальном релизеДля production-проекта я бы разделил код и схему.
На build-этапе собрать migration bundle или SQL-script.
На review посмотреть SQL глазами, а не только миграцию на C#.
На staging прогнать на данных, похожих по объему на production.
Перед выкаткой выполнить migration job ровно один раз.
Только потом выкатывать приложение.
Для больших таблиц делать backfill пачками.
Destructive changes переносить в отдельный релиз.
И еще один важный пункт: rollback-план должен быть до миграции, а не после фразы “а почему у нас колонка исчезла?”
Мини-чек-лист перед миграциейПеред релизом я бы спросил:
SQL миграции сгенерирован и просмотрен?
Есть ли операции, которые берут долгую блокировку?
Проверяли на таблице похожего размера?
Нужен ли CREATE INDEX CONCURRENTLY?
Можно ли применить миграцию повторно без взрыва?
Приложение совместимо со старой и новой схемой?
Откат приложения переживет уже примененную миграцию?
Есть ли backfill-план?
Есть ли метрики длительности миграции и ошибок?
У обычного app-user нет лишних DDL-прав?
Главная мысль:
Миграция базы — это часть релиза, а не побочный эффект старта приложения.
Database.Migrate() удобен, пока не становится единственным человеком в комнате с правом менять схему. В production лучше, когда миграции выполняются явно, проверяемо и отдельно от старта сервиса.
В Telegram-канале «Продовый оффсет» отдельно выложу короткий чек-лист для релиза, схему expand/contract и памятку по PostgreSQL DDL: где нужен CONCURRENTLY, где suppressTransaction, а где лучше просто не делать вид, что таблица на 200 млн строк это маленькая DTO.
Microsoft: Applying Migrations — варианты применения миграций, SQL scripts, bundles и ограничения runtime-подхода.
Microsoft: MigrationBuilder.Sql — параметр suppressTransaction.
PostgreSQL: CREATE INDEX — поведение CREATE INDEX CONCURRENTLY.
PostgreSQL: ALTER TABLE — DDL-команды и блокировки.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
18.18%PostgreSQL индексы в проде (Почему запрос быстрый на dev, медленный на prod, и как индексы могут лечить или добивать систему)2
81.82%Очереди background jobs в .NET (Hangfire/Quartz/Worker Service: retry, дедупликация, блокировки и что делать, когда job «почти точно выполнилась»)9
36.36%API versioning и обратная совместимость (Как менять контракт, не ломая клиентов, мобильные приложения и веру бизнеса в стабильность)4
Проголосовали 11 пользователей. Воздержались 3 пользователя.