Когда миграции базы данных ломают работающие приложения
Ваша команда только что выкатила новую фичу. Развёртывание прошло чисто. Но через пять минут дежурный инженер присылает скриншот логов с ошибками. Старые инстансы приложения падают с ошибками базы данных. Запрос SELECT * FROM users WHERE status = 'active' внезапно перестал работать. Что случилось?
Вы изменили тип колонки status с VARCHAR на INT в миграции. Новый код приложения отлично работает с целыми числами. Но во время rolling update старые и новые инстансы работают одновременно. Старые инстансы всё ещё ожидают строки. Схема базы данных изменилась под ними, и они сломались.
Это ключевое противоречие миграций БД в современных развёртываниях: база данных общая, а версии приложения — нет.
Проблема общей базы данных
Когда вы развёртываете с rolling update, blue-green или canary-релизами, несколько версий приложения работают одновременно. Все они подключаются к одной базе данных. Но каждая версия имеет разные ожидания относительно схемы.
Старое приложение ожидает определённые колонки, типы данных и ограничения. Новое приложение ожидает немного другую структуру. Оба должны корректно работать в переходный период. Если ваша миграция нарушает совместимость со старым приложением, вы получаете ошибки в продакшене.
Это не теоретическая проблема. Это происходит каждый раз, когда миграция меняет то, от чего зависит работающий код.
Обратная совместимость: правило без исключений
Фундаментальное правило просто: каждая миграция должна быть обратно совместима со старым приложением. Старый код должен уметь читать и писать данные без ошибок даже после выполнения миграции.
Рассмотрим две SQL-миграции, чтобы увидеть разницу:
-- Безопасно: добавляем nullable-колонку со значением по умолчанию
-- Старое приложение может делать INSERT без указания phone_number
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) DEFAULT NULL;
-- Опасно: меняем тип колонки с VARCHAR на INT
-- SELECT * FROM users WHERE status = 'active' у старого приложения упадёт,
-- потому что 'active' — строка, а не целое число
ALTER TABLE users ALTER COLUMN status TYPE INT USING status::integer;
Некоторые изменения естественно обратно совместимы. Например, добавление nullable-колонки со значением по умолчанию не ломает существующие запросы. Старое приложение спокойно выполняет INSERT INTO users (name, email) — новая колонка phone принимает NULL.
Другие изменения ломают совместимость мгновенно. Изменение типа колонки, переименование колонки, добавление ограничения NOT NULL в заполненную колонку или добавление внешнего ключа, которому не удовлетворяют существующие данные — всё это вызовет ошибки в старых инстансах приложения.
Правило не обсуждается. Если вы не можете гарантировать обратную совместимость, вы не можете безопасно развёртывать с нулевым временем простоя.
Паттерн Expand-Contract
Самый безопасный способ обработки ломающих изменений — паттерн expand-contract, иногда называемый dual-write. Идея в том, чтобы вносить изменения поэтапно, никогда не удаляя старые структуры, пока все инстансы приложения не будут обновлены.
Следующая диаграмма последовательности иллюстрирует временную шкалу паттерна expand-contract, показывая, как старые и новые инстансы приложения взаимодействуют с базой данных на каждом этапе.
Фаза 1: Expand. Добавьте новую структуру, не удаляя старую. Если вы хотите заменить status (VARCHAR) на status_id (INT), добавьте новую колонку, сохранив старую. Новое приложение пишет в обе колонки. Старое приложение продолжает использовать status. Оба работают.
Фаза 2: Миграция данных. Заполните новую колонку сконвертированными значениями из старой. Это можно сделать фоновым заданием или отдельным шагом миграции.
Фаза 3: Обновление кода приложения. Разверните новую версию приложения на всех инстансах. Теперь каждый работающий инстанс знает об обеих колонках.
Фаза 4: Contract. В отдельном развёртывании удалите старую колонку. К этому моменту ни одно работающее приложение от неё не зависит.
Паттерн добавляет сложности. Ваш код приложения должен уметь обрабатывать логику dual-write во время перехода. У вас временно появляются лишние колонки. Но это цена, которую вы платите за отсутствие простоев и ошибок при развёртывании.
Прямая совместимость: другой взгляд
Обратная совместимость защищает старый код приложения. Прямая совместимость защищает новый код приложения, когда база данных ещё не полностью мигрирована.
Рассмотрим сценарий: вы сначала развернули новое приложение, но миграция ещё не выполнилась на всех репликах базы данных. Новый код должен уметь работать как со старой, так и с новой схемой. Если он читает status как VARCHAR, но ожидает INT, он должен корректно обработать преобразование.
Прямая совместимость достигается сложнее и обычно имеет ограничения. Это означает, что ваш новый код должен быть защитным при чтении данных. Он не должен предполагать, что схема уже изменилась. Часто это означает добавление fallback-логики или преобразования данных на уровне приложения до завершения миграции.
Не только колонки: индексы, ограничения и внешние ключи
Совместимость касается не только колонок и типов данных. Индексы, ограничения и внешние ключи тоже могут ломать работающие приложения.
Добавление нового внешнего ключа может привести к ошибкам в существующих INSERT или UPDATE, если ссылочные данные отсутствуют. Добавление ограничения UNIQUE на колонку, которая раньше допускала дубликаты, сломает любой запрос, пытающийся вставить дубликат. Даже добавление индекса может вызвать проблемы с производительностью, если база данных блокирует таблицу во время создания индекса.
Каждое изменение схемы нужно оценивать на предмет влияния на работающий код приложения. Спросите себя: вызовет ли это изменение ошибку в любом запросе старого приложения? Изменит ли оно поведение так, как старый код не ожидает?
Практический чеклист для безопасных миграций
Перед выполнением миграции в продакшене проверьте следующие пункты:
- Может ли старое приложение читать все существующие данные без ошибок после миграции?
- Может ли старое приложение писать новые данные без ошибок после миграции?
- Все ли новые колонки nullable или имеют значения по умолчанию?
- Выполняются ли новые ограничения для существующих данных?
- Вызовет ли миграция блокировки таблиц, которые заблокируют запросы?
- Есть ли план отката, если что-то пойдёт не так?
Вывод
Миграции базы данных при zero-downtime развёртываниях требуют отношения к схеме как к общему интерфейсу между версиями приложения. Каждая миграция должна быть обратно совместима со старым кодом. Ломающие изменения требуют паттерна expand-contract: сначала добавляем новые структуры и удаляем старые только после обновления всех инстансов.
База данных — единый источник истины, который разделяют все версии приложения. Если вы меняете её небрежно, вы ломаете работающий код. Проектируйте миграции как мосты, по которым и старые, и новые приложения могут безопасно перейти. Только после того, как каждый инстанс перешёл на новую сторону, можно модифицировать сам мост.