DarkRiDDeR16 мин

Миграция схемы БД без простоя: expand, switch, contract

Базы данныхНадёжность

Проблема начинается с невинной команды ALTER TABLE: запрос проходит на пустой базе, но на большой таблице блокирует чтение или оставляет старый код без нужной колонки. Цена ошибки — простой, очередь запросов и откат приложения, который уже не умеет читать изменённые данные.

Причина — рассматривать схему и код как один пакет. В работающей системе старый и новый binary живут одновременно, миграция может быть прервана, а несколько экземпляров переключаются не синхронно. Поэтому изменение нужно разложить на совместимые фазы: сначала добавить форму, затем переключить чтение и запись, и только потом удалить старое.

Совместимость — это матрица чтения и записи

Представьте добавление display_name вместо вычисления имени из двух колонок. Старый код читает first_name и last_name, новый хочет читать display_name. Если сразу сделать новое поле обязательным и перевести writer, старый reader может не понять запись. Если сразу удалить старые колонки, rollback перестанет быть обратимым.

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

Фазы expand/switch/contract
ФазаЧтениеЗаписьДопустимое изменение
Expandстароестарое или обе формыдобавить nullable колонку/индекс
Dual writeстарое с fallbackобе формызаполнить новый формат
Switchновое с fallbackобе формыперевести reader после проверки данных
Contractновоеновая формаудалить старую только после сигнала
Rollbackстарое или fallbackсовместимая записьвернуть binary без потери данных

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

Документация PostgreSQL предупреждает, что изменение таблицы может зависеть от блокировок и объёма работы. В review важно смотреть не только на SQL, но и на lock mode, время ожидания, транзакцию миграции и поведение при остановке. Индекс, backfill и изменение типа имеют разную стоимость; объединять их в одну «маленькую миграцию» опасно.

Backfill лучше считать отдельной нагрузкой. Он может конкурировать с пользовательскими запросами, вызвать рост WAL и изменить порядок обновлений. Ограниченная пачка, пауза и метрика отставания полезнее одного огромного UPDATE. Если backfill прерван, повтор должен быть идемпотентным и не перезаписывать более свежую запись.

Временная схема миграции: добавление совместимой формы, двойная запись, переключение чтения и удаление старой колонки разделены измеримыми контрольными точками.
Диаграмма показывает порядок, в котором старый и новый код могут сосуществовать. Удаление старой формы находится в конце и требует сигнала использования.

Runnable-пример: определить безопасную фазу

Функция принимает четыре boolean-признака: умеет ли старый и новый код читать и писать новый формат. Она возвращает фазу и причину. Пример не подключается к базе и не запускает DDL; он фиксирует мысль, которую удобно проверить в review или в тесте миграционного инструмента. Если новый writer не имеет совместимого reader, результат должен быть unsafe.

import { classifyMigrationStep } from './upgrade-2027-11.mjs';

const expand = classifyMigrationStep({
  oldReads: false,
  newReads: false,
  oldWrites: false,
  newWrites: true,
});
const switchPhase = classifyMigrationStep({
  oldReads: true,
  newReads: true,
  oldWrites: true,
  newWrites: true,
});

console.log(expand.phase, expand.reason);
console.log(switchPhase.phase, switchPhase.reason);
// unsafe new-writer-has-no-compatible-reader
// switch both-readers-and-writers-understand-format

Порядок безопасной миграции

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

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

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

Тестировать нужно не только финальное состояние. Нужны состояния после expand, после частичного dual write и после остановки backfill. В каждом состоянии старый и новый binary должны иметь понятное поведение. Такой набор дороже одного smoke test, но дешевле восстановления после того, как несовместимость попала в основную таблицу.

Ограничения и следующий шаг

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

Следующий шаг — выбрать одну миграцию и заполнить compatibility matrix для старого/нового reader и writer, а затем проиграть остановку на каждой фазе. Если нет состояния, в котором старый код безопасно читает новую запись, сначала исправьте контракт и только потом пишите DDL.

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

  • PostgreSQL 16 Documentation — Modifying Tables — PostgreSQL 16, раздел 5.6, документация версии 16. Применение: Показывает, как операции изменения таблиц связаны с блокировками, размером данных и совместимостью чтения. Граница: Не является инструкцией для конкретного кластера: версия, расширения, объём и lock policy требуют отдельной проверки.
  • RFC 9110 — HTTP Semantics — IETF, июнь 2022 года, RFC 9110, Standards Track. Применение: Даёт HTTP-семантику методов, статусов и условных запросов, важную для совместимого API вокруг миграции. Граница: Не описывает схему вашей базы, ORM и порядок выката приложения.