Алексей Меленчук
вся записка
дата
объём 6 мин чтения

Миграции без простоя: почему «просто добавить колонку» врёт

Схема и код меняются в разные моменты, и между ними всегда есть окно, где работают обе версии. Разбор того, что из этого следует.

Коротко. Во время выката старый и новый код работают одновременно. Любая миграция, которая ломает одну из двух версий, — это простой, даже если она выполнилась за миллисекунду.

«Добавить колонку — безопасная миграция». Утверждение верное ровно наполовину, и вторая половина стоит дорого.

Безопасность миграции — это не про то, сколько она блокирует таблицу. Это про то, что происходит в окне, когда старый и новый код работают одновременно.

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

Кто-то в этом окне работает не с той схемой, на которую рассчитан.

Три вредных случая

Добавили колонку NOT NULL без значения по умолчанию. Новый код её заполняет, старый — нет. Пока живёт хоть один старый экземпляр, его вставки падают. Приложение в этот момент отдаёт пятисотые, и это и есть простой, хотя миграция прошла мгновенно.

Переименовали колонку. Худший из безобидных на вид вариантов. ALTER TABLE ... RENAME COLUMN выполняется быстро и ломает всё, что написано на старое имя, в ту же секунду. Отката нет: обратное переименование ломает уже новый код.

Удалили колонку сразу после того, как код перестал её писать. Перестал писать — не значит перестал читать. Кэши, отчёты, аналитика, экспорт, чужой сервис с прямым доступом к реплике.

Расширить — переехать — сузить

Схема, которая решает это, известна и скучна. Скучность здесь — достоинство.

Расширение. Схема меняется так, чтобы работали обе версии кода. Новая колонка добавляется как NULL-совместимая, без обязательности. Новая таблица создаётся рядом. Ничего не удаляется и не переименовывается. На этом шаге старый код не должен заметить вообще ничего.

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

Этот шаг длится столько, сколько нужно. Дни — нормально. Спешка здесь не окупается.

Сужение. Только когда проверено, что старого кода не осталось, а данные заполнены: ограничение NOT NULL ставится, старая колонка удаляется, двойная запись убирается.

Три выката вместо одного. Это и есть цена, и её платят не всегда осознанно: обычно её платят один раз в виде инцидента.

Что ломается тише всего

Блокировки на больших таблицах. В PostgreSQL добавление колонки без значения по умолчанию давно не переписывает таблицу, и добавление со статическим значением тоже — с одиннадцатой версии. Но ALTER TABLE всё равно берёт ACCESS EXCLUSIVE на короткий момент, и если в этот момент идёт долгий запрос, миграция встаёт в очередь. А за ней встаёт всё остальное, потому что очередь блокировок не пропускает вперёд.

Отсюда практика, которая спасает: короткий lock_timeout на миграции. Лучше миграция, которая честно не смогла взять блокировку и откатилась, чем миграция, которая на три минуты остановила сервис.

Создание индекса. Обычный CREATE INDEX блокирует запись на всё время построения. CONCURRENTLY — не блокирует, но работает дольше, не может выполняться в транзакции и умеет упасть, оставив невалидный индекс, который надо заметить и удалить.

Внешний ключ. Добавление проверяет все существующие строки под блокировкой. На больших таблицах это делается в два шага: сначала ограничение как NOT VALID, потом VALIDATE CONSTRAINT отдельно, оно берёт более слабую блокировку.

Заполнение исторических строк

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

UPDATE orders SET currency = 'RUB' WHERE currency IS NULL на таблице в сорок миллионов строк — это одна транзакция, которая держит блокировки на всё время, раздувает журнал транзакций и мешает очистке старых версий строк. Она либо не закончится, либо закончится так, что лучше бы не начиналась.

Заполнение делается порциями, и у порции есть три свойства, каждое из которых обязательно:

  1. Ограничение по размеру. Несколько тысяч строк за проход, не по времени, а по количеству — время непредсказуемо.
  2. Пауза между порциями. Не для вежливости: пауза даёт очистке догнать созданные версии строк. Без неё таблица распухает быстрее, чем чистится.
  3. Возобновляемость. Процесс должен переживать перезапуск. Это значит: идти по ключу с курсором, а не по смещению, и хранить позицию.

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

Как убедиться, что старого кода не осталось

Между «переезд» и «сужение» стоит проверка, которую обычно делают на глаз: посмотрели, вроде все экземпляры новые, поехали.

Надёжнее сделать так, чтобы система ответила сама. Самый дешёвый способ — счётчик обращений к старому пути. Код, который читает из старой колонки, инкрементирует метрику. Через неделю смотрим: ноль — можно сужать, не ноль — ищем, кто ходит.

Это ловит не только забытые экземпляры приложения, но и то, о чём обычно не думают: отчёты из чужой системы, скрипты аналитиков, подключения к реплике напрямую. Про них узнают в момент удаления колонки, и узнают неприятно.

Про откат

Распространённое требование: каждая миграция должна иметь обратную.

Я считаю, что это требование даёт ложное чувство безопасности. Обратная миграция для добавления колонки — удаление колонки, то есть потеря данных, которые новый код успел записать. Формально откат есть. Практически применять его нельзя.

Что работает вместо: миграции только вперёд, и совместимость как обязательство. Если каждый шаг схемы совместим с предыдущей версией кода, откат кода не требует отката схемы. Это и есть настоящая обратимость — не в скрипте, а в свойстве.

Когда всё это лишнее. Внутренний сервис, который можно остановить на пять минут в субботу, миграции по расширению не требует. Останов проще, дешевле и понятнее. Схема нужна там, где остановки нет — и решение «нам можно останавливаться» стоит принять явно, а не выяснить это в момент, когда останавливаться уже нельзя.