Почему откат базы данных сложнее, чем откат приложения

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

А теперь представьте другой сценарий. Вы выполняете миграцию базы данных, которая добавляет колонку status в таблицу orders. Миграция завершается, новая колонка заполняется значениями по умолчанию, и ваше обновленное приложение начинает записывать реальные данные в эту колонку. Несколько часов спустя вы обнаруживаете баг в логике приложения, из-за которого значения status становятся ненадежными. Вы решаете откатить приложение до предыдущей версии. Старый код снова работает. Но колонка status все еще существует. Данные, записанные в нее, все еще там. И ваше старое приложение может не знать, как обрабатывать эту лишнюю колонку, или, что хуже, может сломаться, столкнувшись с колонкой, которую оно никогда не ожидало.

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

Почему откат приложения прост

Когда вы откатываете приложение, вы, по сути, заменяете один набор исполняемого кода другим. Старая версия вступает в дело, начинает обрабатывать новые запросы, и система продолжает работу. Никакое постоянное состояние не изменяется во время самого отката. База данных остается ровно такой же, какой была до запуска отката. Меняется только то, какая версия кода выполняется.

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

Почему откат базы данных отличается

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

Рассмотрим миграцию, которая удаляет колонку legacy_flag из таблицы users. Если вам нужно откатиться, вы должны добавить эту колонку обратно. Но что насчет данных, которые были в этой колонке? Если миграция просто удалила ее, эти данные потеряны, если вы не сделали резервную копию заранее. Если миграция переименовала или преобразовала колонку, вам нужно точно обратить это преобразование, не повредив новые данные, которые могли быть записаны за это время.

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

-- Прямая миграция: добавляем NOT NULL колонку со значением по умолчанию
ALTER TABLE orders ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending';

-- Через несколько часов новое приложение записывает реальные статусы
-- В некоторых строках теперь status = 'shipped', 'cancelled' и т.д.

-- Откат миграции: удаляем колонку
ALTER TABLE orders DROP COLUMN status;
-- Это выполняется успешно, но все данные статусов потеряны навсегда.

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

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

Более безопасный подход: обратно совместимые миграции

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

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

Вот как обратно совместимые миграции работают на практике для типовых операций:

  • Добавление колонки: Просто добавьте ее. Не делайте ее NOT NULL, если вы не можете предоставить значение по умолчанию, которое подходит как для старого, так и для нового кода. Старое приложение не будет читать или писать в нее, поэтому оно не будет затронуто.

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

  • Удаление колонки: Сначала прекратите ее использование в приложении. Выкатите это изменение. Затем, в отдельной миграции, удалите колонку. Если вам нужно откатить приложение, колонка все еще будет на месте.

  • Изменение типа колонки: Добавьте новую колонку с новым типом, перенесите данные постепенно, обновите приложение для использования новой колонки и только затем удалите старую колонку.

Каждый из этих шаблонов добавляет шаги, но каждый шаг обратим без потери данных.

Реальная цена down-миграций

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

Когда происходит инцидент в продакшене и вам нужно быстро откатиться, последнее, что вы хотите — запускать непроверенную down-миграцию, которая может завершиться ошибкой, занять слишком много времени или молча удалить данные. Цейтнот, стресс и отсутствие чистого fallback-варианта делают down-миграции азартной игрой.

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

Практический чек-лист для планирования отката базы данных

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

  • Может ли старая версия приложения все еще корректно работать после этой миграции?
  • Если миграция добавляет колонку, игнорирует ли ее старый код?
  • Если миграция удаляет колонку, прекратил ли старый код ее использование?
  • Если миграция переименовывает или изменяет колонку, предусмотрен ли переходный период, когда обе структуры сосуществуют?
  • Существует ли проверенный, безопасный способ отменить эту миграцию без потери данных?

Если вы не можете ответить «да» на все эти вопросы, ваша миграция несет риск отката, который вы не учли.

Вывод

Откат базы данных — это не просто возврат версии. Это сохранение данных в целости и согласованности, обеспечивая при этом возможность возврата приложения в предыдущее состояние без побочных эффектов. Самый безопасный путь — проектировать миграции так, чтобы они не заставляли вас выбирать между откатом приложения и потерей данных. Встраивайте обратную совместимость в каждое изменение схемы и рассматривайте down-миграции как крайнюю меру, а не стратегию по умолчанию. Ваше будущее «я», отлаживающее инцидент в 2 часа ночи, скажет вам спасибо.