Написание миграций базы данных, которые не сломаются при повторном запуске
Вы разворачиваете новую функциональность, для которой нужна дополнительная колонка в таблице 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. Миграция должна:
- Добавить две новые колонки
- Заполнить их из существующих данных
- Обработать случай повторного запуска скрипта
Наивный подход скопирует данные без проверки, были ли они уже скопированы. Повторный запуск создаст дубликаты или перезапишет корректные значения. Безопасный паттерн выглядит так:
-- Добавляем колонки, если их нет
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 гарантирует, что обновление выполнится только для строк, которые ещё не были обработаны. При повторном запуске скрипта эти строки уже заполнены, и обновление ничего не делает.
Для более сложных сценариев может потребоваться сравнение исходных и целевых данных или использование контрольной суммы для проверки корректности преобразования. Принцип остаётся тем же: никогда не предполагайте, что данные находятся в исходном состоянии.
Постепенное удаление снижает риски
Удаление колонок или таблиц рискованно, потому что его сложно отменить. Более безопасный подход — делать это поэтапно:
- Первая миграция: переименовать колонку в
column_name_deprecated - Подождать несколько релизных циклов, чтобы убедиться, что ничего не сломалось
- Вторая миграция: удалить устаревшую колонку
Этот паттерн даёт вашей команде время обнаружить код, который всё ещё ссылается на старую колонку. Если что-то пошло не так, переименование обратимо. Удаление — нет.
Ведите журнал выполненных миграций
Идемпотентность обрабатывает случай многократного выполнения скрипта. Но также нужно знать, какие скрипты выполнялись, когда и успешно ли. Это ваш аудиторский след.
Большинство фреймворков миграций, таких как 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 часа ночи, скажет вам спасибо.