Когда миграции базы данных ломают работающие приложения

Ваша команда только что выкатила новую фичу. Развёртывание прошло чисто. Но через пять минут дежурный инженер присылает скриншот логов с ошибками. Старые инстансы приложения падают с ошибками базы данных. Запрос 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, показывая, как старые и новые инстансы приложения взаимодействуют с базой данных на каждом этапе.

sequenceDiagram participant OldApp as Старое приложение participant DB as База данных participant NewApp as Новое приложение Note over OldApp,NewApp: Фаза 1: Expand DB->>DB: Добавить новую колонку (сохранить старую) OldApp->>DB: Читает/пишет старую колонку (OK) NewApp->>DB: Пишет в обе колонки (OK) Note over OldApp,NewApp: Фаза 2: Миграция данных DB->>DB: Заполнить новую колонку из старой Note over OldApp,NewApp: Фаза 3: Обновление всех приложений OldApp->>NewApp: Развернуть новую версию NewApp->>DB: Читает/пишет обе колонки (OK) Note over OldApp,NewApp: Фаза 4: Contract DB->>DB: Удалить старую колонку NewApp->>DB: Читает/пишет только новую колонку (OK)

Фаза 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: сначала добавляем новые структуры и удаляем старые только после обновления всех инстансов.

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