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

Вы разворачиваете новую функциональность, для которой нужна дополнительная колонка в таблице users. Пишете скрипт миграции, запускаете на стейджинге — всё работает. Затем кто-то из команды случайно запускает тот же скрипт повторно во время перезапуска пайплайна. И получаете ошибку: "column already exists". Деплой падает, кому-то приходится вручную править базу, а релиз задерживается.

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

Что делает миграцию безопасной

Ключевой принцип — идемпотентность. Операция идемпотентна, если её однократное или стократное выполнение приводит к одному и тому же конечному состоянию. Речь не о том, чтобы запретить повторный запуск скрипта. Речь о том, чтобы повторный запуск не нанёс вреда.

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

Простой паттерн: проверяй перед действием

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

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

-- Неидемпотентно: падает, если колонка уже существует
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP;

-- Идемпотентно: выполняется успешно независимо от наличия колонки
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP;

Первая версия выдаст ошибку, если колонка уже существует. Вторая версия безопасно выполняется каждый раз.

Вместо:

ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);

Пишите:

ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number VARCHAR(20);

Конструкция IF NOT EXISTS гарантирует, что скрипт выполнится успешно, независимо от того, существует колонка или нет. Тот же подход работает для индексов, ограничений и новых таблиц. PostgreSQL, MySQL и большинство современных СУБД поддерживают такие условные конструкции.

Для удаления действует та же логика. Попытка удалить несуществующую колонку приведёт к ошибке. Используйте IF EXISTS, чтобы сделать операцию безопасной:

ALTER TABLE users DROP COLUMN IF EXISTS old_phone_number;

Работа с миграциями данных

Добавление и удаление колонок — это простые случаи. Настоящая сложность возникает, когда нужно переместить или преобразовать существующие данные. Предположим, вы разделяете колонку full_name на first_name и last_name. Миграция должна:

  1. Добавить две новые колонки
  2. Заполнить их из существующих данных
  3. Обработать случай повторного запуска скрипта

Наивный подход скопирует данные без проверки, были ли они уже скопированы. Повторный запуск создаст дубликаты или перезапишет корректные значения. Безопасный паттерн выглядит так:

-- Добавляем колонки, если их нет
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);

-- Заполняем только если целевые колонки пусты
UPDATE users 
SET first_name = SPLIT_PART(full_name, ' ', 1),
    last_name = SPLIT_PART(full_name, ' ', 2)
WHERE first_name IS NULL AND last_name IS NULL;

Условие WHERE гарантирует, что обновление выполнится только для строк, которые ещё не были обработаны. При повторном запуске скрипта эти строки уже заполнены, и обновление ничего не делает.

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

Постепенное удаление снижает риски

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

  1. Первая миграция: переименовать колонку в column_name_deprecated
  2. Подождать несколько релизных циклов, чтобы убедиться, что ничего не сломалось
  3. Вторая миграция: удалить устаревшую колонку

Этот паттерн даёт вашей команде время обнаружить код, который всё ещё ссылается на старую колонку. Если что-то пошло не так, переименование обратимо. Удаление — нет.

Ведите журнал выполненных миграций

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

Большинство фреймворков миграций, таких как Flyway или Liquibase, делают это автоматически. Они создают таблицу, которая отслеживает каждый скрипт миграции по имени, контрольной сумме и временной метке выполнения. Если вы пишете сырые SQL-скрипты без фреймворка, создайте свою таблицу отслеживания:

CREATE TABLE IF NOT EXISTS migration_log (
    script_name VARCHAR(255) PRIMARY KEY,
    started_at TIMESTAMP,
    completed_at TIMESTAMP,
    status VARCHAR(20),
    script_hash VARCHAR(64)
);

Перед запуском любой миграции вставьте строку с именем скрипта и статусом "running". После завершения обновите статус на "success" или "failed". Если скрипт упал, пайплайн можно перезапустить, а раннер миграций сможет проверить, завершился ли скрипт успешно ранее.

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

Практический чек-лист для написания скриптов миграций

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

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

Вывод

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