04 нояб. 2025 г.·6 мин чтения

Безопасный шаблон миграции схемы для спокойных пятничных релизов

Узнайте безопасный шаблон миграции схемы с этапами expand, backfill, switch и cleanup, чтобы команда могла вносить изменения в базу данных с меньшим риском.

Безопасный шаблон миграции схемы для спокойных пятничных релизов

Почему изменения схемы ломают обычные релизы

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

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

Фоновые задачи усугубляют ситуацию. Воркеры часто продолжают работу в течение минут или часов после деплоя. Старый воркер может читать и писать данные после того, как веб‑приложение уже обновилось, поэтому изменение, которое казалось безопасным на стейджинге, начинает выдавать ошибки в продакшене.

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

Небольшие несовпадения вызывают заметные проблемы. Отсутствующий дефолт может остановить оформление заказа. Переименование колонки ломает обновления аккаунтов. Жёсткое ограничение превращает безобидный краевой случай в ошибку 500, которую сразу замечают пользователи.

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

Как работает четырёхшаговый шаблон

Большинство неудачных релизов происходят, когда приложение и база меняются одновременно. Более безопасный подход разбивает рискованное изменение на четыре маленьких шага: expand, backfill, switch и cleanup.

Сначала — expand. Добавьте новую колонку, таблицу, индекс или сделайте поле nullable. Старый код должен продолжать работать после этого изменения. Если текущая версия приложения не совместима с новой схемой — вы движетесь слишком быстро.

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

Затем — switch. Переведите чтение и запись на новый путь только после того, как убедитесь, что данные на месте. Многие команды делают переключение поэтапно или через feature flag, чтобы быстро выключить новый путь при необходимости.

И в конце — cleanup. Удаляйте старые колонки, старые записи и код совместимости только после того, как приложение какое‑то время будет стабильно. Это единственный шаг, который убирает простой путь отката.

Порядок важнее инструментов. Во время expand и backfill старый код всё ещё работает. В момент switch у вас остаётся запасной вариант, потому что старая форма данных ещё не удалена. Это делает откат скучным — а именно этого вы хотите в пятницу.

Простое правило: никогда не вводите новое и не удаляйте старое в одном и том же релизе. Держите оба пути живыми, пока продакшен не покажет, что всё в порядке. Когда новый путь перенёс реальный трафик без сюрпризов, cleanup безопасен.

Что проверить перед началом

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

Начните с карты таблицы. Запишите все места, которые читают или пишут её, не только основной код приложения. Проверьте API‑эндпоинты, веб‑страницы, воркеры, расписанные задачи, админ‑инструменты, внутренние скрипты, экспорты, отчёты, дашборды и любые интеграции с партнёрами или ETL‑джобы. Забытой отчёт может ломаться так же сильно, как пользовательский трафик.

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

Потом посчитайте бэкфилл. Подсчитайте строки, оцените размер батчей и прикиньте, как долго это будет идти при нормальной нагрузке. Таблица на 50 000 строк ведёт себя иначе, чем на 80 миллионов. Маленькие батчи идут дольше, но обычно меньше бьют по продакшену — часто это правильный компромисс.

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

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

Expand без поломки старого кода

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

Сделайте новые поля nullable или задайте безвредный дефолт. Обязательное поле может сломать старые ноды приложения, фоновые задачи или админ‑скрипты в момент вставки строки без этого поля. Null не красиво, но снисходителен — а снисходительность нужна во время живого релиза.

Код должен уметь работать с обеими формами некоторое время. Чтение может отдавать приоритет новому полю, когда в нём есть данные, и падать обратно на старое, если нет. Записи можно оставить на старом поле на один релиз или писать в оба, если логика простая и хорошо протестирована.

Безопасный этап expand обычно простой:

  • добавить новую колонку, таблицу или индекс
  • не трогать старую колонку
  • разрешить null в новом поле
  • обновить приложение, чтобы читать обе формы
  • пока избегать переименований и удалений

Переименования — частая ловушка. Они выглядят как мелкая правка, но старый код всё ещё обращается к старому имени, а распределённые деплои дают перекрытие старых и новых версий. Рассматривайте переименование как многошаговую операцию: сначала добавьте новое имя, потом перенесите данные, и только после проверки удаленияйте старое.

Если эта часть кажется скучной — это хороший знак. Спокойные релизы обычно начинаются с «скучных» изменений в базе.

Бэкфилл старых строк контролируемыми батчами

Проверьте план миграции
Пусть Oleg проверит ваши шаги expand, backfill, switch и cleanup перед выпуском в продакшен.

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

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

Выберите один понятный способ измерять прогресс и придерживайтесь его. Большинство команд используют возрастающий id, метку времени created_at или другой стабильный столбец. Это упрощает возобновление работы, если она остановится.

Простой цикл бэкфилла делает пять вещей:

  • выбирает следующий кусок строк, которые ещё требуют нового значения
  • обновляет только этот кусок
  • фиксирует последний обработанный id или метку времени
  • при высоких нагрузках делает паузу
  • пишет упавшие id в лог для повторных попыток

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

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

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

Когда оставшийся счёт приблизится к нулю — сделайте финальную проверку перед переключением трафика.

Переключение трафика на новую форму

Рискованное место обычно не в самой схеме. Риск — в моменте, когда приложение перестаёт читать старую форму и начинает доверять новой.

Не переключайте чтения в первую очередь. Запустите двойные записи (dual writes) как минимум в одном релизе до изменения чтения, чтобы каждая новая строка обновляла и старые поля, и новые. Это даст новому пути свежие данные, а не полупустые строки.

Поставьте новый путь чтения за feature flag и выкатывайте поэтапно. Можно отправлять 1% запросов новому пути, затем 10% и только потом весь трафик, если метрики стабильны.

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

Следите за простыми проверками: частота null в новых полях, сравнение количества записей между старыми и новыми запросами, суммарные значения или статусы, которые должны совпадать, и задержка, если новые соединения или индексы меняют время ответа.

Если показатели уходят в дрейф — первым делом выключайте флаг. Это обычно самый быстрый откат. Пользователи вернутся к старому пути чтения, а двойные записи сохранят обе версии данных, давая вам время разобраться.

Даже после того, как переключение прошло успешно, держите старые чтения ещё один релиз. Команды попадают в беду, когда убирают запасной путь слишком рано и потом обнаруживают, что один воркер, cron‑таск или админ‑страница всё ещё зависят от старой формы.

Очистка после устойчивого переключения

Cleanup — это момент, где миграция либо завершается, либо тянется месяцами. Когда приложение читает новую форму и пишет только в неё, дайте системе время. Дождитесь 100% бэкфилла, стабильных метрик ошибок и отсутствия обращений в поддержку. Если что‑то выглядит подозрительно — держите старую колонку дольше и наблюдайте.

После этого удаляйте код совместимости из приложения. Старые чтения, двойные записи, флаги и обходы усложняют будущие изменения и путают новых инженеров, которые не понимают, какой путь важен.

Типовая очистка включает:

  • убрать чтение из старой колонки или таблицы
  • прекратить дублирующие записи, которые синхронизировали обе схемы
  • удалить одноразовые бэкфилл‑джобы и скрипты повторов
  • закрыть флаги, которые были нужны только для миграции
  • обновить алерты и дашборды, если они всё ещё смотрят на старую форму

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

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

Простой пример: разделить full_name на две колонки

Добавить проверки релиза на базе ИИ
Настройте AI-помощь для проверок кода и релизов вокруг работ по миграции.

Разделить full_name на first_name и last_name кажется тривиальным, но можно сломать формы, экспорты и профили пользователей, если торопиться.

Начните с добавления first_name и last_name рядом с full_name. Оставьте старую колонку и позвольте старому коду работать. На этом этапе новые колонки могут быть nullable.

Затем обновите приложение, чтобы при сохранении записывалось в обе формы. Если пользователь редактирует профиль, форма всё ещё должна сохранять full_name, но также заполнять новые колонки. На первом этапе используйте простые правила: убирать лишние пробелы, если имя одно — класть его в first_name, если слов несколько — первое слово в first_name, остальное в last_name.

Это правило не идеально — имена сложны. Цель — безопасно перевести большую часть строк и пометить странные случаи для ручной проверки, а не блокировать релиз.

Далее бэкфилл существующих строк небольшими батчами. Читайте строки, где first_name или last_name пусты, разбивайте full_name и записывайте результат. Пропускайте уже исправленные строки. Если таблица большая — обрабатывайте по несколько сотен или тысяч строк за раз, чтобы не нагружать базу.

После верификации переключайте чтение на новые колонки. Собирайте отображаемое имя из first_name и last_name, но ещё некоторое время продолжайте писать в full_name. Это даст запасной вариант, если отчёты, админ‑страницы или шаблоны писем всё ещё зависят от старого поля.

Удаляйте full_name только после того, как переключение подтвердилось в продакшене и поддержка молчит. Сделайте очистку отдельным релизом. Если что‑то пойдёт не так, вы сможете восстановить старое значение и избежать пятничного отката.

Ошибки, которые вызывают откаты

Большинство откатов начинаются с предположения, что все части системы изменятся одновременно. В реальности это не так. Один инстанс приложения обновился первым, воркер отстал, админ‑инструмент всё ещё читает старое поле — и маленькое изменение схемы превращается в продакшен‑баг.

Переименование колонки и деплой кода в одном релизе — классическая ловушка. Веб‑приложение может использовать новое имя, но старые инстансы, очереди и расписанные задачи всё ещё будут просить старое имя. Даже короткое перекрытие ломает записи, возвращает пустые данные или кидает ошибки в неожиданных местах.

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

Команды также забывают части системы за пределами основного приложения. Фоновые джобы, скрипты поддержки, админ‑панели, отчёты и экспорты часто сохраняют старые предположения долго после первого релиза. Безопасная миграция включает все эти части, а не только код, видимый пользователю.

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

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

Проверки перед нажатием кнопки деплоя

Исправить пробелы в процессе деплоя
Поможем с feature flags, двойными записями и чек-листами релизов, чтобы деплои проходили спокойнее.

Спокойные релизы обычно зависят от нескольких простых проверок, а не от героизма. Если одна из них провалилась — подождите. Отложенный релиз дешевле, чем откат в пиковое время.

  • Убедитесь, что старая версия приложения может читать и писать с расширенной схемой.
  • Убедитесь, что новая версия корректно работает с частично заполненным бэкфиллом.
  • Следите за ошибками, задержками запросов, временем блокировок и отставанием репликации.
  • Проверьте, что бэкфилл можно аккуратно приостановить и потом возобновить.
  • Назовите одного человека, который принимает решение «идти/не идти».

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

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

Что делать дальше

При следующем изменении базы делайте меньше в одном релизе. Разбейте миграцию на четыре релиза: expand, backfill, switch и cleanup. Эта простая привычка упрощает контроль над релизами, потому что старый и новый код могут некоторое время сосуществовать.

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

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

Короткий план достаточен:

  • разделите миграцию на четыре релиза
  • запишите шаг отката для каждого релиза
  • выполняйте бэкфилл малыми батчами с поддержкой возобновления
  • тестируйте в стейджинге на грязных, реалистичных данных
  • ждите с очисткой, пока новый путь не станет стабильным

Этот шаблон лучше работает, когда один человек отвечает за весь откат, а не только за SQL. Изменения в базе затрагивают код, джобы, алерты и время деплоя. Если что‑то кажется ненадёжным — замедлитесь и сделайте первый шаг скучным.

Если миграция затрагивает код приложения, воркеры и инфраструктуру одновременно, вторая проверка может сэкономить вам болезненный откат. Oleg Sotnikov на oleg.is работает как Fractional CTO и консультант стартапов — и опытное техническое наблюдение часто помогает в таком планировании релизов.