Когда миграция базы данных требует чистого разрыва: фаза переключения

Представьте: ваша команда потратила недели, аккуратно перенося данные из старой схемы в новую. Паттерн expand-contract работал без сбоев. Приложение писало и в старую, и в новую структуры. Скрипты обратной загрузки перенесли исторические данные. Проверки сошлись. Всё выглядит идеально на бумаге.

Но наступает момент, от которого у многих инженеров холодеет в животе: переключение приложения на чтение исключительно из новой структуры. Это и есть фаза cutover — именно здесь миграции баз данных либо проходят чисто, либо порождают неожиданные инциденты в продакшене.

Что такое cutover на самом деле

Cutover — это точка, в которой ваше приложение перестаёт читать из старой структуры и полностью полагается на новую. Звучит просто, но на практике требует тщательной координации и верификации.

До cutover приложение находилось в режиме двойного чтения. Новые данные с момента начала миграции писались в обе структуры. Исторические данные были обратно загружены. Приложение читало из двух мест: из старой структуры для данных, существовавших до миграции, и из новой — для данных, записанных после.

Cutover убирает эту логику двойного чтения. Код приложения меняется так, что каждый запрос на чтение идёт только к новой структуре. Обычно это изменение кода и деплой, а не изменение схемы. Вы обновляете путь чтения, собираете приложение и выкатываете его как любое другое обновление функциональности.

Вот упрощённый пример того, как выглядит такое изменение кода в Node.js-сервисе:

// До cutover: логика двойного чтения
async function getUserProfile(userId) {
  // Сначала пробуем новую структуру для свежих данных
  const newProfile = await db.query(
    'SELECT * FROM user_profiles_v2 WHERE user_id = $1', [userId]
  );
  if (newProfile.rows.length > 0) {
    return newProfile.rows[0];
  }
  // Падаем обратно на старую структуру для устаревших данных
  const oldProfile = await db.query(
    'SELECT * FROM user_profiles WHERE user_id = $1', [userId]
  );
  return oldProfile.rows[0] || null;
}

// После cutover: логика одинарного чтения
async function getUserProfile(userId) {
  const profile = await db.query(
    'SELECT * FROM user_profiles_v2 WHERE user_id = $1', [userId]
  );
  return profile.rows[0] || null;
}

Следующая диаграмма последовательности иллюстрирует переход от двойного чтения к cutover:

sequenceDiagram participant OldApp as Старое приложение participant NewApp as Новое приложение participant OldDB as База данных (старая структура) participant NewDB as База данных (новая структура) Note over OldApp,NewDB: До cutover OldApp->>OldDB: чтение NewApp->>OldDB: чтение (старые данные) NewApp->>NewDB: чтение (новые данные) Note over OldApp,NewDB: Cutover происходит NewApp->>NewDB: только чтение Note over OldApp: Старое приложение выведено из эксплуатации

Риск, который нельзя игнорировать

Опасность во время cutover — частичный сбой. Если одни экземпляры приложения всё ещё читают из старой структуры, а другие уже переключились, результаты могут быть несогласованными. Пользователь может видеть разные данные в зависимости от того, какой сервер обработал его запрос.

Вот почему стратегия cutover имеет значение. Есть два основных подхода:

Big bang cutover — переключает все экземпляры сразу. Это быстро и просто в координации, но если что-то идёт не так, страдают сразу все пользователи.

Gradual cutover — переключает экземпляры партиями, часто по регионам, зонам доступности или группам пользователей. Это ограничивает радиус взрыва. Если вы переключили одну зону доступности и видите ошибки, вы можете разобраться, прежде чем переходить к следующей. Плата за это — бóльшая сложность координации и мониторинга.

Большинство команд с продакшен-опытом предпочитают постепенный cutover для миграций баз данных. Дополнительные усилия по координации стоят той страховки, которую он даёт.

Поиск скрытых зависимостей

После cutover ваше приложение больше не читает из старой структуры. Но действительно ли оно единственное? В продакшен-среде одну и ту же базу данных часто используют несколько сервисов, пакетных задач, отчётных скриптов и ручных запросов.

Распространённая ошибка — считать, что достаточно обновить основное приложение. А в это время ночная пакетная задача, запускающаяся в 2 часа ночи, всё ещё запрашивает старую колонку. Или аналитик данных каждый понедельник запускает ручной отчёт, используя старую таблицу. Эти скрытые зависимости вызовут сбои или будут выдавать устаревшие данные после cutover.

Самый надёжный способ их найти — мониторинг запросов к базе данных. Включите инструменты вроде pg_stat_statements в PostgreSQL или performance_schema в MySQL. Ищите любые запросы, которые ссылаются на старые колонки или таблицы. Запустите этот мониторинг как минимум на один полный цикл всех известных процессов. Если у вас есть еженедельная отчётная задача, подождите полную неделю после cutover, прежде чем объявлять старую структуру неиспользуемой.

Некоторые команды также проводят тест в стейджинге: отзывают права на чтение старых колонок и прогоняют все известные сценарии приложения. Если ошибок нет, продакшен, скорее всего, безопасен для следующего шага.

Что происходит после cutover

Как только cutover завершён и все зависимости подтверждены как чистые, ваше приложение полностью работает на новом формате. Старая структура всё ещё существует в базе данных, но из неё никто не читает. Это момент, когда можно начинать планировать фазу contract: полное удаление старой структуры.

Но не спешите с удалением. Даже после cutover держите старую структуру в течение периода безопасности. Длительность зависит от уверенности команды и стоимости хранения лишних колонок или таблиц. Некоторые команды держат их неделю. Другие — месяц, особенно если миграция затрагивает критически важные данные клиентов.

В этот период следите за любыми алертами или логами ошибок, которые могут указывать на необнаруженную зависимость. Если ничего не всплывёт, можно уверенно переходить к удалению старой структуры.

Практический чек-лист для cutover

Перед выполнением cutover в продакшене пройдитесь по этому списку:

  • Все исторические данные обратно загружены и проверены
  • Двойная запись работала без ошибок как минимум один полный бизнес-цикл
  • Изменение кода пути чтения готово и прошло ревью
  • Документирован план отката: как переключить чтение обратно на старую структуру, если потребуется
  • Включён и настроен мониторинг запросов к БД для выявления запросов к старой структуре
  • Все известные зависимые приложения, пакетные задачи и отчёты идентифицированы и обновлены
  • В стейджинге протестировано удаление прав на чтение старой структуры
  • Определён план постепенного cutover: какие экземпляры или регионы переключаются первыми
  • Настроены дашборды мониторинга для обнаружения ошибок чтения или несогласованности данных после cutover
  • Готов план коммуникации: кто и когда должен знать о cutover

Вывод

Cutover — это момент, когда ваш паттерн миграции перестаёт быть теоретическим и начинает влиять на реальных пользователей. Не относитесь к нему как к простому изменению кода. Относитесь как к продакшен-событию, требующему мониторинга, верификации и чёткого пути отката. Лишний день, потраченный на подтверждение отсутствия скрытых зависимостей, стоит гораздо больше, чем час, который вы могли бы сэкономить, поспешив удалить старую структуру.