{"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.

Механизм: матрица reader и writer

Перед DDL выпишите четыре возможности: умеет ли старый reader читать новую форму, умеет ли новый reader читать её, пишет ли старый writer старую форму и пишет ли новый writer обе формы. Из этой матрицы видно, на какой фазе находится система и где возникнет несовместимость.

Совместимые состояния миграции
ФазаЧтениеЗаписьЧто разрешеноКонтроль
Expandстарая формастарая формадобавить nullable-колонку или совместимый индексстарый binary продолжает работать
Dual writeстарая форма, новая с fallbackобе формызаполнять новую форму пачками или при записисверять значения и ошибки записи
Switchновая форма с fallbackобе формыперевести reader после проверки данныхнаблюдать долю чтения fallback
Contractновая формановая формаудалить старую форму отдельным изменениеместь сигнал, что старые потребители ушли
Rollbackстарая или fallbackсовместимая записьвернуть binary без потери данныхпуть отката проверен до switch

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

DDL — операция с ресурсом

Изменение таблицы зависит от блокировок, объёма работы и конкретной версии 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 сервер?».

Порядок выполнения

  1. Опишите старую и новую формы данных. Укажите каждый reader и writer, версию binary и допустимый fallback.
  2. Проверьте DDL на блокировки, размер таблицы, индексы, транзакцию, репликацию и план восстановления. Для production-объёма используйте среду с похожими данными, если это возможно в вашей процедуре.
  3. Добавьте новую форму без требования, которое сломает старый binary. Сначала проверьте, что старый код продолжает читать и писать прежнюю форму.
  4. Включите двойную запись или backfill идемпотентными пачками. Сверяйте количество обработанных строк, контрольные значения и случаи, когда более свежая запись уже существует.
  5. Переведите чтение на новую форму с fallback. Наблюдайте ошибки, latency, lock wait и долю чтения старой формы. Fallback должен быть виден, иначе нельзя понять, ушли ли старые потребители.
  6. Удалите fallback и старую форму отдельным изменением после окна наблюдения. Сохраните понятный сигнал, что старый reader больше не обращается к колонке и rollback-путь больше не требуется.

Почему rollback не равен обратной миграции

Откат приложения возвращает код, но не обязательно возвращает схему. Если новый writer заполняет только display_name, старый reader без fallback может увидеть пустое значение. Если преобразование типа потеряло информацию, обратный DDL не восстановит её. Поэтому старый reader должен оставаться совместимым с данными, которые создал новый writer, а путь отката нужно определить до switch.

Проверяйте промежуточные состояния: сразу после expand, во время частичной двойной записи, после остановки backfill и после переключения только чтения. В каждом состоянии остановка приложения или миграции должна иметь понятное продолжение. Финальный smoke test не проверяет совместимость всех этих переходов.

Ограничения и критерий готовности

Эта схема не выбирает lock mode, размер пачки, стратегию индексации или настройки WAL для вашего кластера. На результат влияют версия PostgreSQL, расширения, ORM, триггеры, партиционирование, репликация, размер таблицы и политика блокировок. Документация описывает свойства операций, но не разрешает выполнять их без проверки вашей нагрузки.

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

Проверяемые источники

"}