{"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 удаляет совместимость.

Временная линия миграции: expand добавляет новую форму, dual write заполняет обе, switch переводит чтение, contract удаляет старую
Удаление старой формы — последняя точка, а не часть первого выката.

Пример: составное имя становится одной колонкой

Пусть в таблице users уже есть nullable-колонки first_name и last_name. Новому экрану нужен display_name. Правило форматирования в примере учебное: соединить непустые части одним пробелом. В реальном домене нужно отдельно решить порядок имени, локаль, пробелы, пустые строки и допустимое отсутствие значения.

Что разрешено на каждой фазе
ФазаReaderWriterДействиеВыход из фазы
ExpandСтарая формаСтарая формаДобавить nullable display_name без требования для старого кодаСтарый binary читает и пишет как прежде
Dual writeНовая форма с fallbackОбе формы в одной операцииОбновлять колонку при каждой записи и заполнить старые строкиПроверка расхождений и готовности readers
SwitchНовая форма, fallback виденОбе формыПеревести основной путь чтения и наблюдать ошибкиНи один потребитель не использует fallback
ContractНовая формаНовая формаУбрать fallback, затем удалить старые колонки отдельными шагамиЕсть подтверждённый план отката или принято решение жить без него

Fallback в этой таблице — не молчаливый костыль. Он должен быть измеримым: например, код увеличивает счётчик чтений старой формы и пишет идентификатор потребителя. Если fallback не виден, команда не может доказать, что contract безопасен.

Expand: сначала меняем схему

Для 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, изменение типа и удаление старых полей в ту же операцию. У этих действий другая стоимость и другой риск. Сначала отдельно подтвердите, что схема появилась, старые запросы всё ещё проходят, а повторный запуск миграции обрабатывается вашей системой миграций.

Dual write и backfill: две формы, одно правило

Новый 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: переводим чтение после проверки данных

Перед 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: откат чтения не должен остановить поддержание обеих форм.

Contract: удаляем только доказанно ненужное

После switch старые колонки ещё нужны для rollback и для забытых потребителей. Удаляйте их минимум двумя отдельными изменениями: сначала код и fallback, затем схема. Для старых данных полезно иметь явный сигнал готовности, например отсутствие fallback за согласованное окно наблюдения и успешные проверки всех потребителей. Само по себе отсутствие ошибок в основном endpoint не является таким сигналом.

Индексы и ограничения требуют отдельного плана. PostgreSQL описывает CREATE INDEX CONCURRENTLY как способ не блокировать обычные вставки, обновления и удаления, но такая операция делает больше работы, ждёт другие транзакции и не выполняется внутри транзакционного блока. Она не превращает любой DDL в безопасный для production и не отменяет проверку диска, CPU, репликации и времени ожидания.

Если нужно добавить уникальность или NOT NULL, разделите проверку существующих данных и изменение контракта. Например, constraint можно сначала добавить как NOT VALID, затем проверить и валидировать отдельной операцией, если конкретный тип ограничения и версия PostgreSQL это поддерживают. Не копируйте этот приём для каждого ограничения: синтаксис, блокировки и поведение нужно сверить с документацией вашей версии.

Пошаговый runbook

  1. Составьте список readers и writers, включая фоновые задачи и внешние SQL-доступы. Зафиксируйте старую форму, новую форму, владельца каждого потребителя и совместимый путь чтения.
  2. Опишите доменное преобразование. Для имени зафиксируйте правила пустых значений, пробелов, локали, длины и редких случаев. Учебный concat_ws не заменяет это решение.
  3. Проверьте expand на копии production-данных или на среде с сопоставимым объёмом. Запишите lock wait, план остановки, лимит ожидания и способ повторного запуска.
  4. Выпустите только расширение схемы. Проверка выхода: старый binary читает и пишет старые поля, новая колонка не обязательна, миграция повторяется безопасно.
  5. Включите dual write и fallback. Проверьте атомарность записи, повторную попытку и метрику fallback. Не запускайте backfill, пока writer не защищает новые изменения.
  6. Запускайте backfill ограниченными идемпотентными пачками. После каждой пачки сохраняйте диапазон, количество обновлений и ошибку; при остановке продолжайте с последнего подтверждённого диапазона.
  7. Сверьте значения и конфликтные строки. Ненулевые расхождения — причина остановить switch, а не повод подобрать фильтр, который скроет проблему.
  8. Переведите чтение на новую форму, оставив fallback. Наблюдайте ошибки, задержку, реплики и долю fallback; при деградации верните только чтение, сохранив dual write.
  9. После подтверждённого ухода старых потребителей уберите fallback и старые поля отдельным выпуском. Перед удалением проверьте, что план rollback описывает уже созданные новые записи, а не только возврат версии приложения.

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

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.

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

"}