{"index":6,"slug":"editorial-2027-11-practice-mistakes-revisions","title":"Миграция схемы БД без простоя: совместимые фазы expand, switch, contract","excerpt":"Как изменить схему при работающем старом и новом коде: сначала добавить совместимую форму, затем заполнить и проверить данные, переключить чтение и только после этого удалить старую колонку.","contentHtml":"
Миграция падает не только на самом ALTER TABLE. Частый симптом выглядит так: новый релиз уже пишет в display_name, а экземпляр старого кода ещё читает first_name и last_name. Другой вариант — DDL ждёт блокировку, пока пользовательские запросы продолжают работать. В обоих случаях одна команда пытается поменять контракт сразу для базы, writer и reader.
Цена ошибки — ошибки чтения, потерянное значение имени или очередь запросов. Откат бинарника не возвращает данные, которые новый writer уже записал только в новую форму, а обратный DDL не восстановит информацию после необратимого преобразования. Главный вопрос миграции поэтому такой: как сделать каждый промежуточный шаг совместимым с уже работающими потребителями?
Под reader будем понимать любой код, который читает строку, а под writer — код, который её создаёт или изменяет. Переход совместим, если после каждого шага любой ещё работающий reader может прочитать запись, созданную любым writer. Это правило относится и к фоновым задачам, отчётам, админке, скриптам и другим сервисам, а не только к основному HTTP-приложению.
Схема old -> new не выкатывается одной миграцией. Сначала расширяем контракт, потом некоторое время обслуживаем две формы, затем переключаем чтение и лишь в конце сужаем контракт. Именно такой смысл у паттерна expand and contract в документации GitLab: expand сохраняет обратную совместимость, migrate переводит потребителей, contract удаляет совместимость.
Пусть в таблице users уже есть nullable-колонки first_name и last_name. Новому экрану нужен display_name. Правило форматирования в примере учебное: соединить непустые части одним пробелом. В реальном домене нужно отдельно решить порядок имени, локаль, пробелы, пустые строки и допустимое отсутствие значения.
| Фаза | Reader | Writer | Действие | Выход из фазы |
|---|---|---|---|---|
| Expand | Старая форма | Старая форма | Добавить nullable display_name без требования для старого кода | Старый binary читает и пишет как прежде |
| Dual write | Новая форма с fallback | Обе формы в одной операции | Обновлять колонку при каждой записи и заполнить старые строки | Проверка расхождений и готовности readers |
| Switch | Новая форма, fallback виден | Обе формы | Перевести основной путь чтения и наблюдать ошибки | Ни один потребитель не использует fallback |
| Contract | Новая форма | Новая форма | Убрать fallback, затем удалить старые колонки отдельными шагами | Есть подтверждённый план отката или принято решение жить без него |
Fallback в этой таблице — не молчаливый костыль. Он должен быть измеримым: например, код увеличивает счётчик чтений старой формы и пишет идентификатор потребителя. Если fallback не виден, команда не может доказать, что contract безопасен.
Для PostgreSQL 16 минимальный первый шаг может выглядеть так:
BEGIN;\nSET LOCAL lock_timeout = '1s';\nALTER TABLE users ADD COLUMN display_name text;\nCOMMIT;1s здесь — проектный пример, а не универсальное значение. При тайм-ауте транзакция должна завершиться с ошибкой, а миграционный runner — оставить понятный результат для повторного запуска. Документация PostgreSQL указывает, что требуемый lock level зависит от формы ALTER TABLE, а без специальной оговорки берётся ACCESS EXCLUSIVE. Поэтому nullable-колонка без backfill всё равно может ждать уже занятую блокировку.
После expand старый binary не должен требовать новую колонку. На этом шаге не добавляйте без проверки NOT NULL, тяжёлый default, изменение типа и удаление старых полей в ту же операцию. У этих действий другая стоимость и другой риск. Сначала отдельно подтвердите, что схема появилась, старые запросы всё ещё проходят, а повторный запуск миграции обрабатывается вашей системой миграций.
Новый writer должен в одной логической операции сохранить обе формы. Простейшая чистая функция показывает контракт, но не притворяется ORM или транзакцией:
function formatDisplayName(firstName, lastName) {\n return [firstName, lastName]\n .filter((part) => part !== null && part !== undefined && part !== '')\n .join(' ');\n}\n\nfunction readDisplayName(row) {\n if (row.display_name !== null && row.display_name !== undefined) {\n return row.display_name;\n }\n return formatDisplayName(row.first_name, row.last_name);\n}\n\nfunction writeUser(input) {\n return {\n ...input,\n display_name: formatDisplayName(input.first_name, input.last_name),\n };\n}\n\nconsole.assert(\n readDisplayName({ display_name: null, first_name: 'Ada', last_name: 'Lovelace' }) ===\n 'Ada Lovelace',\n);\nconsole.assert(\n writeUser({ first_name: 'Ada', last_name: 'Lovelace' }).display_name ===\n 'Ada Lovelace',\n);В приложении результат writeUser нужно записать атомарно с изменением исходных полей. Если ORM выполняет два независимых запроса, ошибка между ними создаёт ещё один промежуточный формат. Поэтому проверяйте не только функцию, но и границу транзакции, обработку повторной попытки и поведение при конфликте.
Старые строки заполняются отдельным backfill. Идемпотентная пачка для PostgreSQL 16 может быть такой:
UPDATE users\nSET display_name = trim(concat_ws(' ', first_name, last_name))\nWHERE id > $1\n AND id <= $2\n AND display_name IS NULL\n AND (first_name IS NOT NULL OR last_name IS NOT NULL);Параметры $1 и $2, размер пачки и пауза между пачками зависят от runner и нагрузки. Условие display_name IS NULL защищает уже заполненную строку от повторной записи, но не решает доменное различие между «пусто» и «ещё не обработано». Если исходные поля меняются во время backfill, задайте конфликтное правило: общий транзакционный writer, версия строки или повторная сверка.
Перед switch сравните старую и новую формы, а не просто посчитайте обработанные строки. Для примера полезно разделить строки без исходных данных и строки с расхождением:
SELECT\n count(*) FILTER (\n WHERE display_name IS NULL\n AND (first_name IS NOT NULL OR last_name IS NOT NULL)\n ) AS missing_display_name,\n count(*) FILTER (\n WHERE display_name IS NOT NULL\n AND display_name <> trim(concat_ws(' ', first_name, last_name))\n ) AS different_display_name\nFROM users;Нулевой результат этих двух счётчиков ещё не доказывает готовность всей системы. Нужно проверить потребителей: старый binary, новый binary, фоновые workers, экспорт, SQL-запросы и кэш. Для каждой записи в журнале изменения должно быть понятно, кто её пишет и какая форма будет прочитана после переключения.
Переводите reader отдельно от writer. Сначала новый reader использует display_name и считает fallback. Затем включайте новый путь постепенно, если такая возможность есть. Наблюдайте ошибки чтения, задержку, lock wait, отставание реплик и количество fallback. При росте ошибки верните reader на совместимый fallback, но оставьте dual write: откат чтения не должен остановить поддержание обеих форм.
После switch старые колонки ещё нужны для rollback и для забытых потребителей. Удаляйте их минимум двумя отдельными изменениями: сначала код и fallback, затем схема. Для старых данных полезно иметь явный сигнал готовности, например отсутствие fallback за согласованное окно наблюдения и успешные проверки всех потребителей. Само по себе отсутствие ошибок в основном endpoint не является таким сигналом.
Индексы и ограничения требуют отдельного плана. PostgreSQL описывает CREATE INDEX CONCURRENTLY как способ не блокировать обычные вставки, обновления и удаления, но такая операция делает больше работы, ждёт другие транзакции и не выполняется внутри транзакционного блока. Она не превращает любой DDL в безопасный для production и не отменяет проверку диска, CPU, репликации и времени ожидания.
Если нужно добавить уникальность или NOT NULL, разделите проверку существующих данных и изменение контракта. Например, constraint можно сначала добавить как NOT VALID, затем проверить и валидировать отдельной операцией, если конкретный тип ограничения и версия PostgreSQL это поддерживают. Не копируйте этот приём для каждого ограничения: синтаксис, блокировки и поведение нужно сверить с документацией вашей версии.
concat_ws не заменяет это решение.Expand/switch/contract не гарантирует нулевую блокировку и не заменяет резервное копирование, репликацию и проверку восстановления. На результат влияют major-версия PostgreSQL, размер и партиционирование таблицы, индексы, триггеры, ORM, внешние readers, длительные транзакции, политика lock timeout и способ доставки релиза. Для MySQL, другой СУБД или другой major-версии PostgreSQL нельзя механически переносить lock behavior из этого примера.
К contract переходите только когда новая форма заполнена и сверена, каждый известный writer поддерживает её, readers больше не обращаются к старым полям, fallback не нужен по наблюдаемому сигналу, а команда понимает необратимые последствия удаления. Если один пункт нельзя доказать запросом, метрикой, логом или тестом, остановитесь на dual write и продолжите сбор сведений.
На практике полезно начать с маленькой таблицы или копии данных: выполнить expand, остановить backfill, вернуть старый binary и проверить чтение записи, созданной новым writer. Такой сценарий показывает реальный путь отката лучше, чем зелёный финальный smoke test.
NOT VALID.CONCURRENTLY.