Откатить миграцию базы нельзя: что готовят вместо отката
В команде принято думать, что у каждой миграции базы данных есть кнопка «назад»: накатили — не понравилось — откатили, и всё вернулось как было. Это работает для части миграций и совсем не работает для другой, а разница между ними всплывает не на этапе планирования, а в момент, когда откат уже нужен по-настоящему. Разберём, почему «откатить миграцию» технически невозможно для многих изменений схемы, и что опытные команды готовят вместо надежды на автоматический откат.
Содержание
- Почему «просто откатить» не работает для многих миграций схемы
- Какие изменения обратимы, а какие — нет
- Тестирование на копии прод-данных: основная линия защиты
- Явный бэкап данных перед потенциально необратимым шагом
- Что на самом деле делают down-миграции в инструментах вроде Flyway, Liquibase и Alembic
- План Б — это восстановление из бэкапа, а не откат схемы
Почему «просто откатить» не работает для многих миграций схемы
Миграция схемы — обычно две операции: up, приводящая базу к новому состоянию, и down, которая должна вернуть её к предыдущему. Инструменты миграций (Flyway, Liquibase, Alembic, Django migrations, Rails ActiveRecord) честно генерируют или просят написать down-скрипт, и это создаёт иллюзию симметрии — раз есть up, должна быть равноценная down.
Симметрия есть на уровне структуры (DDL), но не на уровне данных. Пример: миграция удаляет колонку.
-- up: 2026-08-14_drop_legacy_status.sql
ALTER TABLE orders DROP COLUMN legacy_status;
-- down: 2026-08-14_drop_legacy_status_down.sql
ALTER TABLE orders ADD COLUMN legacy_status varchar(20);
Формально down откатывает миграцию: колонка legacy_status снова существует, схема совпадает с тем, что было до up. Но данные в ней — значения всех строк таблицы orders — потеряны безвозвратно в момент DROP COLUMN. PostgreSQL и MySQL не хранят содержимое удалённой колонки «на всякий случай» — это была бы неоправданная трата места для операции, которая по умолчанию считается окончательной. down-миграция восстанавливает форму, а не содержимое.
То же самое, только менее очевидно, происходит с:
DROP TABLE— восстановить структуру таблицы изdown-скрипта легко, восстановить строки — нет.TRUNCATEвнутри миграции при смене формата данных «на лету» — если конвертация шла черезUPDATE ... SET, а не через промежуточную таблицу, старые значения нигде не остались.- Изменение типа колонки с потерей точности:
ALTER COLUMN amount TYPE integerизnumeric(10,2)— округление необратимо,downвернёт уже округлённые числа. - Слияние двух колонок в одну (
full_nameизfirst_name+last_name) — обратная миграция разобьёт строку по пробелу, но для «Анна Мария де ла Крус» результат будет неправильным: это не багdown-скрипта, а фундаментальная потеря информации о границе.
Это не недоработка конкретного инструмента и не повод писать более умный down-скрипт, а следствие того, что операции с данными в общем случае необратимы, если исходная информация физически не сохранена где-то ещё — в бэкапе, в WAL/binlog в пределах окна хранения, в отдельной таблице-архиве. Тему миграций как инструмента разбирает статья про Flyway и Liquibase — оба честно документируют: undo откатывает DDL, а не гарантирует сохранность данных.
Какие изменения обратимы, а какие — нет
Прежде чем паниковать по поводу каждой миграции, стоит разделить их на категории — от этого зависит, нужны ли особые меры предосторожности.
| Тип изменения | Обратимость | Пример |
|---|---|---|
| Добавление колонки (nullable) | Полностью обратимо | ADD COLUMN → DROP COLUMN, колонка была пустой |
| Добавление индекса | Полностью обратимо | CREATE INDEX → DROP INDEX |
| Добавление таблицы | Полностью обратимо | CREATE TABLE → DROP TABLE, пока в неё не пишут |
| Переименование колонки/таблицы | Обратимо | RENAME в обе стороны, данные не теряются |
| Удаление колонки | Необратимо | Данные исчезают при DROP COLUMN |
| Удаление таблицы | Необратимо | Все строки исчезают при DROP TABLE |
| Сужение типа/точности данных | Необратимо | Округление, обрезка строки |
| Слияние или разбиение колонок | Необратимо | Теряется информация о границе |
NOT NULL с дефолтом задним числом | Условно обратимо | Старые NULL неотличимы от «настоящих» значений |
| Уникальное ограничение с удалением дублей | Необратимо | Дубликаты не восстановятся откатом ограничения |
Общее правило: если миграция добавляет структуру, откат почти всегда безопасен — данных там ещё не было. Если миграция убирает или трансформирует существующие данные, откат структуры возможен, а откат данных — нет, если они не сохранены отдельно.
Практический вывод: на код-ревью миграции стоит явно помечать её категорию и для второй требовать отдельного пункта — «где лежит копия данных до изменения». Это часть регламента работы с боевой базой, а не индивидуальное решение каждого разработчика в моменте.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверТестирование на копии прод-данных: основная линия защиты
Раз откат постфактум не спасает от потери данных, главная защита должна работать *до* применения миграции на проде — это тестирование на реалистичной копии продакшн-данных.
Ключевое слово — реалистичной. Миграция, прогнанная на пустой тестовой базе или на пяти строках синтетики, не покажет ни проблем с производительностью (блокировки на таблице в 40 млн строк ведут себя иначе, чем на таблице в 40 строк), ни проблем с данными (NULL там, где не ждали, дубликаты, которые нарушат новый уникальный индекс, значения вне диапазона нового типа).
Практическая схема, которая закрывает большинство рисков:
# 1. Свежий дамп прод-базы (можно из последнего бэкапа)
pg_dump -h prod-db-host -U app_user -Fc appdb > appdb_snapshot.dump
# 2. Разворачиваем на отдельном сервере
createdb -h test-db-host -U app_user appdb_staging
pg_restore -h test-db-host -U app_user -d appdb_staging appdb_snapshot.dump
# 3. Прогоняем миграцию тем же инструментом, что и на проде
flyway -url=jdbc:postgresql://test-db-host/appdb_staging migrate
# 4. Замеряем время и проверяем блокировки во время миграции
psql -h test-db-host -c "SELECT pid, state, query, now() - query_start AS duration \
FROM pg_stat_activity WHERE state != 'idle';"
# 5. Прогоняем тесты приложения против staging с применённой миграцией —
# не "накатилось без ошибок", а "приложение реально работает"
Если под рукой нет отдельного окружения для таких прогонов — это стоит исправить в первую очередь, до планов отката. Постоянная точка входа с копией боевой базы — то, что описано в статье про тестовый сервер с копией боевой базы — снимает большую часть риска ещё до того, как миграция увидит прод. Копию стоит обновлять регулярно (раз в неделю или перед каждой значимой миграцией), иначе через полгода staging разъедется с продом настолько, что тесты на нём перестанут что-либо гарантировать. И маскируйте чувствительные поля (email, телефоны, платёжные реквизиты) при выгрузке, если staging доступен шире, чем прод.
Отдельная, менее очевидная часть тестирования — не только «применилась ли миграция», но и «сколько времени она заняла и какие блокировки держала». ADD COLUMN ... NOT NULL DEFAULT ... на таблице в 20 млн строк в старых версиях PostgreSQL (до 11) требовал полной перезаписи с блокировкой на запись — на проде это многоминутный даунтайм, а на маленькой тестовой таблице пройдёт за миллисекунды и никого не насторожит.
Явный бэкап данных перед потенциально необратимым шагом
Тестирование на копии снижает риск ошибки в самой миграции, но не отменяет того, что часть миграций по замыслу удаляет или трансформирует данные. Для них нужна не проверка, а явное резервное сохранение — отдельный шаг до up-миграции, который делает восстановление данных возможным независимо от того, что случится со схемой дальше.
Практические паттерны вместо надежды на down-скрипт:
Переименование вместо удаления (soft-drop). Вместо DROP COLUMN legacy_status — сначала ALTER TABLE orders RENAME COLUMN legacy_status TO legacy_status_deprecated_20260814, оставить колонку на несколько релизов, и только потом, отдельной миграцией, когда точно ясно, что она не нужна, — DROP COLUMN. Не бесплатно (лишнее поле мешает читаемости), но даёт настоящий откат на весь переходный период.
Архивная копия перед удалением. Если данные всё же нужно удалить сразу (например, по требованию регламента об удалении персональных данных), сохраните их отдельно перед удалением:
-- Перед DROP COLUMN или массовым UPDATE/DELETE
CREATE TABLE orders_legacy_status_archive_20260814 AS
SELECT id, legacy_status, now() AS archived_at
FROM orders
WHERE legacy_status IS NOT NULL;
-- Дальше можно спокойно выполнять основную миграцию
ALTER TABLE orders DROP COLUMN legacy_status;
Такая архивная таблица занимает место, поэтому у неё должен быть срок жизни и явный план удаления — иначе база обрастает десятками архивов, о происхождении которых через год никто не вспомнит. Если данные подпадают под требования по удалению (152-ФЗ, GDPR и аналоги), архивная копия — тоже хранение персональных данных: «удалили из основной таблицы, но оставили в архиве навсегда» само может быть нарушением. Смежная тема — в статье про регламент удаления данных и копии в бэкапах.
Экспорт в файл вне базы. Для одноразовых операций достаточно COPY в CSV перед изменением:
psql -h prod-db-host -U app_user -c \
"COPY (SELECT id, legacy_status FROM orders WHERE legacy_status IS NOT NULL) \
TO STDOUT WITH CSV HEADER" > orders_legacy_status_backup_20260814.csv
Файл кладётся в защищённое хранилище (не рядом с самой базой) с понятным именем и датой — самый дешёвый вариант страховки для миграций, которые прогоняются редко и вручную.
Правило, которое стоит закрепить письменно: любая миграция, помеченная как необратимая, не проходит код-ревью без указанного места хранения снимка данных до изменения. Не «мы же бэкапим всю базу каждую ночь» (это восстанавливает всё состояние целиком, а не позволяет точечно вернуть одну колонку) — а конкретная строка в описании миграции: где лежит архив и как его применить обратно.
Что на самом деле делают down-миграции в инструментах вроде Flyway, Liquibase и Alembic
Flyway (undo в Teams-редакции, в community — откат новой forward-миграцией), Liquibase (rollback), Alembic (downgrade), Django (migrate app 0003) — все умеют откатывать изменения схемы, если вы сами написали корректный обратный DDL. Ни один не восстанавливает данные, уничтоженные операцией up, если вы явно не запрограммировали это сами — например, вставив в down INSERT из заранее сохранённой архивной таблицы.
Более того, инструменты миграций не хранят «снимок» данных на момент каждой миграции — только историю применённых версий схемы (flyway_schema_history, alembic_version). Это метаданные о том, *что* было применено и когда, а не бэкап того, *что было до*.
Отдельная грабля: down-миграции часто не поддерживаются в рабочем состоянии. Команда пишет up, тестирует, деплоит — а down пишется формально и никогда не прогоняется до момента, когда он внезапно понадобится в кризис. К этому времени схема могла уйти на несколько миграций вперёд, и down просто не применится к текущему состоянию. Если полагаетесь на down как на план восстановления — периодически прогоняйте цепочку up → down → up на staging.
После применения миграции на проде стоит не полагаться на «раз ошибок нет, значит всё в порядке», а иметь чёткий чек-лист проверки — этому посвящена статья о том, как проверить, что миграция прошла успешно.
План Б — это восстановление из бэкапа, а не откат схемы
Когда команда планирует «что если миграция пойдёт не так», нужно сразу договориться о терминологии — путаница между двумя разными вещами и есть источник большинства неприятных сюрпризов:
- Откат схемы (
down-миграция) — быстрая операция в первые минуты послеup, если проблема обнаружена сразу и данные ещё не начали меняться под новую схему. Работает надёжно только для обратимых по природе изменений. - Восстановление из бэкапа — единственный универсальный план Б, применимый для любой миграции, включая необратимые. Он не «отменяет одну миграцию», а откатывает состояние всей базы на момент до её начала — со всеми последствиями: записи, созданные между стартом миграции и восстановлением, тоже пропадут, если их не перенести отдельно.
Практический вывод: не пишите в плане миграции «в случае проблем — откатим миграцию» как единственный пункт плана Б. Пишите две ветки: проблема обнаружена сразу и без потери данных — применяем down-скрипт или компенсирующую миграцию; данные уже пострадали или изменение необратимо по природе — восстанавливаемся из бэкапа на отдельном инстансе, сверяем и переносим вручную то, что записалось после старта миграции, и только потом переключаем прод.
Это требует, чтобы было из чего восстанавливаться — рабочий, регулярно проверяемый бэкап с известным RPO (сколько данных потеряется — интервал между бэкапом/WAL-архивом и сбоем) и RTO (сколько реально займёт восстановление, а не в теории). Если вы ни разу не разворачивали свой последний бэкап на чистом сервере и не замеряли время — у вас нет плана Б, у вас есть файл, про который вы верите, что он рабочий.
Точная процедура восстановления после плохой миграции обычно выглядит так:
# 1. Останавливаем запись приложения в базу (maintenance mode)
# 2. Разворачиваем последний бэкап на отдельном инстансе, НЕ поверх прод-базы
pg_restore -h recovery-host -U app_user -d appdb_recovery latest_backup.dump
# 3. Если есть WAL-архив/binlog после точки бэкапа — докатываем до момента
# перед стартом проблемной миграции (recovery_target_time)
# 4. Сверяем строки и контрольные суммы ключевых таблиц с прод-базой
# 5. Переносим вручную записи, созданные ПОСЛЕ бэкапа, но ДО миграции —
# их бэкап не содержит, а к самой миграции они не относятся
# 6. Переключаем приложение на recovery-инстанс
Шаг 5 — самый недооценённый: если между бэкапом и стартом миграции прошло несколько часов, восстановление «в лоб» отбросит и эти данные тоже, хотя они не связаны с неудачной миграцией. Регулярность бэкапов и RPO важны отдельно от темы миграций — это конкретный параметр, определяющий, сколько часов работы вы готовы потерять в худшем случае.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли автоматически определить, обратима ли миграция?
Частично — линтеры вроде squawk для PostgreSQL или проверки некоторых версий Liquibase находят опасные операции (DROP COLUMN, DROP TABLE, сужение типов) и требуют подтверждения. Финальное решение о нужности архивной копии всё равно принимает человек, знающий контекст таблицы.
Нужно ли писать down-миграцию, если данные всё равно не восстановятся?
Да, для отката *схемы* — это полезно, если проблема в структуре, а не в потере данных. Просто не рассчитывайте, что down решит проблему потерянных данных.
Что делать, если необратимая миграция уже применена, а архивной копии не сделали?
Единственный путь — восстановление из бэкапа/WAL-архива на отдельный инстанс и ручной перенос данных оттуда, если точка бэкапа была после последнего их изменения. Если бэкапа не было — данные потеряны безвозвратно.
Сколько держать soft-drop колонку перед удалением?
Ориентируйтесь не на календарь, а на цикл релизов и на то, когда убедились, что код (включая фоновые задачи и отчёты) больше не читает колонку — часто от двух недель до пары месяцев.
Отличается ли ситуация для MySQL/MariaDB?
Принципиально нет — DROP COLUMN в MySQL так же необратим на уровне данных. Детали отличаются (pt-online-schema-change/gh-ost для миграций без долгой блокировки на больших таблицах), но логика «схему вернуть можно, данные — нет» универсальна для любой реляционной СУБД.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →