Написание миграций базы данных, которые не сломают продакшн
У вас есть база данных, работающая месяцами. Пользователи зависят от неё. Таблицы выросли, запросы оптимизированы, схема устоялась. И тут появляется задача, требующая новую колонку, переименование таблицы или миграцию данных.
В тот момент, когда вы выполняете ALTER TABLE на продакшне, вы делаете ставку. Если миграция выполняется слишком долго, запросы встают в очередь. Если она блокирует таблицу, пользователи видят ошибки. Если она падает на полпути, нужен путь назад. А если у вас нет плана отката, единственный вариант — восстановление из бекапа, что означает потерю всех данных, введённых после последнего снимка.
Вот почему безопасные миграции базы данных — это не только про написание корректного SQL. Это про структурирование изменений так, чтобы их можно было ревьюить, тестировать и откатывать без паники.
Для каждой миграции нужно два файла
Простейший паттерн, который не раз спасал команды — это пара up- и down-миграций.
Вот конкретный пример парной up и down миграции:
-- 20241101_add_last_login_at.up.sql
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP;
-- 20241101_add_last_login_at.down.sql
ALTER TABLE users DROP COLUMN last_login_at;
Up-миграция содержит SQL, который вносит изменение. Down-миграция содержит SQL, который его отменяет. Для каждого изменения создаются оба файла, хранящиеся вместе с уникальным идентификатором, чтобы порядок был очевиден.
20241101_add_email_index.sql -- up
20241101_add_email_index_down.sql -- down
Идентификатором может быть временная метка, порядковый номер или префикс с датой. Важно, чтобы любой, кто смотрит на папку, видел точный порядок изменений. Когда миграция выполняется на продакшне и что-то идёт не так, down-миграция даёт быстрый и предсказуемый способ отката.
Без down-миграции ваш единственный запасной вариант — полное восстановление базы данных. Это требует времени, координации и рискует потерей недавних данных. Down-миграция выполняется за секунды.
Когда down-миграций недостаточно
Down-миграции хорошо работают для обратимых изменений: добавление колонки, создание индекса, вставка справочных данных. Но некоторые изменения сложно отменить.
Удаление колонки — типичный пример. Как только колонка исчезла, а данные удалены, down-миграция, которая добавляет колонку заново, не сможет вернуть данные. Та же проблема возникает при переименовании таблиц, изменении типов колонок или удалении ограничений, от которых зависят другие части системы.
В таких случаях безопасный подход — разбить изменение на несколько маленьких миграций, каждая из которых обратима сама по себе:
- Добавьте новую колонку нужного типа.
- Заполните данные пакетами.
- Обновите код приложения для использования новой колонки.
- Удалите старую колонку.
У каждого шага есть свои up- и down-миграции. Если на шаге 3 обнаружится проблема, вы можете чисто откатить шаги 2 и 1. Вы никогда не окажетесь в ситуации, когда единственный выход — восстановление.
Пишите миграции, которые можно выполнять несколько раз
Паплайны падают. Сеть обрывается, происходят таймауты, и иногда миграция выполняется наполовину до того, как процесс падает. Когда паплайн перезапускается, миграция запускается снова.
Если ваша миграция предполагает, что изменение ещё не применено, она упадёт при втором запуске. Этот сбой блокирует весь паплайн и требует ручного вмешательства.
Сделайте каждую миграцию идемпотентной. Используйте IF NOT EXISTS при создании таблиц или индексов. Используйте IF EXISTS при удалении объектов. Проверяйте, существует ли колонка, прежде чем изменять её. Цель проста: повторный запуск той же миграции должен дать тот же результат, что и первый.
Избегайте длительных блокировок на больших таблицах
ALTER TABLE, изменяющий тип колонки, может заблокировать всю таблицу на минуты, если в ней миллионы строк. В это время каждое чтение и запись в эту таблицу ждут. Пользователи видят таймауты. Очереди растут.
Решение — избегать одношаговых изменений схемы на больших таблицах. Вместо этого используйте многошаговый подход:
- Добавьте новую колонку нужного типа.
- Обновляйте строки пакетами, чтобы заполнить новую колонку.
- Добавьте индекс, если нужно.
- Удалите старую колонку в следующей миграции.
Каждый шаг блокирует таблицу ненадолго. Приложение может продолжать работу между шагами. Этот паттерн дольше писать, но гораздо безопаснее выполнять.
Не храните значения, зависящие от окружения, в файлах миграций
Файл миграции должен работать одинаково в разработке, стейджинге и продакшне. Если вы захардкодите имя базы данных, пароль или строку подключения в SQL, файл станет привязан к одному окружению. Вы не сможете запустить его в другом месте без редактирования, а редактирование файла миграции после ревью противоречит цели системы контроля версий.
Используйте параметры, переменные окружения или конфигурацию, предоставляемые инструментом миграций. Сам SQL должен содержать только логику схемы и данных, а не детали окружения.
Храните миграции вместе с кодом приложения
Есть два распространённых подхода: хранить файлы миграций в том же репозитории, что и код приложения, или в отдельном репозитории. Оба работают, но выбор влияет на то, как команды координируют изменения.
Когда миграции живут в том же репозитории, каждый пул-реквест, меняющий схему, также включает миграцию. Ревью кода охватывает и изменение приложения, и изменение базы данных вместе. Это упрощает выявление несоответствий, например, запроса, который ссылается на колонку, ещё не добавленную.
Когда миграции живут в отдельном репозитории, изменения приложения и базы данных могут развиваться в разном темпе. Это полезно, когда несколько сервисов используют одну базу данных, но требует больше координации для синхронизации схемы и кода.
В любом случае ключевое — файлы миграций должны быть под версионным контролем, проходить ревью и быть отслеживаемыми. Изменение базы данных должно оставлять такой же аудиторский след, как и изменение кода.
Практический чек-лист для написания безопасных миграций
Прежде чем смержить миграцию в паплайн, проверьте следующее:
- Есть ли у каждой миграции соответствующая down-миграция?
- Может ли down-миграция реально восстановить предыдущее состояние без потери данных?
- Является ли миграция идемпотентной? Можно ли запустить её дважды без ошибок?
- Заблокирует ли миграция большую таблицу более чем на несколько секунд?
- Отсутствуют ли в SQL-файле значения, зависящие от окружения?
- Хранится ли файл миграции в репозитории с кодом приложения или в выделенном репозитории базы данных?
Вывод
Безопасные миграции базы данных — это не про избегание изменений. Это про то, чтобы сделать изменения обратимыми, тестируемыми и проверяемыми. Каждый написанный вами файл миграции — это небольшой контракт: вот что меняется, и вот как это отменить. Когда этот контракт ясен, команда может двигаться быстрее, потому что знает, что у неё есть путь назад.