Расширяющие и сужающие миграции: почему схему меняют в два релиза
Классическая ситуация: нужно переименовать колонку, разбить таблицу на две или поменять тип поля — а сервис при этом не должен упасть ни на секунду, потому что раскатка идёт постепенно на десяток инстансов. Если менять схему и код одним шагом, часть трафика неизбежно попадает на под со старым кодом, который обращается к уже удалённому полю. Паттерн expand/contract (расширяющая и сужающая миграция) решает эту проблему тем, что делит одно изменение на два независимых релиза — и именно поэтому он стал стандартом для баз данных с несколькими одновременно работающими версиями приложения.
Содержание
- Почему «поменять всё сразу» ломается на раскатке
- Расширяющая миграция: добавляем, ничего не трогая
- Раскатка: код работает и со старой, и с новой схемой одновременно
- Сужающая миграция: убираем старое, когда оно точно не нужно
- Пример целиком: переименование email → user_email по шагам
- Инструменты, автоматизация и что зашить в CI/CD
Почему «поменять всё сразу» ломается на раскатке
Представьте обычный деплой: у вас три инстанса приложения за балансировщиком, и раскатка новой версии идёт по одному — canary, потом остальные. Пока раскатка не завершена, на проде одновременно работают старая и новая версия кода. Это нормально и неизбежно: даже при мгновенном деплое одного контейнера будут запросы, которые «в полёте» — обработка началась старым кодом, а транзакция коммитится уже после того, как схему поменяли.
Если миграция одним шагом переименовывает колонку email в user_email и удаляет старую, происходит следующее:
- Старый код (ещё не замененные поды) продолжает делать
INSERT INTO users (email, ...)— и получает ошибкуcolumn "email" does not exist. - Новый код мог задеплоиться раньше, чем накатилась миграция (в зависимости от порядка в pipeline) — тогда он падает на
column "user_email" does not exist. - Даже если порядок правильный, откат деплоя (а он случается чаще, чем хочется) откатывает код, но не всегда откатывает схему — и старый код снова видит несуществующую колонку.
Разбор похожего случая, но с блокировкой таблицы вместо рассинхрона схемы, есть в статье про миграцию, которая добавила одну колонку и заблокировала таблицу — там ALTER TABLE был безопасным сам по себе, но всё равно привёл к простою, потому что упёрся в блокировки. Проблема экспанд/контракта — другого рода: не блокировка, а несовместимость версий кода и схемы во время раскатки. Оба случая лечатся тем, что миграция не должна быть «резкой».
Правило простое: в любой момент времени схема БД должна быть совместима одновременно и со старым, и с новым кодом. Это единственный способ дать раскатке идти постепенно, без хирургического окна простоя.
Расширяющая миграция: добавляем, ничего не трогая
Expand-миграция добавляет в схему всё, что нужно новому коду, не удаляя и не переименовывая ничего из того, чем пользуется старый код. После неё база на какое-то время становится «шире», чем нужно любой отдельной версии кода — и старая, и новая версия могут работать с ней одновременно.
Типичные расширяющие операции:
-- добавить новую колонку (nullable — обязательно, см. ниже)
ALTER TABLE users ADD COLUMN user_email varchar(255);
-- добавить новую таблицу для новой сущности
CREATE TABLE user_addresses (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
address text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- добавить индекс не блокируя запись (Postgres)
CREATE INDEX CONCURRENTLY idx_users_user_email ON users(user_email);
Важные детали, которые часто упускают:
- Новая колонка должна быть nullable или иметь DEFAULT, вычисляемый мгновенно (константа, а не подзапрос). Иначе
ADD COLUMN ... NOT NULLв некоторых СУБД перепишет всю таблицу под блокировкой — это отдельная и хорошо известная грабля, разобранная в статье про блокировку таблицы из-за добавленной колонки. NOT NULL constraint навешивается уже потом, отдельным шагом, когда все строки заполнены. - Ничего не удаляется и не переименовывается.
RENAME COLUMNв этом паттерне не используется вообще — переименование делается как «добавить новое поле + перестать использовать старое», см. пример ниже. - Расширяющая миграция должна быть обратно совместима сама по себе: если её откатить (или просто не выполнить), старый код должен продолжать работать как ни в чём не бывало. Она ничего не сломает даже без соответствующего кода — потому что ничего не забирает.
После этой миграции деплой нового кода можно катить сколь угодно медленно: старые поды пишут только в старые колонки, новые поды — в старые и новые одновременно (двойная запись, double write), и оба варианта валидны для текущей схемы.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверРаскатка: код работает и со старой, и с новой схемой одновременно
Между expand-миграцией и следующим релизом (contract) обычно проходит не один деплой, а целый период — от нескольких дней до нескольких недель, в зависимости от того, как быстро нужно быть уверенным, что откат назад больше не понадобится. Всё это время код должен явно поддерживать оба состояния данных.
Практически это выглядит как переходный код на чтение и запись:
# запись: пишем в обе колонки, пока не убедимся, что весь код перешёл на новую
def save_user(user):
db.execute(
"UPDATE users SET email = %s, user_email = %s WHERE id = %s",
(user.email, user.email, user.id),
)
# чтение: новое поле в приоритете, но старое — это фолбэк
def get_email(row):
return row["user_email"] or row["email"]
Для полей это несложно, но то же самое верно и на уровне таблиц: если разбиваете одну таблицу на две (например, выносите адреса из users в user_addresses), новый код читает из новой таблицы, но пока не все данные туда перенесены — с фолбэком на старую. Бэкфилл существующих строк — отдельная задача, которую стоит гонять батчами вне пиковой нагрузки:
-- бэкфилл малыми пачками, чтобы не держать долгую блокировку и не забить WAL
UPDATE users SET user_email = email
WHERE user_email IS NULL
AND id IN (SELECT id FROM users WHERE user_email IS NULL LIMIT 5000);
Повторяйте, пока не останется строк с user_email IS NULL — и мониторьте прогресс отдельным запросом (SELECT count(*) FROM users WHERE user_email IS NULL), а не полагайтесь на «прогнал один раз и забыл». Здесь же уместно свериться с чек-листом из статьи как проверить, что миграция прошла успешно — критерий готовности к сужающей миграции почти дословно совпадает с тем, что там описано для обычной миграции сервера: доступность, целостность данных, ничего не осталось «в старом виде».
Сужающая миграция: убираем старое, когда оно точно не нужно
Contract-миграция — зеркало expand: она убирает то, что стало избыточным, теперь когда весь код гарантированно перешёл на новую схему. Это отдельный релиз, который выкатывается только после того, как выполнены все условия готовности:
- Новая версия кода раскатана на 100% инстансов, старой версии нигде не осталось — ни на проде, ни в фоновых воркерах, ни в отложенных джобах, которые могли быть поставлены в очередь до деплоя.
- Бэкфилл данных завершён — нет строк, где новое поле пустое, а старое заполнено.
- Прошло достаточно времени, чтобы быть уверенным: откатывать деплой на предыдущую версию кода уже не понадобится. Для большинства сервисов это несколько дней стабильной работы; для критичных — иногда неделя-две с постепенным снятием двойной записи.
- В коде не осталось ни одного обращения к старому полю или таблице — это стоит проверить
grep-ом по кодовой базе перед тем, как сносить колонку, а не полагаться на память.
Сама сужающая миграция выглядит так:
-- убедиться, что данные консистентны, перед тем как навесить constraint
ALTER TABLE users ALTER COLUMN user_email SET NOT NULL;
-- удалить старое поле, которое больше никто не читает и не пишет
ALTER TABLE users DROP COLUMN email;
Важно: сужающая миграция необратима в том смысле, что после неё откат кода на версию, которая писала в email, снова всё сломает. Поэтому именно на этом шаге — а не на expand-шаге — нужен настоящий «путь назад» только через бэкап или через ещё один expand, который временно вернёт колонку. Держите план отката под рукой; общая логика такого плана — точка невозврата, критерии решения, порядок действий — разобрана в статье про план отката миграции, и она применима к contract-шагу почти без изменений.
Пример целиком: переименование email → user_email по шагам
Соберём всё в последовательность релизов, как это выглядело бы в реальном пайплайне на Postgres.
Релиз 1 (expand):
ALTER TABLE users ADD COLUMN user_email varchar(255);
CREATE INDEX CONCURRENTLY idx_users_user_email ON users(user_email);
Код в этом релизе ещё не меняется — миграция катится отдельно и заведомо безопасна для текущей версии приложения.
Релиз 2 (двойная запись + бэкфилл): Код начинает писать в обе колонки при каждом создании/обновлении пользователя. Параллельно запускается фоновый скрипт бэкфилла батчами по 5000 строк с паузой между итерациями, чтобы не создавать заметную нагрузку на реплику и не раздувать WAL на проде.
Релиз 3 (чтение из нового поля): Код переключается на чтение из user_email с фолбэком на email, если новое поле ещё пустое (на случай, если бэкфилл где-то не успел). Это самый рискованный с точки зрения бизнес-логики релиз — он меняет поведение чтения, поэтому его стоит катить через canary на небольшой процент трафика. Общий подход к постепенной выкатке с мониторингом на каждом шаге описан в статье про canary deploy — та же механика «раскатить на 5%, посмотреть на метрики, только потом на всех» применима и здесь, просто объект наблюдения — не только ошибки 5xx, но и число строк с расхождением между email и user_email.
Релиз 4 (contract): После недели стабильной работы без единого отката и с подтверждённым нулём расхождений — снос старой колонки:
ALTER TABLE users ALTER COLUMN user_email SET NOT NULL;
ALTER TABLE users DROP COLUMN email;
Четыре релиза вместо одного — это не бюрократия ради бюрократии. Каждый из них по отдельности маленький, предсказуемый и легко откатывается независимо от остальных. Один большой релиз с переименованием «в лоб» экономит время только на бумаге — на практике инцидент из-за рассинхрона версий во время раскатки почти всегда стоит дороже, чем три дополнительных PR.
Инструменты, автоматизация и что зашить в CI/CD
Вручную дисциплину expand/contract соблюдать тяжело — рано или поздно кто-то поспешит и сольёт в одном PR добавление колонки и удаление старой. Здесь помогает и инструментарий, и процессные правила.
Версионированные миграции через Flyway или Liquibase дают историю схемы и позволяют явно нумеровать expand- и contract-шаги как отдельные файлы — так в code review сразу видно, что это два разных релиза, а не один диф. Разбор того, чем эти инструменты отличаются друг от друга и как их встроить в проект, — в статье Flyway и Liquibase против ручных ALTER TABLE.
Практические правила, которые стоит закрепить в процессе:
| Правило | Зачем |
|---|---|
| Новая колонка — только nullable или с мгновенным DEFAULT | Не блокирует таблицу при добавлении |
DROP COLUMN / DROP TABLE — только отдельным PR от ADD | Не даёт случайно совместить expand и contract |
Линтер миграций в CI, запрещающий RENAME/DROP в одном файле с ADD | Механически исключает ошибку, а не полагается на память ревьюера |
| Чек «нет обращений к старому полю» перед contract-релизом | grep -r "\.email\b" по коду и по логам медленных запросов |
| Дата/тег на выполненный expand + минимальная выдержка перед contract | Даёт время на откат кода без отката схемы |
Если в команде несколько сервисов делят одну базу — правило становится ещё строже: contract нельзя катить, пока не обновлены все клиенты этой таблицы, а не только тот сервис, который её меняет. В такой ситуации выдержка перед сужающей миграцией часто растягивается до релизного цикла самого медленного из потребителей.
Фича-флаги — ещё один рычаг: можно раскатить новый код чтения/записи под флагом на 1% пользователей, убедиться, что двойная запись работает корректно, и только потом расширять флаг до 100% — прежде чем вообще думать про contract. Это не обязательный элемент паттерна, но он сильно снижает риск на самом хрупком, третьем релизе из примера выше.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Обязательно ли делать именно четыре релиза, как в примере?
Нет, это зависит от сложности изменения. Простое добавление новой независимой колонки может обойтись без выраженной стадии двойной записи — expand и contract с достаточной выдержкой между ними. Переименование или перенос данных между таблицами обычно требует промежуточных шагов с двойной записью и бэкфиллом, как в примере.
Что делать, если contract уже выкатили, а потом нашли старый код, который ещё ходил в удалённое поле?
Это откат на бэкап схемы или экстренный expand, возвращающий колонку с последующим повторным бэкфиллом — оба варианта дорогие, поэтому и стоит жёстко проверять отсутствие обращений к старому полю перед contract, а не после.
Нужна ли двойная запись, если изменение затрагивает только одну таблицу и один сервис без внешних читателей?
Часто можно упростить: expand, бэкфилл, переключение чтения на новое поле, contract — без явной фазы «писать в оба поля», если запись идёт только через одну точку входа в коде и деплой атомарен. Но если пишущих путей несколько (API + фоновые джобы + импорт), безопаснее не срезать угол.
Применим ли паттерн к NoSQL-хранилищам, а не только к SQL?
Да, суть та же: сначала документы/записи получают новое поле параллельно со старым, код какое-то время читает и то и другое, и только потом старое поле физически убирается миграционным скриптом по коллекции. Механика чуть другая (нет ALTER TABLE), идея — идентична.
Как понять, что можно катить contract, а не ждать ещё?
Формальный критерий: ноль запросов к старому полю в логах медленных запросов и в APM за представительный период (обычно не меньше недели), ноль строк с расхождением данных между старым и новым полем, и явное подтверждение от всех команд, использующих таблицу, что их код обновлён.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →