open atlas
↑ К треку
CI/CD-пайплайны CICD · 04 · 04

Безопасные миграции схемы и откат

Код откатывается за секунды; база — нет. Деструктивная миграция, уехавшая вместе с деплоем, делает откат невозможным. Разбей любое изменение схемы на expand/contract по релизам, чтобы старый и новый код оба работали: аддитивное до деплоя, деструктивное после.

CICD Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Деплой, который положил прод, выглядел чистым. Один релиз привёз код, переставший использовать users.legacy_name, и в той же миграции — ALTER TABLE users DROP COLUMN legacy_name. Он стал зелёным. Через три часа латентный баг в несвязанной части того релиза вынудил откат — дежурный нажал кнопку, спасавшую нас десяток раз, переразвернув предыдущий артефакт. Каждый запрос тут же отдал 500. Старый артефакт всё ещё выполнял SELECT ... legacy_name FROM users, а колонки уже не было. Хуже того, накат вперёд тоже не вернул бы её: данные в legacy_name были дропнуты, а не заархивированы. Кнопка отката — вся наша история про безопасность — не сделала ничего, потому что единственное, что она не может переразвернуть, — это база. Этот урок о том, почему так происходит, и о дисциплине, которая держит откат настоящим: expand/contract.

Почему миграция ломает откат

Откат работает на коде, потому что деплой — это просто подмена одного неизменяемого артефакта предыдущим: старый образ контейнера всё ещё существует, ты возвращаешь на него трафик, готово за секунды. У базы нет предыдущего артефакта. Это одна изменяемая stateful-сущность, и миграция мутирует её на месте. Как только DROP COLUMN legacy_name выполнился, «вернуться назад» не имеет смысла: колонка и её данные ушли, и даже если ты воссоздашь колонку, строки будут пустыми. Деплой и изменение схемы связаны в релизе, но обратима только одна половина этой пары. Поэтому «просто откати деплой» молча предполагает, что старый код всё ещё может прочитать схему, которую найдёт, — а деструктивное изменение, уехавшее с новым кодом, ломает ровно это предположение.

Это острее, чем кажется, из-за того, как раскатываются современные релизы. Rolling-, canary- или blue-green-деплой (разобранный в прошлом уроке) гоняет две версии твоего кода одновременно против одной базы. Есть окно — иногда минуты, иногда весь прогрев канарейки, — где vN-1 и vN обе обслуживают живой трафик. Если миграция vN изменила схему так, что vN-1 этого не переносит, ты сломал ещё работающую старую версию до того, как вообще нажал откат. Схема обязана удовлетворять каждую живую версию одновременно.

Expand/contract: никогда не меняй колонку за один шаг

Дисциплина, которая делает это безопасным, — параллельное изменение, обычно называемое expand/contract (расширение/сужение: сначала аддитивно расширяй схему, потом деструктивно убирай старое). Ты никогда не мутируешь колонку деструктивно в одной миграции. Ты разбиваешь изменение по релизам так, чтобы в каждый отдельный момент любая работающая версия кода могла читать и писать схему такой, какая она сейчас.

-- РЕЛИЗ 1, шаг EXPAND: только аддитивно, выполняется ДО деплоя нового кода.
-- Старый код игнорирует новую колонку; новый код может начать её использовать. Обратно совместимо.
ALTER TABLE users ADD COLUMN full_name text;        -- nullable, без переписывания
CREATE INDEX CONCURRENTLY idx_users_full_name ON users (full_name);
// РЕЛИЗ 1, код приложения: DUAL-WRITE — пиши и старое, и новое на каждой мутации,
// и читай пока из старой колонки. Бэкфилл существующих строк батчами, не одним UPDATE.
async function setName(id: string, name: string) {
  await db.query(
    `UPDATE users SET legacy_name = $2, full_name = $2 WHERE id = $1`,
    [id, name],
  );
}
// задача бэкфилла: копируй существующие данные ограниченными батчами (например, 1k–10k строк за цикл)
//   UPDATE users SET full_name = legacy_name
//   WHERE full_name IS NULL AND id IN (<следующий батч>);   -- повторяй, пока не кончатся
-- РЕЛИЗ 2: переключи ЧТЕНИЯ на full_name (изменение только кода, без изменения схемы).
-- РЕЛИЗ 3, шаг CONTRACT: дропни старую колонку — ТОЛЬКО после того, как R2 полностью
-- раскатан и проверен, когда legacy_name больше никто не читает. Это единственный деструктивный шаг.
ALTER TABLE users DROP COLUMN legacy_name;

Переименование — каноничный случай, и это не один RENAME COLUMN — это добавь новую колонку + бэкфилл + dual-write + переключи чтения + дропни старую, растянутое на три или более релизов. Ни в один момент работающая версия не встречает схему, которую не умеет обработать, поэтому предыдущий артефакт всегда всё ещё запускается, и откат остаётся настоящим на всём пути.

Порядок: аддитивное до деплоя, деструктивное после

Expand/contract диктует, когда каждая миграция выполняется относительно деплоя, и ошибка тут заново порождает исходный инцидент. Аддитивная миграция (expand) обязана выполниться до деплоя нового кода — колонка должна существовать в тот миг, как новый код стартует, иначе новый код падает на отсутствующей колонке. Деструктивная миграция (contract) обязана выполниться после того, как новый код полностью раскатан и ты уверен, что откатываться не будешь, — к этому моменту старую колонку никто не читает. Запуск деструктивного изменения вместе с деплоем — ровно то, что делает откат невозможным, потому что в момент, когда ты откатываешь деплой, старый код встречает схему, которую оставил после себя новый код.

Почему это работает

Почему нельзя просто положить миграцию и её down-откат в один релиз и довериться down-скрипту, что он всё отменит? Потому что обратимость на бумаге — это не обратимость данных и не обратимость живого парка смешанных версий. down, который «восстанавливает» дропнутую колонку, может воссоздать пустую колонку, но не данные, которые уже удалены, — этих данных нет. А в rolling- или canary-деплое старый и новый код работают одновременно против одной базы, так что деструктивный up ломает ещё работающую старую версию немедленно, до того как down вообще сможет сработать. down-скрипт защищает тебя от провалившейся миграции; он не делает ничего против успешной миграции, чью схему предыдущий код не может прочитать. Только аддитивная последовательность expand/contract — где каждая версия работает со схемой на каждом шаге — реально держит откат безопасным.

Online DDL: сама миграция может положить таблицу

Ты написал правильную expand/contract-последовательность — и тут твой CREATE INDEX на таблице в 50 миллионов строк блокирует все записи на три минуты в утренний пик. Последовательность была верна; механика — нет.

Даже аддитивная миграция может вызвать простой, если DDL блокирует таблицу. В Postgres ADD COLUMN с неволатильным дефолтом быстрый и безопасный, но обычный CREATE INDEX берёт блокировку, останавливающую записи на всё время построения, — используй CREATE INDEX CONCURRENTLY, чтобы записи продолжали течь. Долгие ALTER берут блокировку ACCESS EXCLUSIVE и за очередью активных запросов могут застопорить всю таблицу под нагрузкой. А бэкфилл обязан быть батчевым — один гигантский UPDATE users SET full_name = legacy_name переписывает каждую строку в одной транзакции, держа блокировки, раздувая таблицу и работая достаточно долго, чтобы заблокировать autovacuum. Гоняй его ограниченными батчами в несколько тысяч строк, коммитя между ними.

Накат-вперёд против отката

Поскольку база часто не может вернуться назад, сеньорский дефолт для плохого релиза, привёзшего изменение схемы, — нередко накат-вперёд (отгрузить исправленную версию вперёд), а не откат. Но это вариант, который ты держишь открытым только тем, что с самого начала делаешь каждую миграцию обратно совместимой: если каждое изменение схемы — expand/contract, предыдущий артефакт всё ещё запускается против новой схемы, так что откат тоже остаётся настоящим выбором, а не кнопкой, кладущей прод в 500. Обратно совместимые миграции не заменяют откат накатом-вперёд; они делают оба безопасными, чтобы ты выбирал по инциденту.

Выбери лучший вариант

Тебе нужно переименовать активно используемую колонку с нулевым простоем и возможностью откатить деплой в любой момент. Какой подход?

Викторина

Релиз привёз `DROP COLUMN legacy_name` вместе с кодом, переставшим её читать. Баг вынуждает откат к предыдущему артефакту. Почему код может откатиться, а деструктивная миграция — нет?

Викторина

Тебе нужно безопасно переименовать колонку по релизам через expand/contract. Какова последовательность и каков порядок аддитивной против деструктивной миграции относительно деплоев?

Вспомните перед уходом
  1. 01
    Объясни, почему деструктивная миграция ломает откат, особенно при rolling/canary/blue-green деплое, и пройди последовательность expand/contract и порядок миграций, держащий откат безопасным.
  2. 02
    Почему down-скрипта недостаточно, какие опасности online DDL кусают даже аддитивные миграции и когда ты делаешь накат-вперёд вместо отката?
Итог

Код откатывается за секунды, потому что деплой просто меняет один неизменяемый артефакт на предыдущий; у базы нет предыдущего артефакта, так что миграцию, которая уже выполнилась, нельзя авто-отменить. Деструктивное изменение — DROP COLUMN, RENAME, смена типа — уехавшее вместе с деплоем, и есть то, что делает откат невозможным: как только колонка ушла, старый артефакт всё ещё делает SELECT её и отдаёт 500, а данные восстановить нельзя. Это острее при rolling-, canary- или blue-green-деплое, где две версии кода работают разом против одной базы, так что деструктивное изменение ломает ещё работающую старую версию до того, как ты нажал откат. Дисциплина — expand/contract (параллельное изменение): никогда не мутируй колонку за один шаг. EXPAND добавляет новую структуру аддитивно (nullable-колонка, CREATE INDEX CONCURRENTLY) и выполняется ДО деплоя нового кода; приложение затем делает dual-write старого и нового и бэкфиллит существующие строки ограниченными батчами, не одним гигантским UPDATE; более поздний релиз переключает чтения; а CONTRACT дропает старую колонку только ПОСЛЕ того, как новый код полностью раскатан и проверен. Поэтому переименование — 3+ релизов, никогда не один RENAME, а порядок — аддитивное-до-деплоя, деструктивное-после-раскатки. down-скрипта недостаточно — он может воссоздать пустую колонку, но не удалённые данные, а деструктивный up ломает живую старую версию немедленно. Опасности online DDL кусают даже аддитивные миграции: CREATE INDEX CONCURRENTLY, чтобы избежать блокировок записи, следи за блокировками ACCESS EXCLUSIVE на долгих ALTER, батчи бэкфиллов. И поскольку база часто не может вернуться, сеньорский дефолт для плохого релиза, несущего схему, — часто накат-вперёд, но ты держишь и откат, и накат-вперёд безопасными, только делая каждую миграцию обратно совместимой с самого начала. Теперь, когда увидишь PR, где миграция дропает колонку в том же коммите, что код перестаёт её читать, — ты будешь знать: кнопка отката в этом релизе сломана ещё до того, как кто-то её нажмёт.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.