А что не так с вашими миграциями? Liquibase, rollback и первые тревожные признаки

от автора

Привет, Хабр !

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

Не всегда…

Миграции часто выглядят как обычная техническая рутина. Добавили колонку, создали индекс, поправили enum, пересобрали витрину. Но проблема может проявиться уже на релизе: база держит lock, отчёты показывают пустоту, rollback есть только на словах, а команда разбирается, изменилась схема, изменились данные или Liquibase просто выполнил то, что было описано в changelog-е.

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


Что будет в статье:

  1. Что такое миграция БД и почему это не просто ALTER TABLE.

  2. Какие инструменты миграций чаще всего встречаются в разных языках.

  3. Почему Liquibase одновременно удобный и опасный.

  4. Несколько проблем из реальных проектов: rollback, checksum, большие changeset-ы, runInTransaction=false, IF EXISTS, destructive migration и пересоздание таблиц.


1. Миграция: простое изменение с непростыми последствиями

Миграция базы данных это версия изменения схемы или данных, которую можно повторяемо применить к окружению. Сегодня на dev, завтра на stage, потом на prod. В идеале одинаково.

Обычно миграция делает одну из таких вещей:

  • создаёт таблицу, колонку, индекс, constraint;

  • меняет тип поля;

  • преобразует данные под новую структуру;

  • создаёт view, materialized view, enum, sequence;

  • удаляет старую схему;

  • иногда чинит данные, потому что реальные сценарии отличаются от проектных диаграмм.

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

В коде мы привыкли к git history. В базе нужен похожий механизм. Только с важным отличием: git можно откатить локально за секунду, а базу с терабайтами данных и живыми пользователями так просто не откатишь.

Миграция это не просто SQL-файл. Это изменение состояния базы данных.


2. Чем обычно мигрируют базы

Для миграций используют разные инструменты. Одни запускаются отдельно, другие встроены во фреймворки.

Самые распространённые:

  • Liquibase. Java/JVM-мир, но не только он. XML/YAML/JSON/SQL changelog-и, checksums, contexts, labels, rollback, preconditions. Часто встречается в Java, Kotlin, Scala.

  • Flyway. Тоже JVM-экосистема, но проще по модели: версионированные SQL-скрипты, repeatable migrations, минимум скрытого поведения. Подходит командам, которым важно явно видеть SQL миграций.

  • Rails Active Record Migrations. Ruby on Rails. Миграции как Ruby-код: add_column, create_table, change.

  • Django migrations. Python/Django. Автогенерация по моделям, зависимости между миграциями, операции уровня ORM.

  • Alembic. Python/SQLAlchemy. Часто в FastAPI/SQLAlchemy-проектах. Есть автогенерация, но результат всё равно нужно проверять и дорабатывать.

  • Ecto migrations. Elixir/Phoenix. Приятный DSL, нормальная интеграция с приложением.

  • Entity Framework Core migrations. .NET/C#. Миграции из модели, C#-код, генерация SQL под провайдера.

  • Prisma Migrate. Node.js/TypeScript. Хорош для проектов вокруг Prisma schema.

  • TypeORM / Knex migrations. Node.js. Обычно миграции в TypeScript/JavaScript, с up/down.

  • golang-migrate, Goose, dbmate. Go и не только. Простые CLI-инструменты, чаще всего SQL-first.

  • Sqitch. Менее массовый, но интересный: dependency-based подход, verify/revert-скрипты.

  • Atlas. Более современный declarative/schema-as-code подход, особенно популярен там, где хотят сравнивать желаемое состояние схемы с реальным.

Я не думаю, что есть один «правильный» инструмент. Есть разные компромиссы.

Если команда хорошо пишет SQL и не хочет скрытой логики инструмента, Flyway или golang-migrate могут быть хорошим выбором. Если нужен единый механизм на несколько СУБД, contexts, rollback, changelog graph и формальные preconditions, Liquibase даёт богатый набор. Если проект живёт внутри Rails или Django, встроенные миграции часто удобны просто потому, что они часть привычного workflow.

Проблема начинается не с выбора инструмента. Проблема начинается, когда команда решает, что инструмент сам сделает миграции безопасными.

Не сделает.


3. Liquibase: возможности и ограничения

Liquibase хранит историю применённых changeset-ов в служебной таблице. У changeset-а есть id, author, путь к файлу и checksum. При запуске Liquibase смотрит, что уже применено, что новое, что изменилось, и выполняет недостающие операции.

Звучит надёжно. И в целом это удобно.

Плюсы Liquibase:

  • можно писать декларативно: addColumn, createTable, createIndex;

  • можно писать сырой SQL, если DSL не хватает;

  • есть preConditions;

  • есть rollback;

  • есть contexts и labels;

  • есть checksum, который ловит изменение уже применённого changeset-а;

  • можно запускать миграции из приложения, из CI или отдельным job-ом.

Но широкий набор возможностей повышает требования к тому, как команда пишет и проверяет миграции.

Например, checksum должен защищать от правки старой миграции. А потом кто-то пишет validCheckSum any, и защита фактически отключается. Rollback есть в документации, но в реальном changelog-е его может не быть. preConditions есть, но SQL внутри changeset-а всё равно может быть неидемпотентным. runInTransaction=false иногда нужен, например для CREATE INDEX CONCURRENTLY в PostgreSQL, но если он стоит в большом changeset-е с набором DDL, частично применённая миграция становится штатным сценарием.

Проблема не в самом Liquibase. Он просто выполняет то, что ему дали. Ошибочные миграции он применяет так же последовательно, как корректные.


4. Несколько проблем из реальных миграций

Ниже примеры проблем из реальных кодовых баз. Где-то PostgreSQL, где-то ClickHouse, где-то обычный XML Liquibase с raw SQL внутри.

1. Rollback отсутствует почти везде

Распространённая и дорогая проблема.

В проекте может быть больше сотни changelog-файлов, а rollback-блоки встречаются единично. И обычно не там, где риск максимальный, а там, где кто-то однажды был особенно дисциплинирован.

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

Минимальный rollback нужен хотя бы для:

  • добавленных колонок;

  • созданных индексов;

  • новых таблиц;

  • изменённых default-ов;

  • миграций данных;

  • опасных DDL, которые могут остановить релиз.

А для destructive migration одного rollback-блока мало. Там нужен ещё тест отката. Иначе это неподтверждённое предположение, а не рабочий rollback.

2. validCheckSum any: отключение важной проверки

Checksum в Liquibase нужен, чтобы старый changeset нельзя было незаметно поменять. Это полезная защита.

Но потом в миграциях появляется:

<validCheckSum>1:any</validCheckSum>

После этого Liquibase больше не сообщает о несовпадении checksum.

Я понимаю, почему так делают. Где-то поправили форматирование, где-то уже применённая миграция отличалась между окружениями, где-то требовалось срочно исправить релиз. Но когда any становится привычкой, история миграций перестаёт быть надёжной. По такой истории уже нельзя точно понять, какой changeset был применён.

Для промышленной базы это высокий риск. Особенно если рядом raw SQL на создание таблиц, удаление view и перекладку данных.

3. Один changeset делает слишком много

Хороший changeset небольшой и понятный. Его можно прочитать, понять, применить, откатить, обсудить на ревью.

Плохой changeset делает всё сразу:

  • удаляет старую витрину;

  • создаёт таблицу;

  • создаёт materialized view;

  • меняет enum;

  • добавляет колонку;

  • переливает данные;

  • и всё это под одним id.

Когда такой changeset прерывается на середине, состояние становится неочевидным. Что уже применилось? Что Liquibase записал в служебную таблицу? Можно ли повторить запуск? Нужно ли вручную удалять частично созданные объекты? А на втором узле кластера состояние такое же?

Для отчётных баз и ClickHouse это особенно рискованно. Там DDL часто идёт ON CLUSTER, а значит состояние может разойтись уже на уровне кластера.

4. runInTransaction=false как default, а не как исключение

Иногда runInTransaction=false необходим. Например, PostgreSQL не даст выполнить CREATE INDEX CONCURRENTLY внутри транзакции.

Но если этот флаг стоит на многих changeset-ах просто потому что «так принято в этом модуле», последствия могут быть серьёзными. Миграция может частично примениться. Liquibase может не записать changeset как выполненный. Следующий запуск увидит уже созданную часть объектов и завершится ошибкой на следующей операции.

Типичный сценарий:

CREATE TABLE ...-- успешноCREATE VIEW ...-- упало

Повторный запуск:

CREATE TABLE ...-- теперь уже упало, потому что таблица есть

Можно добавить IF NOT EXISTS. Но это не решает проблему полностью. Это локальная защита, иногда нужная, но недостаточная как общий подход.

5. IF EXISTS и IF NOT EXISTS маскируют расхождение окружений

Идемпотентность полезна. Но у неё есть важный недостаток.

Когда в каждой второй строке:

DROP TABLE IF EXISTS ...ADD COLUMN IF NOT EXISTS ...CREATE INDEX IF NOT EXISTS ...

миграция перестаёт проверять фактическое состояние схемы. Она не говорит: «на этом окружении схема неожиданно другая». Она говорит: «условие не мешает выполнению, продолжаю».

Иногда это правильно. Например, если миграция специально чинит разные состояния после аварийного релиза. Но как постоянный стиль это опасно.

На одном стенде колонка уже была с типом String, на другом её нет, на третьем она есть, но nullable. ADD COLUMN IF NOT EXISTS не выявит два из трёх случаев. А приложение потом работает с тем состоянием схемы, которое получилось.

6. Destructive migration без отдельной проверки

Удалить колонку просто. Удалить таблицу тоже просто. В XML это занимает одну строку. В SQL тоже.

В реальных миграциях я видел группы DROP TABLE IF EXISTS, иногда десятками строк подряд. Особенно в отчётном контуре: старые витрины, materialized view, промежуточные таблицы.

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

Но на ревью destructive migration должны звучать обязательные вопросы:

  • кто читает эти данные сейчас?

  • есть ли dashboard, который не лежит в репозитории приложения?

  • есть ли внешняя выгрузка?

  • можно ли сначала перестать писать, потом перестать читать, потом удалить?

  • сколько времени данные должны храниться по требованиям бизнеса?

Если ответов нет, DROP лучше не мержить. Такая проверка снижает риск восстановления данных после релиза.

7. Пересоздание таблицы вместо эволюции схемы

Отдельная проблемная практика: DROP TABLE, потом CREATE TABLE с новой структурой.

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

У неё должны быть:

  • план сохранения или восстановления данных;

  • оценка времени;

  • понимание нагрузки;

  • совместимость с текущим кодом;

  • rollback или хотя бы план исправления следующей миграцией.

Без этого пересоздание таблицы выглядит как обычный рефакторинг, хотя по факту затрагивает production-данные.


Короткий вывод

Миграции становятся опасными не тогда, когда команда выбирает «не тот» инструмент, а когда начинает относиться к изменению базы как к технической формальности. Liquibase, Flyway или любой другой механизм только выполняют описанные шаги. Они не знают, потеряются ли данные, выдержит ли production нагрузку и можно ли будет безопасно повторить запуск.

Если в changelog-е нет понятного rollback-а, changeset делает слишком много, runInTransaction=false используется по привычке, а DROP проходит ревью как обычная чистка, база постепенно превращается в место, где релиз может сломаться на проде, несмотря на зелёный CI и успешный регресс.


Если вам близки темы разработки, рефакторинга, архитектуры и стартапов, буду рад видеть вас в моём Telegram-канале.


ссылка на оригинал статьи https://habr.com/ru/articles/1036052/