Научитесь делать изменения схемы без простоя с помощью паттерна expand/contract: добавляйте столбцы безопасно, поэтапно заполняйте данные, выкатывайте совместимый код и только потом удаляйте старые пути.

Простои от изменения базы данных не всегда выглядят как явный отказ. Для пользователей это может быть страница, которая загружается бесконечно, неудачная оплата или приложение с сообщением «что‑то пошло не так». Для команд это — алерты, рост ошибок и очередь неудачных записей, которые надо разбирать.
Изменения схемы 위험ны потому, что база данных используется всеми запущенными версиями приложения. Во время релиза часто одновременно живут старый и новый код (поэтапные деплои, несколько инстансов, фоновые задания). Миграция, которая на первый взгляд верна, всё ещё может сломать одну из этих версий.
Типичные причины поломок:
Даже если код в порядке, релизы тормозятся из‑за тайминга и совместимости между версиями.
Изменения схемы без простоя сводятся к одному правилу: каждое промежуточное состояние должно быть безопасно и для старого, и для нового кода. Вы меняете базу так, чтобы не ломать текущие чтения и записи, выкатываете код, который умеет работать с обеими формами, и удаляете старый путь только после того, как ничего от него не зависит.
Эти дополнительные усилия оправданы при реальном трафике, строгих SLA или большом числе инстансов и воркеров. Для маленького внутреннего инструмента с тихой базой запланированное окно обслуживания может быть проще.
Большинство инцидентов при работе с базой происходит из‑за ожидания, что изменения базы произойдут мгновенно, хотя на самом деле они занимают время. Паттерн expand/contract избегает этого, разбивая рискованную операцию на небольшие безопасные шаги.
Короткое время система поддерживает две «диалектные» формы одновременно. Сначала вводят новую структуру, оставляют старую работающей, постепенно переносят данные, а затем убирают старый путь.
Паттерн прост:
Это хорошо сочетается с поэтапными деплоями. Если вы обновляете 10 серверов по одному, вы кратко будете запускать старые и новые версии вместе. Expand/contract сохраняет совместимость с базой данных в этом перекрытии.
Это также облегчает откаты. Если в релизе баг, вы можете откатить приложение без отката базы, потому что старые структуры живут во время окна expand.
Пример: вы хотите разделить столбец PostgreSQL full_name на first_name и last_name. Вы добавляете новые столбцы (expand), выкатываете код, который умеет читать и писать обе формы, бэфиллят старые строки, затем удаляете full_name, когда убедились, что он больше не нужен (contract).
Фаза expand — про добавление новых возможностей, а не про удаление старых.
Частое первое действие — добавить новый столбец. В PostgreSQL обычно безопаснее добавить его nullable и без значения по умолчанию. Добавление ненулевого столбца с дефолтом может вызвать переписывание таблицы или сильные блокировки в зависимости от версии Postgres и точного изменения. Более безопасная последовательность: добавить nullable, выкатить терпимый код, бэфиллят, затем уже накладывать NOT NULL.
Индексы требуют внимания. Обычное создание индекса может дольше блокировать записи, чем вы ждёте. По возможности используйте создание индекса CONCURRENTLY, чтобы чтения и записи продолжали работать. Это дольше, но избегает блокировок при релизе.
Expand также может означать добавление новых таблиц. Если вы переходите от одного столбца к связи многие‑ко‑многим, вы можете добавить join‑таблицу, оставив старый столбец. Старый путь продолжит работать, пока новая структура начинает собирать данные.
На практике expand часто включает:
После expand старые и новые версии приложения должны уметь работать одновременно без сюрпризов.
Большая часть боли при релизах случается в середине: часть серверов уже на новом коде, часть ещё на старом, а база уже меняется. Ваша цель проста: любая версия в процессе релиза должна работать и со старой, и с расширенной схемой.
Популярный подход — double‑write. Если вы добавили новый столбец, новый код пишет и в старый, и в новый столбец. Старые версии продолжают писать только в старый — это нормально, потому что старый столбец ещё есть. Делайте новый столбец опциональным на старте и откладывайте строгие ограничения до тех пор, пока все писатели не обновятся.
Чтения обычно переключают осторожнее, чем записи. Некоторое время держите чтение на старом столбце (тот, который гарантированно заполнен). После бэфилла и проверки переключайте чтение так, чтобы сначала пытаться взять новое поле, с откатом на старое, если новое пусто.
Также держите стабильный API во время изменений. Даже если вы добавляете внутреннее новое поле, избегайте изменения формы ответа, пока все потребители (веб, мобильные, интеграции) не будут готовы.
Пример безболезненного релиза:
Ключевая идея — первый необратимый шаг это удаление старой структуры, поэтому его откладывают на конец.
Именно на бэфилле многие «безпростоянные» миграции сходят с рельсов. Надо заполнить новый столбец для существующих строк без долгих блокировок, медленных запросов и всплесков нагрузки.
Важны батчи. Ставьте такие батчи, которые завершаются быстро (секунды, не минуты). Небольшие батчи можно приостановить, настроить и продолжить без блокировки релизов.
Для отслеживания прогресса используйте стабильный курсор. В PostgreSQL это обычно первичный ключ. Обрабатывайте строки по порядку и храните последний обработанный id или работайте диапазонами id. Это избегает дорогих полных сканов таблицы при перезапуске задания.
Простой шаблон:
UPDATE my_table
SET new_col = ...
WHERE new_col IS NULL
AND id > $last_id
ORDER BY id
LIMIT 1000;
Делайте обновление условным (например, WHERE new_col IS NULL), чтобы задача была идемпотентной. Повторы трогают только строки, которые ещё нужно обработать, снижая лишние записи.
Планируйте приход новых данных во время бэфилла. Обычно порядок такой:
Хороший бэфилл — это скучная работа: стабильный, измеримый и легко приостанавливаемый при повышении нагрузки на базу.
Самый рискованный момент — не добавление нового столбца, а решение, что на него можно полагаться.
Перед переходом к контракту докажите два факта: новые данные заполнены, и продакшен безопасно их читает.
Начните с быстрых и повторяемых проверок полноты:
Если вы пишете в оба поля, добавьте проверку согласованности, чтобы поймать тихие ошибки. Например, запускайте запрос ежечасно, который находит строки, где old_value <> new_value, и тревожьте, если не ноль. Это часто самый быстрый способ обнаружить, что один писатель всё ещё обновляет только старое поле.
Следите за основными продакшен‑сигналами во время миграции. Если время запросов или ожидания блокировок растут, даже ваши «безопасные» верификационные запросы могут поднимать нагрузку. Мониторьте ошибки для путей, которые читают новое поле, особенно сразу после релизов.
Сколько держать оба пути? Достаточно, чтобы пережить по крайней мере один полный цикл релиза и один перезапуск бэфилла. Многие команды держат 1–2 недели или пока не убедятся, что старых версий приложения больше нет.
Contract пугает команды, потому что это кажется точкой невозврата. Если expand сделан правильно, contract — в основном зачистка, и её тоже можно разбить на маленькие низкорисковые шаги.
Выбирайте момент аккуратно. Не удаляйте ничего сразу после завершения бэфилла. Дайте пройти как минимум один цикл релиза, чтобы фоновые задания и граничные случаи проявились.
Безопасная последовательность contract обычно такая:
По возможности разделяйте contract на два релиза: один — убрать упоминания в коде (с доп. логированием), другой — удалить объекты базы. Такое разделение упрощает откат и отладку.
Специфика PostgreSQL важна. Удаление столбца в основном метаданные, но оно всё равно берёт ACCESS EXCLUSIVE lock на короткое время. Планируйте тихое окно и делайте миграцию быстро. Если вы создавали дополнительные индексы, предпочитайте DROP INDEX CONCURRENTLY, чтобы не блокировать записи (этот оператор нельзя запускать внутри транзакционного блока, так что ваше миграционное средство должно это поддерживать).
Миграции без простоя не работают, когда база и приложение перестают соглашаться, что допустимо. Паттерн действует только если каждое промежуточное состояние безопасно и для старого, и для нового кода.
Часто встречаются такие ошибки:
Реалистичный сценарий: вы начали писать full_name из API, но фоновая задача, которая создаёт пользователей, всё ещё заполняет только first_name и last_name. Она вставляет строки с full_name = NULL, а позже код предполагает, что full_name всегда есть.
Считайте каждый шаг релизом, который может длиться дни:
Повторяемый чек‑лист помогает не выпустить код, который работает только в одном состоянии базы.
Перед деплоем убедитесь, что база уже содержит элементы расширения (новые столбцы/таблицы, индексы, созданные с минимальными блокировками). Затем подтвердите, что приложение терпимо: оно должно работать со старой формой, с расширенной и с промежуточной, частично заполненной формой.
Короткий чек‑лист:
Миграция считается завершённой, когда чтения используют новые данные, записи больше не поддерживают старые данные, и вы проверили бэфилл хотя бы одной простой проверкой (счёты или выборка).
Допустим, в таблице PostgreSQL customers есть столбец phone с неряшивыми значениями (разные форматы, иногда пусто). Вы хотите заменить его на phone_e164, но не можете блокировать релизы или останавливать приложение.
Чистая expand/contract последовательность:
phone_e164 как nullable, без дефолта и строгих ограничений.phone, и в phone_e164, но продолжайте читать phone, чтобы ничего не менялось для пользователей.phone_e164, а если NULL — падает назад на phone.phone_e164, уберите fallback, удалите phone, и при необходимости добавьте строгие ограничения.Откат прост, если каждый шаг обратим. Если переключение чтений вызывает проблемы — откатывайте приложение, база всё ещё содержит оба столбца. Если бэфилл перегружает БД — приостановите задание, уменьшите размер батчей и продолжите позднее.
Для согласованности команды документируйте план в одном месте: точный SQL, в каком релизе переключаются чтения, как измерять завершённость (например, процент не‑NULL в phone_e164) и кто отвечает за каждый шаг.
Паттерн expand/contract работает лучше, когда он становится рутинным. Напишите короткий рук‑бук, который команда будет переиспользовать для каждой смены схемы — на одну страницу и достаточно конкретный, чтобы новый сотрудник мог следовать ему.
Практический шаблон включает:
Определите ответственных заранее. «Все думали, что кто‑то другой сделает contract» — причина, по которой старые столбцы и feature‑флаги висят месяцами.
Даже если бэфилл идёт онлайн, планируйте его на период с меньшей нагрузкой. Так легче держать батчи маленькими, наблюдать за нагрузкой и быстро остановиться при росте задержек.
Если вы строите и деплоите с Koder.ai (koder.ai), Planning Mode может помочь спланировать фазы и контрольные точки перед касанием продакшна. Правила совместимости остаются прежними, но записанные шаги уменьшают риск пропустить скучные, но важные действия, которые предотвращают простои.
Потому что база данных используется всеми запущенными версиями приложения. При поэтапных релизах и фоновых задачах старый и новый код могут выполняться одновременно, и миграция, которая меняет имена, удаляет столбцы или добавляет ограничения, может сломать ту версию, которая не ожидает такого состояния схемы.
Это означает, что вы проектируете миграцию так, чтобы каждое промежуточное состояние базы данных работало и для старого, и для нового кода. Сначала добавляют новые структуры, некоторое время работают с обеими путями, и только после того, как никто больше от старых путей не зависит, удаляют старые объекты.
Expand добавляет новые столбцы, таблицы или индексы, не убирая ничего, что нужно текущему приложению. Contract — это этап зачистки, когда после проверки новой логики удаляют старые столбцы, старые чтения/записи и временную логику синхронизации.
Обычно самый безопасный старт — добавить nullable-столбец без значения по умолчанию, потому что это избегает тяжёлых блокировок. Затем выкатывают код, который корректно работает при отсутствии значения или при NULL, поэтапно бэфиллят и только потом ужесточают ограничение NOT NULL.
Когда новая версия приложения пишет и в старое поле, и в новое во время перехода. Это помогает сохранить согласованность данных, пока ещё живут старые инстансы приложения и фоновые задачи, которые знают только старое поле.
Бэфилл выполняют в маленьких батчах, которые быстро завершаются, и делают его идемпотентным, чтобы повторные прогоны трогали только оставшиеся строки. Следят за временем запросов, ожиданиями блокировок и задержками репликации, и готовы при необходимости приостановить или уменьшить размер батчей.
Сначала проверьте полноту — сколько строк ещё с NULL в новом столбце. Затем выполните проверку согласованности — сравните старые и новые значения на выборке (или постоянно, если дешёво), и наблюдайте за ошибками в продакшне после релизов, чтобы поймать пути, которые всё ещё используют старую схему.
NOT NULL и другие ограничения могут блокировать записи, если применяются слишком рано; создание обычного индекса иногда держит блокировки дольше, чем вы ожидаете; переименования и удаления опасны, потому что старый код всё ещё может ссылаться на старые имена при поэтапном релизе.
Только после того, как вы перестали писать в старое поле, переключили чтение на новое без падения на fallback и дождались, чтобы убедиться, что никаких старых версий приложения или воркеров не осталось. Многие команды делают это отдельным релизом, чтобы откат был простым.
Если вы можете запланировать окно обслуживания и нагрузка невелика, одношаговая миграция может подойти. Если у вас реальные пользователи, много инстансов приложения, фоновых воркеров или SLA, паттерн expand/contract обычно стоит дополнительных усилий: он делает релизы и откаты заметно безопаснее. В Koder.ai Planning Mode запись фаз и чеков заранее помогает не пропустить «скучные» шаги, которые предотвращают простои.