Симптом появляется в релизном плане: сначала предлагают добавить обязательное поле, затем выкатить код, который его заполняет. Но в кластере ещё живёт старая версия сервиса, она пишет старую форму записи, а новая схема уже отказывается её принять. Цена не сводится к одному падению запроса. Запись может оказаться частично преобразованной, очередь ретраев увеличит нагрузку, а команда потеряет безопасный путь назад: удалённые или перезаписанные данные нельзя вернуть обычным rollback приложения.
Второй знакомый сценарий выглядит спокойнее. Поле добавили nullable, новый код умеет читать обе формы, и команда запускает backfill «до конца». Процесс начинает конкурировать с обычными запросами за те же ресурсы. Когда латентность растёт, его останавливают, но уже не знают, какие записи изменены и может ли старый код жить с новой формой. Здесь важен не красивый термин migration. Нужен договор о совместимости версий, границе нагрузки и обратимом действии на каждом этапе.
Маршрут: symptom → cause → check → action
- Симптом. Схема требует новое значение раньше, чем все writers умеют его сформировать, либо backfill не имеет точки остановки.
- Причина. Один change-set пытается одновременно расширить схему, переписать данные, переключить чтения и удалить старый путь. В нём нет периода, где old и new версия допустимы вместе.
- Проверка. Для каждого этапа выпишите четыре участника: old reader, old writer, new reader, new writer. Отдельно зафиксируйте, что сохраняется после остановки backfill и кто принимает решение продолжить.
- Действие. Разделите работу на expand, migrate и contract. Удаление старого представления разрешайте только после отдельного доказательства, а не после green build нового кода.
Expand — добавить поверхность, не отобрать старую
Expand означает, что новая форма данных уже существует, но старая ещё разрешена. В простом случае это новое nullable-поле или новая таблица, рядом с прежним представлением. Смысл не в конкретном типе DDL. Смысл в контракте: старая версия должна прочитать и записать то, что умела вчера; новая — прочитать старое и новое, а writer новой версии не должен ломать consumer, который ещё ждёт старую форму. Если это не удаётся сформулировать, схему нельзя выпускать отдельно от кода.
Отсюда следует неприятный, но полезный вывод: фраза «схема уже выкачена» ничего не говорит о готовности. Для одного движка добавление поля может быть дешёвым, для другого изменение типа может переписать большую часть таблицы. В PostgreSQL 16 документация отдельно описывает, что добавление колонки с non-volatile default обходится без rewrite, а volatile default или изменение типа могут потребовать rewrite таблицы и индексов. Это факт о PostgreSQL 16, не переносимое обещание для выбранной вами СУБД.
| Участник | В expand | В migrate | Что запрещено |
|---|---|---|---|
| Old reader | читает старое представление | читает старое представление | требовать новое поле только потому, что оно уже добавлено |
| Old writer | пишет старое представление | продолжает писать старое | получать отказ от новой schema contract |
| New reader | понимает новое или старое | проверяет заполненное новое | считать пустое поле ошибкой до завершения migration route |
| New writer | может писать обе формы | сохраняет dual write до switch | тихо перестать поддерживать старый consumer |
| Data owner | фиксирует смысл нового поля | подтверждает правило заполнения | объявлять completion без evidence и rollback draft |
Migrate — отдельно от изменения схемы
Migrate отвечает на другой вопрос: как привести старые записи к новому представлению, не превратив фоновую работу в неограниченный второй production-трафик. Backfill полезно назвать отдельным процессом с owner, входной областью, idempotency-правилом, маленькой партией, наблюдаемым стоп-сигналом и действием после остановки. Без этого слово «batch» не защищает. Партия может быть маленькой, но бесконечной; запрос может быть корректен, но выполняться в момент, когда основная нагрузка уже заняла бюджет.
Stripe в разборе Online migrations at scale описывает свой четырёхфазный подход: dual write, переключение readers, затем writers, после чего удаление старого. Там же они выделяют риск дополнительной записи и говорят о постепенном наращивании доли при наблюдении за operational metrics. Это хороший вопрос к собственному проекту, а не готовая настройка. В статье Stripe другой storage, свои сервисы и инструменты. Нельзя заменить этим текстом расчёт concurrency, лимитов и lock-поведения вашей БД.
Учебный пример без базы и SQL
В sidecar-пакете есть только fixed marked synthetic records. Пример ниже не имеет строки подключения, времени, таблицы, SQL, файла миграции или внешнего вызова. Он различает пять заранее заданных ситуаций: совместимый expand, schema ahead of old writer, unbounded backfill, ранний contract при mixed fleet и rehearsal без draft rollback. Так проще увидеть, что разные блокировки требуют разных следующих действий, а не одного ответа «повторить миграцию».
import {
createFixedSyntheticMigrationInput,
inspectSyntheticDataMigration,
planSyntheticDataMigration,
} from './upgrade-2024-04.mjs';
const input = createFixedSyntheticMigrationInput('fixed-contract-with-mixed-fleet');
const report = inspectSyntheticDataMigration(input);
const plan = planSyntheticDataMigration(report);
console.log({
verdict: report.verdict,
reasons: report.reasons,
draftActions: plan.actions,
});
// Работает только с embedded fixed synthetic records в памяти.
// Не открывает БД, не исполняет SQL и не запускает миграцию.
Для case с ранним contract report вернёт synthetic-stop-and-review и причину contract-while-old-version-or-old-representation-remains. Это не verdict о настоящем сервисе. Fixture не читает версии в кластере и не знает, какие записи существуют. Его проверка ограничена тем, что подменённый report или лишнее поле вроде database-like selector отклоняются closed input contract. PASS подтверждает форму учебного договора, не readiness реального релиза.
Switch — менять читателя после совместимого состояния
Switch часто недооценивают: «новая колонка заполнена» превращается в «теперь все читают только её». Но факт заполнения и право удалить fallback — разные доказательства. Читатель переключают после того, как договорены правила для null, старой записи, нового writer-а, повторного запуска и ошибочного значения. Если новый reader не умеет объяснить, что делает при старой форме, compatibility period закончился слишком рано.
Полезно разделить machine-safe и human evidence. Machine-safe может подтвердить только конкретный контракт: example input принимает old/new form, data-mapping выдаёт ожидаемую нормализованную форму, guard останавливает route по объявленному сигналу. Human evidence отвечает на другие вопросы: где сейчас живут старые версии, кто владеет трафиком, достаточно ли проверки выбранной выборки, имеет ли owner право на следующий шаг. Их нельзя заменить одним другим.
Contract — не обратимый этап
Contract удаляет старое поле, старый индекс, fallback или dual write. Это полезное упрощение, но по природе оно хуже откатывается. Rollback приложения может вернуть reader, однако не восстановит данные, которые уже перестали записываться в старую форму, и не вернёт удалённую историю. Поэтому настоящий rollback для contract начинается раньше: сохранить совместимое представление, иметь чёткий cutover record и заранее назвать, что команда сделает при divergence. Если такого маршрута нет, правильное действие — не ускорить contract, а продлить совместимый период.
PostgreSQL 16 полезен как пример ограниченной операции. Для check и foreign-key constraints документация описывает NOT VALID, а затем VALIDATE CONSTRAINT: новые записи уже проверяются, а старые валидируются отдельно. Это не универсальный expand–migrate–contract API. В частности, команды, типы constraint и locks зависят от PostgreSQL версии и объекта. Сначала проверить собственный engine и миграционный инструмент, затем строить route вокруг подтверждённого поведения.
Упорядоченный выпуск
- Записать data contract: старое и новое представление, owner, readers, writers, допустимые null и условие удаления fallback.
- Подготовить expand, который не отвергает declared old writer. Проверить lock и version behavior именно для своей СУБД; не выводить их из примера.
- Выпустить совместимый code path: new reader понимает обе формы, new writer сохраняет нужную старую форму до switch.
- Сделать draft backfill: область, idempotency, маленькая управляемая порция, stop signal, owner решения и ответ на partial progress.
- Провести rehearsal на согласованной изолированной среде. Проверить mixed-version route и остановку; не называть rehearsal production measurement.
- Собрать evidence, переключить reader только по agreed criterion и оставить fallback до завершения периода наблюдения.
- Отдельным решением выполнить contract. Если нет доказательства отсутствия old consumer или нет recovery plan, не удалять старое представление.
Ограничения и следующий шаг
Этот маршрут не выбирает transaction isolation, batch size, lock timeout, формат journal, график traffic или retention. Он не заменяет policy для PII, юридические требования к хранению, backup restore и disaster recovery. PostgreSQL 16, Stripe и Google SRE Book говорят о разных системах: их факты нельзя склеивать в вымышленный универсальный SLA. Особенно опасно обещать, что nullable column всегда безопасна или что dual write автоматически согласован: семантика default, trigger, replication, clock skew и ошибок записи может менять вывод.
Следующий шаг — оформить на одну страницу migration record для одного реального изменения. В нём должны быть old/new shape, owner, allowed version mix, dual-write rule, stop condition, recovery boundary, rehearsal question и контракт удаления. Пока эти строки не готовы, work item остаётся design, а не migration. Это короткая задержка перед выпуском, которая дешевле поиска невосстановимой записи после contract.
Историческая граница апреля 2024
К апрелю 2024 были доступны Stripe Online migrations at scale 2017 и PostgreSQL 16, выпущенный 14 сентября 2023. В материале они используются только в пределах своих утверждений. Голос M7 не продаёт «zero downtime»: он делит изменение на совместимые этапы, называет цену contract и оставляет человеку проверяемый следующий вопрос.
Проверяемые источники
- Stripe Engineering: Online migrations at scale, 02.02.2017 — Первичный инженерный разбор Stripe, опубликованный задолго до апреля 2024: четыре фазы — dual write, переключение чтений, переключение записей и удаление старого — применены к их subscriptions. Это наблюдение Stripe для конкретной инфраструктуры и объёма данных, а не обещание, что любая БД выдержит такой маршрут без собственных лимитов и проверки.
- PostgreSQL 16: ALTER TABLE — Первичная документация PostgreSQL 16. Она описывает PostgreSQL-специфичные свойства ADD COLUMN, NOT VALID и VALIDATE CONSTRAINT, включая разные блокировки и проверку старых строк. Эти детали нельзя переносить на другую СУБД, ORM или managed service без её собственной документации и rehearsal.
- PostgreSQL 16 released, 14.09.2023 — Официальное сообщение PostgreSQL Global Development Group фиксирует историческую доступность версии 16 до апреля 2024. Оно подтверждает дату версии, но не доказывает версию, настройки, размер таблиц или lock-поведение чьей-либо системы.
- Google SRE Book: Testing for Reliability — Первичный материал Google SRE Book, доступный до апреля 2024: тест снижает неопределённость, но проход теста не доказывает надёжность; рискованные инструменты требуют отдельного барьера. Это общий принцип release engineering, не руководство по синтаксису или нагрузке конкретной базы данных.