Изменение схем баз данных без остановки продакшена

У вас есть база данных, которая работает пять, десять или пятнадцать лет. В ней миллионы транзакций, тысячи таблиц и сотни хранимых процедур, написанных людьми, которые уже могут не работать в компании. Каждый раз, когда нужно добавить колонку, изменить тип данных или исправить индекс, возникает один и тот же вопрос: «Если эта миграция упадет, сколько времени займет восстановление?»

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

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

Настоящий вопрос не в том, «какой инструмент миграции лучше». Он в том: «Как изменить схему базы данных без остановки сервиса и как откатиться, если что-то пошло не так?»

Безопасная миграция начинается с маленьких шагов

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

Возьмем добавление новой колонки. В устаревшей базе данных вы добавляете колонку со значением по умолчанию или делаете ее nullable. Вы не добавляете строгие ограничения сразу. Старые экземпляры приложения продолжают работать, потому что они не читают новую колонку. Новые экземпляры приложения начинают в нее писать. После того как все экземпляры обновлены и работают стабильно, вы добавляете ограничения, такие как NOT NULL или внешние ключи, в отдельной миграции. Если что-то пошло не так на полпути, откат так же прост, как игнорирование новой колонки. Нет необходимости удалять таблицы или восстанавливаться из резервной копии.

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

sequenceDiagram participant OldApp as Приложение (старая версия) participant DB as База данных participant NewApp as Приложение (новая версия) Note over DB: Шаг 1: Добавить колонку nullable DB->>DB: ALTER TABLE ADD COLUMN nullable Note over OldApp,DB: Шаг 2: Старое приложение продолжает работу без изменений OldApp->>DB: Чтение/запись (игнорирует новую колонку) DB-->>OldApp: Ответ Note over DB,NewApp: Шаг 3: Развернуть новое приложение, которое использует колонку NewApp->>DB: Запись в новую колонку DB-->>NewApp: OK NewApp->>DB: Чтение из новой колонки DB-->>NewApp: Данные Note over DB: Шаг 4: Добавить ограничения DB->>DB: ALTER TABLE ADD NOT NULL Note over OldApp: Шаг 5: Удалить старое приложение OldApp-->>OldApp: Выведено из эксплуатации

Тот же шаблон применим к изменению типа данных. Предположим, колонка price сейчас имеет тип INTEGER, но должна стать DECIMAL. Безопасный подход: добавить новую колонку с именем price_decimal, заполнить ее преобразованными значениями из старой колонки, дать приложению читать из новой колонки, продолжая писать в обе, а затем удалить старую колонку, когда все стабилизируется. Откат означает, что приложение снова читает из старой колонки, которая все еще существует.

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

-- Прямая миграция 1: добавить колонку как nullable
ALTER TABLE products ADD COLUMN discount_rate DECIMAL(5,2) NULL;

-- Обратное заполнение данных (запустить после того, как приложение начнет писать в новую колонку)
UPDATE products SET discount_rate = 0.00 WHERE discount_rate IS NULL;

-- Прямая миграция 2: добавить ограничение NOT NULL
ALTER TABLE products ALTER COLUMN discount_rate SET NOT NULL;

-- Скрипт отката (отменяет оба шага)
ALTER TABLE products ALTER COLUMN discount_rate DROP NOT NULL;
ALTER TABLE products DROP COLUMN discount_rate;

Сложные изменения требуют параллельного запуска

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

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

Миграция — это данные, а не только схема

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

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

Где миграция вписывается в конвейер

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

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

Когда требуется ручное утверждение

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

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

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

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

Вывод

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