Перейти к содержанию

Миграции схемы

Каталог меняется вместе с приложением. Уровень сложности, который сначала был дополнительным ключом properties, теперь нужен как устойчивый фильтр и обязательное правило. Нельзя удалить таблицу с курсами и создать её заново ради новой колонки: рабочие записи и связи должны сохраниться. Миграция описывает переход структуры из одного известного состояния в другое.

Используем PostgreSQL 17.11 и самостоятельный снимок catalog-db-advanced/lesson-27 из архива продолжения. Reset создаёт прежние шесть курсов с JSONB и таблицу истории версии один; difficulty пока отсутствует. SQL не запускался. Миграция является отдельным одноразовым файлом с COMMIT, восстановление лаборатории не вызывается внутри неё.

Структура и совместимость приложения

Добавим nullable колонку difficulty с допустимыми уровнями. Отсутствующее значение пока разрешено, потому что старые строки ещё не перенесены, а приложение должно пережить промежуточную версию. Новая колонка не требует сразу придумать уровень всем существующим курсам в одном тяжёлом изменении.

BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE catalog.courses ADD COLUMN difficulty text
CONSTRAINT course_difficulty_values
CHECK (difficulty IN ('beginner', 'intermediate', 'advanced'));
INSERT INTO catalog.schema_migrations(version, name)
VALUES (2, 'nullable-difficulty');
COMMIT;

Это полный migration-002.sql после guard учебной базы. Ограничение допускает NULL по уже изученным правилам CHECK. Значения существующих полей, ключи и двенадцать уроков сохраняются. Настройка двух секунд является выбранной границей ожидания блокировки, а не обещанием длительности миграции в реальной среде.

ALTER TABLE изменяет определение таблицы, в отличие от обычного UPDATE строк. Основные действия описаны в руководстве изменения таблиц PostgreSQL. Даже небольшая операция структуры требует учитывать режимы блокировок и актуальное состояние базы, а не только длину файла миграции.

История переходов

Таблица catalog.schema_migrations содержит номер, название и момент применения. Запись два фиксируется в той же транзакции, что добавление колонки. Если выбранное изменение не завершится успешно, история не должна сообщать, что база уже перешла на новое состояние. Такой договор важнее красивого списка версий в README.

После будущего применения ожидаются две записи истории. Точное время applied_at зависит от выполнения и не приводится заранее. Колонка difficulty существует, но у шести исходных курсов пока отсутствует. Это ожидаемое промежуточное состояние, а не доказательство неудачного переноса: backfill будет отдельной операцией.

Файл применяется один раз к исходной версии. Повтор после успешного COMMIT должен обнаружить уже существующий переход, а не бездумно продолжить. В небольшом снимке повторный ALTER даст ошибку; в настоящем runner история и политика выполнения определяют, какие файлы ещё не применены. Не добавляем IF NOT EXISTS только для подавления непонятного несовпадения схемы.

Номер миграции и courses.version имеют разный смысл. Первый описывает структуру всей базы, второй — редактируемое состояние конкретного курса. Изменение схемы не должно превращать один счётчик в другой. Также schema_migrations не является резервной копией строк и не позволяет восстановить данные только по списку названий.

Чтение во время перехода

Новый читатель может сначала использовать difficulty, а при отсутствии значения временно обращаться к JSON-уровню:

SELECT id, slug,
       coalesce(difficulty, properties->>'level') AS level
FROM catalog.courses
ORDER BY id;

Ожидаемые уровни совпадают с прежним документом: beginner для HTML, Markdown и SEO; intermediate для JavaScript, производительности и PostgreSQL. Новый источник ещё пуст, но карточка не теряет согласованное старое представление. Так меняется инфраструктура хранения без немедленного изменения видимого смысла.

Однако такой fallback имеет границу. Если свойства содержали неверный тип или неизвестную строку, простое извлечение не делает значение допустимым. Наш снимок контролируемый; перед переносом реальной библиотеки нужно отдельно прочитать и классифицировать имеющиеся значения. Не заполняйте «beginner» всем неизвестным курсам только ради быстрого достижения NOT NULL.

Писатели также должны быть согласованы. На переходной версии приложение обновляет уровень одновременно в difficulty и прежнем JSON-ключе, чтобы старые читатели продолжали работать. Перед backfill нужно убедиться, что больше нет писателя, способного менять только старый ключ и создавать новое расхождение. Совместимость требует плана чтения и записи, а не одной добавленной колонки.

Фазы вместо одного разрушительного шага

Переход удобно разделить: расширить схему, обновить приложение на совместимую запись, заполнить старые строки, подтвердить обязательность, затем убрать устаревшее использование JSON-ключа. Эти фазы не являются автоматически выполненными действиями нашей серии. Они описывают порядок, в котором каждая промежуточная версия имеет понятный договор.

Отложенное удаление старого источника полезно для отката приложения. Если новую nullable колонку просто оставить, старый читатель продолжит работать по properties. Если сразу удалить старый ключ и затем вернуть старую программу, она потеряет ожидаемые данные. Поэтому откат кода не равен слепому удалению новой структуры.

Старый урок миграций Entity Framework показывает ту же потребность сохранения данных через другой инструмент. Здесь используем явный PostgreSQL SQL и свою маленькую историю, не выдавая её за готовый универсальный migration framework. Политика параллельных runner и журналирование остаются задачами приложения сопровождения.

Границы блокировок

Добавление колонки не следует обещать как абсолютно незаметное действие при любой нагрузке. Оно получает структурную блокировку. Длительная транзакция другого клиента может заставить миграцию ждать, а миграция может влиять на следующие операции. Разбирать такое ожидание нужно по конкретному плану выпуска, не увеличивая таймер автоматически.

В нашем файле нет массового UPDATE, поэтому копирование строк не удлиняет этот же структурный блок. В следующей главе будем переносить данные небольшими пакетами. Подробности ALTER TABLE и ограничений сканирования описаны в справочнике PostgreSQL 17.

Не все команды сопровождения можно заключить в такой же BEGIN. Например, создание индекса CONCURRENTLY имеет специальные требования. Для выбранного ALTER и записи истории транзакционный переход подходит, но миграционный runner должен знать правила своих команд, а не оборачивать любой текст одинаково.

Миграция и запуск приложения

История версий помогает увидеть, какой переход уже принят, но не выбирает автоматически совместимый клиент. Во время обновления один процесс может ещё читать только JSON level, а другой уже обращаться к difficulty. Расширяющий шаг оставляет старое представление доступным именно для такого периода. Порядок выпуска приложения должен учитывать эту совместимость, а не предполагать мгновенное исчезновение всех старых процессов.

Для конкретной карточки можно проследить последовательность. Сразу после добавления nullable колонки старый документ содержит beginner, новая колонка пока NULL. Совместимый читатель использует объявленный fallback. Новая запись уровня одновременно сохраняет новое и старое представление, пока старые читатели ещё нужны. Только после их вывода из работы можно отказаться от второго пути записи и позже удалить устаревший источник.

Если новый выпуск приложения оказался ошибочным, возврат к прежнему коду может быть возможен при сохранённой расширенной схеме. Механическое удаление difficulty в ответ на любую ошибку способно потерять уже записанные значения. Поэтому откат приложения и разрушительный откат данных нужно рассматривать отдельно, с объяснением того, какие значения теперь принадлежат пользователям.

В учебном файле версия миграции фиксируется вместе с добавлением колонки в одной транзакции. Если этот шаг не принят, история не должна объявлять его выполненным. Но такое устройство не доказывает правильность всего рабочего каталога: следующий пакет переноса и совместимость клиентов всё равно требуют отдельного рассмотрения. История отвечает на вопрос «какой переход применён», а не заменяет предметную сверку состояния.

Читаем результат перехода

В снимке lesson.sql показывает историю и метаданные наличия колонки. После отдельного применения migration-002 используйте after-migration.sql, чтобы прочитать fallback уровней и количество незаполненных значений. Ожидаются шесть NULL у новой колонки при сохранённых курсах. Не пытайтесь выполнить after-файл до создания difficulty.

Для повторного изучения сначала завершите открытые блоки и отдельно восстановите reset исходной версии. На рабочем приложении этот reset недопустим: именно миграция сохраняет объекты. Теперь структура, версия приложения и старые данные имеют согласованный порядок изменения, позволяющий расширять каталог без уничтожения ключей и связей.

Оглавление курса.