Пишем миграции базы данных, которые не положат продакшен

У вас есть новая фича. Код прошёл ревью, тесты, и его уже вмержили. Но между вами и деплоем стоит одно препятствие: изменение в базе данных. Возможно, нужно добавить колонку, переименовать таблицу или создать новый индекс. Вопрос не в том, работает ли это изменение на вашем ноутбуке. Вопрос в том, сработает ли оно в продакшене без поломок.

Здесь на сцену выходят миграционные скрипты. Это не просто SQL-файлы. Это дисциплинированный способ эволюционировать схему базы данных без гаданий, без ручных шагов и без того неприятного чувства, когда деплой идёт не по плану.

Базовый паттерн: один файл — одно изменение

Основная идея проста. Каждое изменение базы данных живёт в собственном файле. Файл имеет уникальный идентификатор — обычно timestamp или порядковый номер — и содержит SQL для применения изменения. Вы также создаёте парный rollback-файл, который может откатить это изменение.

Допустим, вам нужно добавить колонку phone в таблицу users. Вместо того чтобы логиниться в продакшен и выполнять ALTER TABLE напрямую, вы создаёте файл с именем 20241101_add_phone_to_users.sql. Внутри пишете:

ALTER TABLE users ADD COLUMN phone VARCHAR(20);

Затем создаёте 20241101_add_phone_to_users_rollback.sql:

ALTER TABLE users DROP COLUMN phone;

Оба файла попадают в ваш репозиторий вместе с кодом приложения. Они проходят код-ревью. Их мержат как любое другое изменение.

Почему отдельные файлы? Потому что каждое изменение несёт свой собственный риск. Когда вы разделяете изменения на отдельные файлы, вы можете применить одно изменение, понаблюдать за эффектом, а затем перейти к следующему. Если всё свалено в один гигантский скрипт, вы не сможете определить, какая часть вызвала сбой. Хуже того, если миграция упадёт на середине, вы понятия не будете иметь, где именно она остановилась.

Порядок выполнения важнее, чем вы думаете

Timestamp или порядковый номер — это не просто соглашение об именовании. Он определяет порядок выполнения. Ваш пайплайн миграций читает все файлы, сортирует их по этому идентификатору и выполняет от самого старого к самому новому. Это гарантирует, что каждое окружение — разработка, стейджинг, продакшен — применяет изменения в одной и той же последовательности.

Больше никаких «на моей машине работает, а на сервере падает» из-за разного порядка миграций. Больше никаких скрытых несоответствий, когда одно окружение пропустило шаг.

Делайте скрипты идемпотентными

Идемпотентность — это модное слово для простой идеи: повторный запуск одного и того же скрипта должен давать тот же результат и не вызывать ошибку.

Сравните два выражения:

-- Не идемпотентно: будет ошибка, если колонка уже существует
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- Идемпотентно: безопасно выполнять многократно
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);

Второй вариант безопаснее. Пайплайнам иногда приходится перезапускать миграции с нуля — например, при разворачивании свежей стейджинг-базы. Если ваши скрипты не идемпотентны, эта простая операция превращается в сеанс отладки.

Идемпотентность также помогает во время разработки. Разработчики часто применяют миграцию, смотрят результат и хотят начать заново. С идемпотентными скриптами они могут перезапустить миграцию без дропа и пересоздания всей базы данных.

Откат — не опция, а обязательство

У каждой прямой миграции должен быть парный откат. Это нужно не только для экстренных ситуаций в продакшене. Разработчики постоянно используют откаты во время локальной разработки, чтобы тестировать изменения и итерировать. Без скриптов отката им приходится дропать всю базу и запускать все миграции с нуля — это медленно и раздражает.

Но вот честная правда: откаты не всегда могут восстановить данные. Если миграция удаляет колонку, откат может воссоздать эту колонку, но данные будут потеряны. Если миграция переименовывает таблицу, откат может переименовать её обратно, но все записи, сделанные между этими операциями, будут потеряны.

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

Для деструктивных изменений — удаление колонок, удаление таблиц, изменение типов данных — нужна отдельная стратегия. Мы рассмотрим это позже. А пока правило простое: каждая миграция получает откат, даже если он восстанавливает только структуру.

Как параллельная работа остаётся безопасной

Несколько разработчиков, работающих над разными фичами, часто нуждаются в изменениях базы данных. Один добавляет колонку для фичи A. Другой создаёт таблицу для фичи B. Оба создают файлы миграций с разными timestamp'ами. Когда обе ветки мержатся, пайплайн сортирует файлы по timestamp'у и применяет их по порядку.

Конфликты возникают только тогда, когда два изменения затрагивают один и тот же объект схемы. Это настоящий конфликт, требующий человеческого решения — точно так же, как конфликт кода. Паттерн с файлами миграций не устраняет это, но делает конфликт видимым и явным.

Таблица отслеживания

Откуда ваш пайплайн знает, какие миграции уже выполнены? Он использует специальную таблицу внутри самой базы данных. Эта таблица записывает каждую применённую миграцию вместе с timestamp'ом или контрольной суммой. Когда пайплайн запускается, он проверяет эту таблицу, сравнивает её со списком файлов миграций и применяет только те, которых не хватает.

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

Практический чек-лист для написания скриптов миграций

Прежде чем вмержить файл миграции, пробегитесь по этому короткому списку:

  • Есть ли у скрипта уникальный идентификатор (timestamp или порядковый номер)?
  • Есть ли парный скрипт отката?
  • Идемпотентен ли скрипт? Можно ли его выполнить дважды без ошибки?
  • Восстанавливает ли откат предыдущее состояние?
  • Протестировали ли вы прямой и обратный переход на копии продакшен-данных?
  • Блокирует ли миграция таблицы? Если да, можно ли её выполнить в часы низкой нагрузки?

Резюме

Миграции базы данных — это не просто SQL. Это контракт между вашей командой и вашими продакшен-данными. Каждый файл миграции представляет собой решение: что меняется, в каком порядке и как это откатить, если что-то пошло не так. Относитесь к каждому файлу с той же тщательностью, что и к коду приложения. Рецензируйте его. Тестируйте его. Убедитесь, что у него есть откат. Ваша продакшен-база данных скажет вам спасибо.