Привет, Хабр! Меня зовут Павел, я ведущий разработчик. Сегодня без 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-риски
DDL-операции и production-риски

Expand/contract вместо “сломать и починить”

Самый скучный и поэтому полезный подход — делать схему совместимой с несколькими версиями приложения.

Допустим, надо переименовать status в state.

Плохой вариант:

  1. Переименовать колонку.

  2. Выложить код.

  3. Надеяться, что старые pod’ы уже умерли.

Хороший вариант:

Шаг

Что делаем

Expand

Добавляем новую nullable-колонку state

Dual write

Новый код пишет и в status, и в state

Backfill

Пачками переносим старые данные

Switch read

Код начинает читать state

Contract

После всех проверок удаляем status

Это медленнее, чем “давайте одним ALTER”. Зато при rollback старый код еще понимает схему, а новый код не падает на старых данных.

Expand/contract rollout

Как я бы делал в нормальном релизе

Для production-проекта я бы разделил код и схему.

  1. На build-этапе собрать migration bundle или SQL-script.

  2. На review посмотреть SQL глазами, а не только миграцию на C#.

  3. На staging прогнать на данных, похожих по объему на production.

  4. Перед выкаткой выполнить migration job ровно один раз.

  5. Только потом выкатывать приложение.

  6. Для больших таблиц делать backfill пачками.

  7. Destructive changes переносить в отдельный релиз.

И еще один важный пункт: rollback-план должен быть до миграции, а не после фразы “а почему у нас колонка исчезла?”

Мини-чек-лист перед миграцией

Перед релизом я бы спросил:

  • SQL миграции сгенерирован и просмотрен?

  • Есть ли операции, которые берут долгую блокировку?

  • Проверяли на таблице похожего размера?

  • Нужен ли CREATE INDEX CONCURRENTLY?

  • Можно ли применить миграцию повторно без взрыва?

  • Приложение совместимо со старой и новой схемой?

  • Откат приложения переживет уже примененную миграцию?

  • Есть ли backfill-план?

  • Есть ли метрики длительности миграции и ошибок?

  • У обычного app-user нет лишних DDL-прав?

Главная мысль:

Миграция базы — это часть релиза, а не побочный эффект старта приложения.

Database.Migrate() удобен, пока не становится единственным человеком в комнате с правом менять схему. В production лучше, когда миграции выполняются явно, проверяемо и отдельно от старта сервиса.

В Telegram-канале «Продовый оффсет» отдельно выложу короткий чек-лист для релиза, схему expand/contract и памятку по PostgreSQL DDL: где нужен CONCURRENTLY, где suppressTransaction, а где лучше просто не делать вид, что таблица на 200 млн строк это маленькая DTO.

На что опирался

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какую бы тему вы хотели что бы написал следующей?
22.22%PostgreSQL индексы в проде (Почему запрос быстрый на dev, медленный на prod, и как индексы могут лечить или добивать систему)2
77.78%Очереди background jobs в .NET (Hangfire/Quartz/Worker Service: retry, дедупликация, блокировки и что делать, когда job «почти точно выполнилась»)7
33.33%API versioning и обратная совместимость (Как менять контракт, не ломая клиентов, мобильные приложения и веру бизнеса в стабильность)3
Проголосовали 9 пользователей. Воздержались 2 пользователя.