Откаты миграций базы данных без ломки релизов
Откаты миграций БД не должны ломать релизы. Сочетайте порядок деплоя, обратимые изменения схемы и флаги функций, чтобы сделать процесс безопаснее.

Почему откаты не проходят после изменения схемы
Откат кода приложения обычно проходит быстро. Откат данных — нет.
Вы можете переключить серверы назад на предыдущую версию за несколько минут, но база данных может уже содержать строки, записанные новым релизом. Это несоответствие вызывает большинство провалов при откате миграций. Старое приложение всё ещё ожидает старую форму данных. Если новый релиз переименовал колонку, разделил одно поле на два, изменил значение по умолчанию или начал записывать новый формат, старый код может падать или читать неверное значение.
Риск становится выше, когда изменение схемы удаляет что-то, вместо того чтобы добавить. Удаление колонки, удаление таблицы или превращение поля в обязательное может закрыть путь назад. Как только новый релиз начнёт писать данные, понятные только новой схеме, предыдущая версия не сможет безопасно вернуться.
Смешанные версии делают ситуацию ещё хуже. Во время реального деплоя не все процессы переключаются одновременно. Один веб-сервер может выполнять новый код, в то время как другой — старую версию. Фоновые задачи, административные инструменты и запланированные задания могут одновременно обращаться к одной базе.
Отсюда простое правило: схема, которая подходит только одной версии приложения, рискованна. На практике команды сталкиваются с одними и теми же проблемами. Новый код пишет данные, которые старый код не может прочитать. Старый код всё ещё запрашивает колонки, которые миграция удалила или переименовала. Ограничения проходят для одной версии и падают для другой. Рабочие или отложенные задачи продолжают использовать старые запросы после завершения веб-деплоя.
Небольшой пример проясняет ситуацию. Допустим, релиз меняет одно поле name на first_name и last_name. Деплой вроде прошёл, но через полчаса вы откатываете код. Старая версия теперь ищет name, в то время как в базе есть только новые колонки. Приложение вернулось, но релиз всё ещё сломан.
Безопасные релизы рассматривают код и схему как единую систему. База данных должна иметь путь, позволяющий старой и новой версиям сосуществовать некоторое время, даже если вы надеетесь, что он вам никогда не понадобится.
Поймите, какие изменения схемы рискованные
Для успешного отката старый и новый код должны сосуществовать с одной и той же базой хотя бы короткое время. Некоторые изменения схемы это позволяют. Другие ломают совместимость сразу.
Самое безопасное изменение — обычно добавление новой nullable-колонки. Старый код её игнорирует, а новый код может начать записывать в неё, когда будет готов. Это даёт вам время на манёвры — редкость во время релиза.
Риск быстро растёт, когда вы переименовываете или удаляете колонку. Старый код всё ещё обращается по старому имени. Если колонки нет, запросы падают моментально. Даже чистый деплой может оставить несколько старых инстансов приложения работающими минуту или две, и этой минуты достаточно, чтобы вызвать реальные ошибки.
Некоторые изменения требуют особой осторожности. Переименование колонок или таблиц часто ломает старый код при следующем запросе. Удаление колонок, индексов или ограничений может убрать то, что всё ещё нужно старой версии. Изменение формата данных может запутать обе версии. Дата, хранящаяся как текст сегодня и как timestamp завтра, — распространённая ловушка. Крупные бэкафиллы представляют собой отдельный риск, потому что они могут замедлить базу, заполнить логи и превратить короткий деплой в длинный инцидент.
Формат данных легко недооценить. Если старое приложение ожидает "12.99" как текст, а новое приложение хранит 1299 в центах, обе версии могут запускаться, но одна читает бессмыслицу. Это хуже громкой ошибки, потому что плохие данные могут тихо распространиться.
Бэкафиллы создают давление иначе. Схема может быть в порядке, но работа занимает время. Миграция, обновляющая 50 миллионов строк, может блокировать таблицы, повысить загрузку CPU или вызвать отставание реплик. Если вам нужно откатываться, пока такая задача ещё выполняется, уборка становится очень сложной.
Простой тест помогает: спросите, может ли старый код всё ещё читать и записывать после изменения. Если да — изменение обычно низкорисковое. Если ответ "только после обновления всех серверов" или "только после завершения конвертации данных", — рассматривайте его как рискованный релиз.
Сопоставьте стиль деплоя с миграцией
План деплоя и план схемы должны соответствовать друг другу. Если версия A и версия B могут работать одновременно, база должна иметь смысл для обеих. Именно здесь многие откаты терпят неудачу: код откатили, а данные уже не соответствуют ожиданиям старого кода.
Rolling deploys — самый строгий случай. Один сервер может всё ещё работать на старом коде, в то время как другой уже на новом, поэтому обе версии должны читать и писать одни и те же записи без ошибок. Обычно это означает сначала добавления. Добавьте новую колонку, оставьте старую, пишите в обе некоторое время и удалите старую колонку позже.
Blue–green кажется безопаснее, потому что вы переключаете трафик между двумя полными средами. Но и он может провалиться, если старая и новая версии по-разному интерпретируют одни и те же данные. Если новый релиз переписывает поле, меняет его смысл или удаляет данные, которые нужны старой версии, переключение назад вам не поможет.
Canary-релизы дают раннее предупреждение. Небольшая часть трафика попадает на новый код первой, что облегчает обнаружение плохих записей до того, как они распространится по всей базе. Это особенно полезно, когда миграция меняет то, как приложение пишет записи, а не только как читает их.
Выбирайте стиль деплоя по совместимости
Используйте rolling deploys только для обратнo-совместимых изменений схемы. Используйте blue–green, когда версия приложения сильно меняется, но держите формат данных совместимым до тех пор, пока переключение не будет стабильным. Используйте canary, когда хотите проверить новые записи на реальном трафике перед полным релизом.
Крупные скачки схемы редко укладываются в безопасный план отката. Переименование колонки на месте, разделение одного поля на несколько или изменение типов по большим таблицам быстро привязывают вас к новой версии. Как только в продакшене начнут использоваться новые формы, старое приложение может читать мусор или падать.
Более безопасный шаблон — миграция «расширение и сжатие». Сначала расширяете схему, чтобы обе версии работали. Затем обновляете приложение. Дорабатываете старые строки при необходимости. Только когда новый путь стабилен, сжимаете схему, удаляя старые колонки или ограничения.
Если изменение не может поддерживать старый и новый код вместе, относитесь к нему как к специальному релизу. Замедлите его, сузьте трафик и планируйте откат, ориентируясь на данные, а не только на код приложения.
Пишите миграции, которые можно обратить
Миграция обратима только если старая версия приложения всё ещё может работать после изменения схемы. На практике это означает: сначала добавлять, потом удалять. Если вы удалите или переименуете колонку слишком рано, откат провалится даже при простом откате кода.
Начинайте с добавления. Добавьте новую колонку, таблицу, индекс или default сначала. Пусть и старые, и новые пути работают некоторое время, затем удаляйте старую структуру только после того, как продакшен подтвердит стабильность нового пути.
Сохранение старых колонок или таблиц ещё на один релиз часто кажется медленным, но экономит настоящую боль. Если в новом коде есть баг, вы сможете переключить трафик назад, не пытаясь восстанавливать потерянные данные в условиях давления.
Перед мержем опишите шаг отката простыми словами. Не оставляйте это как «очистку» на потом. Если вы не можете точно объяснить, как отменить миграцию, это предупреждение, что изменение слишком большое или уничтожает данные слишком рано.
Для каждой миграции ответьте на несколько вопросов до релиза. Что старое приложение всё ещё должно читать и писать? Какие данные нужно дублировать на один цикл релизов? Как вы вернёте новые данные назад в случае отката кода? Когда действительно безопасно удалить старый кусок схемы?
Небольшие миграции проще доверять. Добавить одну nullable-колонку легко отменить. Добавить три колонки, бэкафилл, изменить ограничения и удалить старое поле в одном коммите — нет. Разбейте работу на отдельные шаги, чтобы каждая миграция могла существовать сама по себе.
Если вы разделяете full_name на first_name и last_name, не удаляйте full_name в том же релизе. Добавьте новые колонки, продолжайте записывать full_name и копируйте данные туда и обратно при необходимости. После того как новый код стабильно работает в продакшене, можно перестать использовать старое поле. Только потом удаляйте его.
Команды, которые хорошо справляются с обратимыми миграциями БД, обычно упорны в этом вопросе. Они принимают дополнительные временные части схемы, потому что чистый откат важнее аккуратной базы в день релиза.
Выпускайте изменение маленькими шагами
Большие провалы обычно случаются, когда команда меняет приложение и базу одним прыжком. Более безопасный релиз разбивает работу на меньшие деплои. Каждый шаг должен работать сам по себе и легко отменяться.
Начните с добавления новой схемы без изменения поведения для пользователей. Создайте новую колонку, таблицу или индекс, но оставьте старый путь работать как прежде. По возможности сделайте новые поля необязательными сначала, чтобы старый код не падал.
Затем выпустите код, который понимает обе формы данных. На этом этапе приложение может читать из старой схемы, новой схемы или из обеих. Для записей многие команды используют временный период двойной записи (dual-write), чтобы новые данные попадали в оба места. Это немного грязно, но даёт возможность откатиться без потери свежих данных.
Распространённая последовательность: добавить новую схему, бэкафилл старых данных в новую структуру, задеплоить код, работающий с двумя версиями, перевести записи на новую схему и переключить чтения только после подтверждения полноты новых данных.
Чтения и записи не стоит переключать одновременно, если изменение не крошечное. Записи обычно переводят первыми — их легче проконтролировать. Чтения часто затрагивают больше экранов, задач и отчётов, поэтому требуют больше внимания. Проверьте количество строк, логи ошибок и несколько реальных пользовательских сценариев перед следующим шагом.
Не удаляйте старую схему сразу после запуска нового пути. Пусть она остаётся в работе через обычный трафик, фоновые задачи и по крайней мере один полный бизнес-цикл. Стартап может ждать пару дней. Компания с ежемесячной тарификацией может ждать, пока счёт за период не пройдёт успешно.
Только после стабильности нового пути удаляйте старые колонки или таблицы в отдельном релизе. Это и есть шаблон «расширение — сжатие», один из немногих практичных способов сделать откаты работоспособными при реальном трафике.
Контролируйте время релиза с помощью флагов функций
Когда релиз зависит от изменения схемы, не включайте новое поведение сразу после деплоя кода. Поместите новый путь за флагом функций. Это даёт время задеплоить код, выполнить миграцию и проверить логи и запросы, прежде чем реальные пользователи начнут пользоваться изменением.
Это разделение важно, если вам нужны безопасные откаты без превращения рутинного релиза в аврал. Если в базе уже есть новая колонка или таблица, возможно, вы не захотите сразу её откатывать. Часто более безопасный ход проще: выключить фичу, оставить пользователей на старом пути и разбираться с проблемой без нового деплоя.
Хороший rollout начинается небольшими шагами. Включите флаг сначала для вашей команды, затем для нескольких тестовых аккаунтов и затем для небольшой доли продакшен-трафика. Наблюдайте за уровнем ошибок, медленными запросами, упавшими задачами и жалобами в саппорт. Если шаг выглядит неверно — остановитесь.
Флаг должен контролировать рискованное поведение, а не только экран, который его показывает. Скрытая кнопка бесполезна, если фоновые задачи, вебхуки или мобильные клиенты по прежнему пишут в новую схему. Поместите выключатель вокруг чтений, записей и любой асинхронной работы, связанной с изменением.
Несколько привычек делают флаги полезными. По умолчанию новые флаги выключены. Таргетируйте небольшую группу перед всеми остальными. Логируйте каждое изменение флага с временем и владельцем. Удаляйте флаг после стабилизации rollout.
Представьте форму регистрации, которая теперь сохраняет first_name и last_name вместо одного name. Вы можете задеплоить код, который понимает оба формата, держать новую форму выключенной, мигрировать данные в фоне, а затем включить её для части аккаунтов. Если парсинг имён даёт плохие данные, выключите флаг за секунды. Пользователи остаются на старом потоке, пока вы исправляете код, и релиз остаётся спокойным.
Пример: разделение одного поля имени на два
Обычное изменение схемы выглядит небольшим и всё равно ломает откаты. Представьте таблицу users с одной колонкой full_name, и приложению теперь нужны first_name и last_name для форм, приветствий по email и сортировки.
Не заменяйте full_name в одном релизе. Сначала добавьте first_name и last_name, оставьте full_name и дайте старому коду продолжать работать. Это безопасная сторона миграции «расширение — сжатие».
Простой rollout выглядит так:
- Релиз 1 добавляет nullable
first_nameиlast_name. - Бэкафилл существующих строк из
full_name. - При создании или редактировании профиля записывайте оба формата.
- Переключите чтения на новые колонки за флагом функций.
- В следующем релизе удалите
full_name, когда новый путь подтвердит стабильность.
Шаг двойной записи важнее, чем многие ожидают. На протяжении одного полного релиза каждое обновление профиля должно сохранять full_name и одновременно first_name и last_name. Это даёт время сравнить реальные данные вместо того, чтобы слепо верить парсеру.
Имена очень быстро становятся сложными. "Mary Ann van der Berg" и "Prince" не укладываются в аккуратное разделение. Если ваш парсер ошибается, оставьте оригинальный full_name нетронутым и пометьте строку для ручной проверки или оставьте одно из новых полей пустым.
Также нужен быстрый чек сравнения. Воссоздайте отображаемое имя из first_name и last_name, затем сравните его с full_name для выборки записей или для каждой изменённой строки. Небольшие несоответствия легче исправить, пока обе формы существуют.
Когда числа будут хороши, включите чтения из новых колонок с помощью флага. Если что-то пойдёт не так, выключите флаг и читайте full_name снова. Это и делает откат миграций практичным: база всё ещё поддерживает старый путь.
Удаляйте full_name только после ещё одного чистого релиза. Если удалить его слишком рано, откат перестаёт быть откатом и превращается в ремонт.
Ошибки, которые создают боль при откате
Проблемы с откатом обычно начинаются задолго до самого отката. Они начинаются, когда релиз предполагает, что приложение, схема, данные и фоновые воркеры будут двигаться в такт. Это редко так и происходит.
Распространённая ошибка — удалить колонку в том же релизе, в котором перестали её использовать. Это кажется аккуратным, но убирает вашу спасательную полосу. Если новый код упадёт и вы задеплоите старую версию, старая версия может всё ещё читать или писать эту колонку. Сохраните старую колонку ещё на один релиз, дайте обеим версиям работать и удалите её позже.
Бэкафиллы создают другой тип боли. Команды часто запускают большой переписывающий job и считают, что это однонаправленная дорога. Затем задача замедляет базу, умирает на полпути или пишет смешанные данные по миллионам строк. Если вы не можете поставить её на паузу, безопасно рестартовать и понять, какие строки уже изменены — у вас нет плана отката, у вас есть план «надеяться и ждать».
Другая ошибка — вера, что одна команда отката всё исправит. Откат приложения прост по сравнению с откатом данных. Как только миграция меняет значения, разделяет поля или удаляет строки, старое приложение может перестать понимать, что видит. Деплои и миграции нуждаются в отдельных шагах с чёткими правилами о том, какие версии могут работать с какой схемой.
Фоновые задачи часто игнорируются до тех пор, пока они не сломают откат. Веб-серверы могут снова запуститься на старом коде, но воркеры, cron и потребители очередей могут продолжать писать в новую форму данных минутами или часами. Это быстро создаёт грязную базу. Поставьте воркеры на паузу, осушите очереди или сделайте задания совместимыми с обеими формами перед релизом.
Типичные проблемные места предсказуемы: удаление старой схемы слишком рано, запуск бэкафиллов без контрольных точек или батчей, сведение отката приложения и данных в один слепой шаг, игнорирование воркеров и расписаний в плане релиза и пропуск короткого периода, когда обе схемы работают.
Скучные релизы безопаснее. Если каждый шаг обратим сам по себе, откаты остаются небольшими и предсказуемыми, а не превращаются в ночной ремонт.
Быстрая проверка перед деплоем
Безопасные откаты миграций БД начинаются с одного простого вопроса: может ли старое приложение работать против новой схемы? Если вчерашняя версия не может безопасно читать или писать, ваш план отката — в основном желание. Тестируйте этот путь до релиза, а не во время инцидента.
Короткий ревью ловит большинство избежимых ошибок. Запустите предыдущую версию приложения против копии мигрированной базы. Создайте и отредактируйте реальные записи, затем проверьте, не падают ли записи, не обнуляются ли поля или не усечены ли значения. Подтвердите, что вы можете выключить новое поведение флагом или конфигом. Если для остановки нужен ещё один деплой, восстановление будет дольше, чем выглядит на бумаге.
Репетиция отката на данных, похожих на продакшен, помогает. Маленькие тестовые фикстуры скрывают грязные случаи: null-ы, дубли, старые битые записи и записи, искажённые прошлыми багами. Также заранее решите, кто принимает решение «стоп» или «вперёд» перед началом деплоя. Один владелец и один резерв — достаточно.
Этот последний пункт важнее, чем многие признают. Во время тяжёлого релиза задержки часто появляются из-за нерешительности, а не из-за инструментов. На небольшой команде решающий может быть CTO, основатель или внешний технический консультант. Задача проста: посмотреть на сигналы, принять решение и держать команду в курсе.
Контроль фич также даёт время. Если миграция в базе уже присутствует, но новый путь скрыт за флагом, вы можете приостановить rollout, изучить ошибки и сохранить базу в текущем состоянии. Часто это безопаснее, чем пытаться форсировать полный откат под давлением.
Одна практическая привычка сильно помогает: выпишите первые десять минут реакции на провал. Кто выключает флаг, кто проверяет здоровье БД, кто смотрит трекинг ошибок и кто сообщает команде продолжать или остановиться. Если ответы расплывчаты до деплоя — они будут ещё хуже, когда всё начнёт идти не так.
Что делать дальше
Начните с ближайшего изменения схемы в вашей дорожной карте. Не ждите большого релиза. Небольшое изменение, выполненное аккуратно, — самый быстрый путь сформировать процесс, который переживёт плохой деплой.
Опишите изменение в трёх стадиях на одной странице: добавьте новую схему, не ломая старый код; переключите приложение контролируемо, часто за флагом; и удалите старую схему только после того, как новый путь стабилен.
Эта простая карта заставляет думать чётко. Если шаг нельзя безопасно откатить — план ещё не готов. Тут чаще всего и падают откаты миграций: команды пропускают средний этап и пытаются сделать всё разом.
Далее проведите одну репетицию отката перед следующим рискованным релизом. Используйте стейджинг, максимально похожий на продакшен. Примените миграцию, включите новый путь, а затем пройдите шаги назад. Проверьте, что старый код всё ещё работает, старые чтения работают, и частичный бэкафилл не оставляет приложение в странном состоянии.
Короткий шаблон полезнее длинной политики. Для каждого релиза фиксируйте: какую схему добавляете сначала, какое изменение приложения переключает поведение, какой бэкафилл запускается в фоне, что говорит о безопасности отката и когда в конце можно удалить старую колонку, таблицу или кодовый путь.
Это особенно полезно, когда несколько людей участвуют в релизах. Шаблон сокращает гадание и делает обратимые миграции частью обычной работы, а не последней минутной паникой.
Если вашей команде нужен второй взгляд перед рискованным релизом, Oleg Sotnikov на oleg.is работает как Fractional CTO и советник стартапов по архитектуре продукта и инфраструктуре. Спокойный внешний обзор часто находит тот единственный небезопасный шаг, который может превратить обычный деплой в долгую ночную починку.
Часто задаваемые вопросы
Почему откат кода не исправляет неудачный релиз схемы?
Потому что новый код мог уже записать строки в формате, который старое приложение не понимает. Если старая версия ожидает колонку или формат, которых больше нет, запросы завершаются ошибкой или данные становятся некорректными.
Какие изменения в базе данных самые безопасные для отката?
Безопаснее всего добавление — новая nullable-колонка или новая таблица. Старый код их игнорирует, а новый код может начать использовать их без блокировки быстрого отката.
Какие изменения схемы делают откат рискованным?
Переименования, удаления, изменения типов, ужесточение ограничений и переписывание формата данных быстро создают проблемы. Старые запросы ломаются, или старый код читает новые значения неправильно.
Что такое миграция «расширение — сжатие»?
Сначала вы добавляете новую схему и оставляете старую работающей. После того как новый путь стабилен в продакшене, вы удаляете старые колонки или правила в отдельном релизе.
Стоит ли переключать чтения и записи одновременно?
Обычно нет. Сначала переводят записи (writes), подтверждают, что новые записи выглядят правильно, и только потом переключают чтения (reads), когда уверены, что данные полные и фоновые задачи работают.
Как долго должен оставаться включён дуал-райт?
Держите дуал-райт хотя бы на один полный релиз и достаточно трафика, чтобы поймать пограничные случаи. Если у вас есть биллинг, отчёты или еженедельные задания — дождитесь окончания соответствующего цикла.
Помогают ли флаги функций при изменениях схемы?
Да, если флаг контролирует именно рискованное поведение. Поместите его вокруг чтений, записей и асинхронных задач, чтобы можно было выключить новый путь без дополнительного деплоя.
Как справляться с большим бэкафиллом?
Запускайте их малыми партиями с контрольными точками и кнопкой паузы. Следите за нагрузкой на базу, блокировками и отставанием реплик, и убедитесь, что можно продолжить без двойной перезаписи строк.
Что делать с фоновыми задачами во время деплоя?
Относитесь к воркерам, cron-задачам и потребителям очередей как к части релиза. Поставьте их на паузу, осушите очереди или заставьте работать с обеими формами данных до переключения трафика.
Что тестировать перед рискованной миграцией?
Прогоните предыдущую версию приложения против копии мигрированной базы и попробуйте реальные потоки создания и редактирования записей. Также отрепетируйте, кто выключает флаг, кто проверяет здоровье БД и кто решает остановить релиз.