Почему миграции баз данных ломаются без простоя
Миграция базы данных без простоя — это не технический фокус, а последовательность решений, где каждое следующее зависит от предыдущего. Ошибка на любом шаге превращает “без простоя” в “с простоями, но мы не знаем где”. Инструменты здесь вторичны: даже идеальный инструмент не спасёт, если применять его не к той задаче или не в том порядке.
Простой возникает не из-за изменений в данных, а из-за несовместимости версий приложения и схемы. Если приложение может работать с обеими версиями схемы одновременно, простоя не будет. Всё остальное — следствия из этого условия. Но как понять, когда это условие выполнено, а когда нет?
Как отличить совместимые изменения от несовместимых
Миграции делятся на два класса: совместимые и несовместимые. Совместимые можно проводить в любой момент, не останавливая приложение. Несовместимые требуют синхронизации версий приложения и базы. Вот как их отличить без привязки к конкретным СУБД или версиям:
Совместимые изменения
Совместимые изменения — это те, которые не ломают существующий код. Приложение может продолжать работать, игнорируя эти изменения, пока не начнёт их использовать.
- Добавление столбца: приложение может игнорировать новый столбец, пока не начнёт его использовать. Например, если добавляется столбец
last_login_at, старый код продолжит работать, так как не знает о его существовании. - Добавление индекса: приложение может работать без индекса, пока он строится. Индексы можно добавлять с помощью
CONCURRENTLY, чтобы не блокировать таблицу. - Добавление ограничения: если ограничение не блокирует существующие данные. Например, добавление nullable foreign key не нарушит работу приложения, так как существующие данные останутся валидными.
- Удаление индекса: приложение может работать без индекса, пока не начнёт тормозить. Удаление индекса тоже можно делать с помощью
CONCURRENTLY.
Несовместимые изменения
Несовместимые изменения — это те, которые ломают существующий код. Приложение не сможет работать с новой схемой без изменений.
- Удаление столбца: приложение упадёт при попытке доступа к удалённому столбцу. Например, если удаляется столбец
old_field, любой запрос, который его использует, завершится с ошибкой. - Изменение типа данных: приложение может не уметь работать с новым типом. Например, если столбец
amountменяется сINTнаDECIMAL(10,2), код, который ожидает целое число, может упасть. - Изменение ограничения: например, добавление
NOT NULLк существующему столбцу. Если в столбце уже естьNULLзначения, миграция завершится с ошибкой. - Переименование столбца или таблицы: приложение не найдёт переименованную структуру. Любой запрос, который использует старое имя, завершится с ошибкой.
Совместимые изменения можно проводить в любой момент, даже под нагрузкой. Несовместимые требуют особого порядка действий.
Как подготовить приложение к несовместимым изменениям
Если миграция несовместимая, приложение должно уметь работать с обеими версиями схемы. Это достигается через несколько механизмов:
-
Добавление кода для новой схемы
- Если удаляется столбец
old_field, код должен уметь работать как с ним, так и без него. Например, можно добавить проверку наличия столбца перед его использованием. - Если меняется тип данных, код должен уметь работать с обоими типами. Например, принимать и строку, и число, и приводить их к нужному типу.
- Если удаляется столбец
-
Проверка наличия структур перед использованием
- В SQL это выглядит как проверка наличия столбца в системных таблицах или попытка доступа с обработкой ошибки. Например, можно использовать
information_schema.columnsдля проверки наличия столбца. - В ORM это проверка наличия поля в модели или использование try-catch блоков. Например, в Django можно использовать
hasattr(model, 'field_name').
- В SQL это выглядит как проверка наличия столбца в системных таблицах или попытка доступа с обработкой ошибки. Например, можно использовать
-
Логирование ошибок доступа к удалённым структурам
- Это поможет отловить места, где код всё ещё зависит от старой схемы.
- Логи должны быть достаточно детальными, чтобы можно было понять, какой именно запрос упал и почему.
Этот шаг нельзя пропустить: без него приложение упадёт при первом же обращении к удалённому столбцу или ограничению.
Почему сначала развёртывать код, а потом менять схему
Новая версия приложения должна быть развёрнута до изменения схемы. Это критически важно: если сначала изменить схему, а потом развернуть приложение, простой неизбежен.
Порядок действий:
-
Развернуть новую версию приложения на всех инстансах
- Это может занять время, особенно если инстансов много или они разнесены географически.
- Нужно убедиться, что все инстансы получили новую версию. Это можно сделать через healthcheck или версионирование API.
-
Проверить, что все инстансы работают с новой версией
- Это можно сделать через логирование версий или специальные эндпоинты.
- Если какой-то инстанс не обновился, его нужно либо обновить, либо вывести из балансировки.
-
Проверить логи на наличие ошибок доступа к удалённым структурам
- Если ошибки есть, нужно либо исправить код, либо откатить развёртывание.
Только после этого можно переходить к изменению схемы.
Почему простое решение не работает
Первое, что приходит в голову — сделать миграцию в один шаг: изменить схему и сразу переключить приложение. Но это не работает по нескольким причинам:
- Блокировка таблицы: многие команды
ALTER TABLEблокируют таблицу на запись, что приводит к простоям. Например, изменение типа столбца может заблокировать таблицу на время выполнения команды. - Несовместимость версий: если приложение не готово к новой схеме, оно упадёт. Например, если удалить столбец, который ещё используется в коде, приложение завершится с ошибкой.
- Потеря данных: если данные не перенесены корректно, они могут быть потеряны. Например, если изменить тип столбца без переноса данных, часть данных может быть утеряна.
Поэтому миграция всегда должна быть многошаговой.
Как менять схему без блокировок
Теперь можно менять схему базы. Для несовместимых миграций порядок такой:
-
Добавить новые структуры
- Например, если меняется тип столбца
amountсINTнаDECIMAL(10,2), сначала добавляется новый столбецamount_new. - Индексы добавляются с помощью
CONCURRENTLY, чтобы не блокировать таблицу.
- Например, если меняется тип столбца
-
Перенести данные из старых структур в новые
- Это можно делать параллельно с работой приложения, если перенос не блокирует таблицу.
- Например, можно использовать batch-обработку, чтобы не перегружать базу. Данные переносятся небольшими порциями, чтобы не блокировать таблицу надолго.
-
Переключить приложение на новые структуры
- Например, изменить код так, чтобы он использовал
amount_newвместоamount. - Это можно делать постепенно, начиная с наименее критичных частей приложения.
- Например, изменить код так, чтобы он использовал
-
Удалить старые структуры
- Только после того, как все данные перенесены и приложение переключено.
- Удаление индексов тоже делается с помощью
CONCURRENTLY.
Для совместимых миграций (например, добавление индекса) этот шаг проще: достаточно выполнить команду CREATE INDEX CONCURRENTLY и дождаться её завершения.
Как проверить миграцию и подготовить откат
После изменения схемы нужно убедиться, что:
- Приложение работает без ошибок.
- Данные не потеряны и не повреждены.
- Производительность не ухудшилась (например, из-за отсутствия индекса).
Проверка:
- Логирование ошибок: проверка логов на наличие ошибок доступа к удалённым структурам.
- Тестирование производительности: сравнение времени выполнения запросов до и после миграции. Если запросы стали выполняться дольше, возможно, не хватает индексов или данные фрагментированы.
- Проверка данных: сравнение контрольных сумм данных до и после миграции. Это можно сделать с помощью хэш-функций или специализированных инструментов.
Откат:
Если что-то пошло не так, должен быть план отката:
- Вернуть старую версию приложения.
- Отменить изменения схемы (если это возможно). Например, если был добавлен новый столбец, его можно удалить.
- Восстановить данные из бэкапа (если данные были потеряны).
Откат должен быть протестирован заранее: в критической ситуации времени на эксперименты не будет.
Когда этот порядок не работает
Описанный порядок не подходит для некоторых случаев:
-
Миграции с потерей данных
- Например, удаление столбца с важными данными без их переноса в новое место.
- В этом случае простой неизбежен, так как приложение не сможет работать без этих данных.
-
Изменения, требующие перезагрузки базы
- Например, изменение параметров конфигурации, которые требуют перезапуска PostgreSQL.
- Здесь простой неизбежен.
-
Миграции с блокировкой таблицы
- Например,
ALTER TABLEбезCONCURRENTLYна большой таблице. - В этом случае простой будет равен времени выполнения команды.
- Например,
-
Миграции, требующие изменения данных
- Например, преобразование данных из одного формата в другой.
- Если это преобразование нельзя сделать параллельно с работой приложения, простой будет неизбежен.
В таких случаях нужно либо смириться с простоями, либо искать обходные пути. Например, можно использовать репликацию данных в другую таблицу с последующим переключением.
Позиция редакции
Миграция без простоя — это результат тщательной подготовки, а не волшебства. Главное условие успеха: приложение должно уметь работать с обеими версиями схемы одновременно. Если это условие не выполнено, простой неизбежен.
Этот порядок работает для большинства случаев, но не для всех. Например:
- Если миграция требует изменения данных, которые нельзя перенести параллельно с работой приложения, простой будет неизбежен.
- Если миграция затрагивает критически важные данные, которые нельзя потерять ни при каких обстоятельствах, простой может быть необходим для гарантии целостности данных.
Единственный способ убедиться, что порядок работает, — протестировать его на копии базы под нагрузкой, максимально приближенной к боевой. Без тестирования любая миграция — это лотерея, где ставка — работоспособность системы.
Условие, при котором позиция неверна
Если приложение не может быть изменено (например, это стороннее ПО без исходников), то миграция без простоя невозможна. В этом случае нужно либо смириться с простоями, либо искать обходные пути. Например, можно использовать репликацию данных в другую базу с последующим переключением на неё. Однако даже в этом случае простой может быть неизбежен, если приложение не поддерживает динамическое переключение источников данных.