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

Современные инструменты миграций, будь то Flyway в мире Java, Alembic в Python, или их аналоги, строят управление схемой по аналогии с системой контроля версий кода. История коммитов превращается в историю миграций, ревью кода в ревью миграции, откат через возврат коммита в откатную миграцию. Каждое изменение схемы это пронумерованный файл, применяемый по порядку, и вся эволюция базы прослеживается так же, как эволюция кода. Эта аналогия задаёт и подход к тестированию: миграцию проверяют как код, а не принимают на веру.

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

Прямая и обратная миграция как пара, которую проверяют вместе

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

-- V2__add_email_column.sql (прямая)
ALTER TABLE users ADD COLUMN email VARCHAR(255);

-- U2__undo_add_email_column.sql (обратная)
ALTER TABLE users DROP COLUMN email;

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

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

Почему некоторые миграции необратимы и как это менять подход к ним

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

Из этого следует фундаментальный сдвиг в проектировании миграций. Раз удаление необратимо, изменения делают добавляющими, предпочитая добавление столбца его удалению. Добавляющие изменения по своей природе обратно совместимы и не требуют немедленного откатного сценария, потому что новый столбец старому коду просто не мешает. Удаление же разносят на фазы: сначала выкатывают код, переставший читать столбец, и лишь в следующем релизе отдельной миграцией удаляют сам столбец, когда уверены, что он никому не нужен.

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

Подготовка тестовых данных как условие осмысленной проверки

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

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

-- seed_test_data.sql (тестовое наполнение, отдельно от схемы)
INSERT INTO users (id, name) VALUES (1, 'Тест Первый');
INSERT INTO users (id, name) VALUES (2, 'Тест Второй');

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

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

Что именно проверять в миграции помимо самого факта применения

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

# проверка целостности, затем применение на тестовой базе в пайплайне
flyway validate
flyway migrate

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

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

Автоматически сгенерированные миграции и ловушка слепого доверия

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

Сгенерированная миграция это черновик, а не готовый к выкатке артефакт. Автогенератор хорошо ловит простые структурные различия вроде нового столбца, но регулярно ошибается на тонком: он не понимает намерения переименования и видит его как удаление старого столбца и добавление нового, что означает потерю данных. Он не всегда корректно выводит обратную часть. Он может предложить блокирующую операцию там, где нужна щадящая. Поэтому каждую сгенерированную миграцию обязательно читают глазами и тестируют, а не применяют на веру.

# автогенерация черновика миграции по изменениям моделей
alembic revision --autogenerate -m "add email to users"
# далее: обязательно открыть, прочитать, исправить, протестировать

Здравое отношение к автогенерации такое же, как к любому сгенерированному коду: это экономия времени на старте, а не освобождение от ответственности. Ревью сгенерированной миграции встраивают в обычный процесс ревью изменений, и тестируют её по всем слоям, разобранным выше. Особенно внимательно проверяют переименования и преобразования данных, где автогенератор чаще всего подсовывает разрушительный вариант под видом безобидного.

Идемпотентность и устойчивость миграции к частичному сбою

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

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

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

Встраивание проверки миграций в пайплайн и шлюзы безопасности

Чтобы свести стратегию к рабочим ориентирам, ниже единственный список этой статьи, расставленный по важности:

  1. Проверять обратную миграцию так же тщательно, как прямую, через круговой прогон с возвратом к исходной схеме, а не откладывать откат на потом;
  2. Делать изменения добавляющими и разносить удаление на фазы, потому что удаление данных необратимо, а добавление обратно совместимо;
  3. Перед необратимой миграцией требовать свежую резервную копию и тестировать сам план отката, а не считать его существующим по умолчанию;
  4. Прогонять миграцию на тестовых данных, в идеале на маскированной копии боевой базы с её историей миграций, а не на пустой схеме;
  5. Проверять не только факт применения, но и итоговую схему, производительность на боевых объёмах и совместимость со старым кодом во время выкатки;
  6. Встраивать в пайплайн проверку целостности, прогон на временной базе и ручной шлюз одобрения перед продакшеном, с обязательной обкаткой на стейджинге.

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

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