{"index":6,"slug":"editorial-2027-11-practice-mistakes-revisions","title":"Миграция схемы БД без простоя: совместимые фазы expand, switch, contract","excerpt":"Как изменить схему при работающем старом и новом коде: проверить совместимость чтения и записи, пережить backfill и удалить старую форму только после явного сигнала.","contentHtml":"
Команда ALTER TABLE проходит на пустой базе, но на большой таблице может ждать блокировку и задержать пользовательские запросы. Другой симптом появляется после выката: новый writer сохраняет только новую форму данных, а ещё работающий старый reader ищет старую колонку и получает ошибку или неполную запись.
Цена ошибки — очередь запросов, простой части сервиса и откат приложения, который уже не возвращает совместимость со схемой. Откат кода не отменяет записи, сделанные новым writer, а обратное изменение типа или удаление данных может оказаться необратимым. Поэтому миграцию проектируют как последовательность совместимых состояний, а не как один SQL-файл.
Безопасный порядок такой: добавить новую форму данных, научить код работать со старой и новой формами, переключить чтение, проверить потребителей и только затем удалить старую форму. Старый и новый binary некоторое время живут одновременно, экземпляры обновляются не синхронно, а миграция может остановиться между фазами.
Рассмотрим замену вычисляемого имени из first_name и last_name на колонку display_name. Сначала новая колонка должна быть совместима со старым кодом: она nullable или имеет безопасное значение по правилам домена. Пока старый reader ещё работает, новый writer не может отказаться от старых полей без fallback.
Перед DDL выпишите четыре возможности: умеет ли старый reader читать новую форму, умеет ли новый reader читать её, пишет ли старый writer старую форму и пишет ли новый writer обе формы. Из этой матрицы видно, на какой фазе находится система и где возникнет несовместимость.
| Фаза | Чтение | Запись | Что разрешено | Контроль |
|---|---|---|---|---|
| Expand | старая форма | старая форма | добавить nullable-колонку или совместимый индекс | старый binary продолжает работать |
| Dual write | старая форма, новая с fallback | обе формы | заполнять новую форму пачками или при записи | сверять значения и ошибки записи |
| Switch | новая форма с fallback | обе формы | перевести reader после проверки данных | наблюдать долю чтения fallback |
| Contract | новая форма | новая форма | удалить старую форму отдельным изменением | есть сигнал, что старые потребители ушли |
| Rollback | старая или fallback | совместимая запись | вернуть binary без потери данных | путь отката проверен до switch |
Новый writer без совместимого reader — небезопасное состояние. Двойная запись решает только доставку данных в две формы; она не доказывает, что значения одинаковы, что backfill не перезапишет более свежую запись и что все потребители готовы к switch.
Изменение таблицы зависит от блокировок, объёма работы и конкретной версии PostgreSQL. В review смотрите на lock mode, время ожидания, границы транзакции, индексы, триггеры, репликацию и план восстановления. Добавление колонки, создание индекса, backfill и изменение типа имеют разную стоимость. Объединять их в одну «маленькую миграцию» нельзя без проверки.
Backfill — отдельная нагрузка, а не деталь миграции схемы. Большой UPDATE конкурирует с пользовательскими запросами и может увеличить WAL. Идемпотентные ограниченные пачки позволяют остановить работу и продолжить её позже. Размер пачки, пауза и условие обновления зависят от вашей нагрузки; пример ниже не задаёт универсальные значения.
Небольшая функция формализует главный запрет: нельзя включать новую запись, если нет reader, который понимает новую форму. Это учебная проверка совместимости; она не подключается к базе, не запускает DDL и не заменяет проверку конкретного кластера.
function classifyMigrationStep({ oldReads, newReads, oldWrites, newWrites }) {\n if (newWrites && !oldReads && !newReads) {\n return { phase: 'unsafe', reason: 'new-writer-has-no-compatible-reader' };\n }\n if (newWrites && !oldWrites) {\n return { phase: 'expand', reason: 'new-write-path-can-be-added-with-old-readers' };\n }\n if (newReads && oldReads && newWrites) {\n return { phase: 'switch', reason: 'both-readers-and-writers-understand-format' };\n }\n if (oldReads && !newReads && !newWrites) {\n return { phase: 'contract', reason: 'remove-format-only-after-consumers-move' };\n }\n return { phase: 'inspect', reason: 'compatibility-matrix-is-incomplete' };\n}\n\nconsole.log(classifyMigrationStep({\n oldReads: false,\n newReads: false,\n oldWrites: false,\n newWrites: true,\n}).phase);\n// unsafeВ настоящей миграции вместо boolean-признаков нужны конкретные версии приложения, формы записи и список потребителей. Проверка должна отвечать на вопрос «кто прочитает запись после этого шага?», а не только на вопрос «принял ли SQL сервер?».
Откат приложения возвращает код, но не обязательно возвращает схему. Если новый writer заполняет только display_name, старый reader без fallback может увидеть пустое значение. Если преобразование типа потеряло информацию, обратный DDL не восстановит её. Поэтому старый reader должен оставаться совместимым с данными, которые создал новый writer, а путь отката нужно определить до switch.
Проверяйте промежуточные состояния: сразу после expand, во время частичной двойной записи, после остановки backfill и после переключения только чтения. В каждом состоянии остановка приложения или миграции должна иметь понятное продолжение. Финальный smoke test не проверяет совместимость всех этих переходов.
Эта схема не выбирает lock mode, размер пачки, стратегию индексации или настройки WAL для вашего кластера. На результат влияют версия PostgreSQL, расширения, ORM, триггеры, партиционирование, репликация, размер таблицы и политика блокировок. Документация описывает свойства операций, но не разрешает выполнять их без проверки вашей нагрузки.
Миграция готова к contract, когда новая форма заполнена и сверена, новый reader работает без скрытой зависимости от старой формы, старый binary больше не является потребителем, а rollback-путь проверен на промежуточном состоянии. Если хотя бы один пункт нельзя доказать наблюдением или проверкой, оставьте старую форму и продолжите расследование.