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

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

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

Базы данных работают иначе.

Реальность stateful баз данных

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

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

Конкретный пример проблемы

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

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

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

Рассмотрим SQL-миграцию, которая добавляет эту колонку:

-- Up migration: add phone_number column
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) NOT NULL DEFAULT '';

-- Down migration: remove phone_number column
ALTER TABLE users DROP COLUMN phone_number;

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

Следующая диаграмма последовательности иллюстрирует разницу между откатом приложения и откатом базы данных в одном и том же сценарии:

sequenceDiagram participant AppV2 as App v2 participant DB as Database participant AppV1 as App v1 Note over AppV2,AppV1: Откат приложения AppV2->>DB: Использует колонку phone_number AppV2->>AppV2: Обнаружена ошибка AppV2->>AppV1: Откат до v1 AppV1->>DB: Работает (не использует phone_number) Note over AppV1: Успех Note over AppV2,AppV1: Откат базы данных AppV2->>DB: Добавление колонки phone_number AppV2->>AppV2: Обнаружена ошибка AppV2->>DB: Запуск down-миграции DB->>DB: Удаление колонки и данных AppV1->>DB: Попытка чтения старой схемы DB-->>AppV1: Несоответствие кода и схемы Note over AppV1: Всё еще сломано

Проблема несоответствия кода и схемы

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

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

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

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

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

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

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

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

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

Более безопасный путь: Roll Forward

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

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

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

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

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

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

Вывод

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