Миграции БД без даунтайма: рабочие паттерны
«Просто накатим миграцию ночью» — в боевом сервисе так себе план, особенно если у тебя пользователи в нескольких часовых поясах. Я набил несколько шишек с миграциями на горячих таблицах и хочу собрать паттерны, которые позволяют деплоить без даунтайма. Большинство примеров будет на Postgres, но сами паттерны переносятся.
Главное правило
Прежде чем браться за миграцию, ответь себе на один вопрос: «Будет ли старый код работать после миграции, и будет ли новый код работать до миграции?». Если оба ответа да — мы говорим о backward-compatible миграции. Если хотя бы один нет — это «выкатим вместе с кодом», и значит, готовь стратегию.
На больших таблицах сложные изменения почти никогда не делаются одним шагом. Они разбиваются на 2–4 миграции, между которыми деплоятся версии кода. Это и называется expand/contract или blue-green schema migration.
Паттерн 1: добавление колонки
Простое добавление колонки в Postgres мгновенно — берётся ACCESS EXCLUSIVE на доли секунды и метаданные обновляются. Но есть нюансы:
- Если ты добавляешь NOT NULL без DEFAULT — Postgres падает, потому что для каждой строки нужно что-то записать.
- Если ты добавляешь NOT NULL DEFAULT <value>, Postgres 11+ умеет это без переписывания таблицы. Но если default не constant (например,
now()), переписывание будет.
Безопасный путь:
-- 1. Добавляем nullable колонку без default
ALTER TABLE users ADD COLUMN locale text;
-- 2. Бэкфилл данных батчами
UPDATE users SET locale = 'ru' WHERE locale IS NULL AND id BETWEEN 1 AND 100000;
-- ... повторить блоками
-- 3. Накатываем код, который пишет locale на любых новых записях
-- 4. Когда всё бэкфилнуто, ставим NOT NULL
ALTER TABLE users ALTER COLUMN locale SET NOT NULL;Бэкфилл крупными батчами с задержкой между батчами — обязателен. Один большой UPDATE на миллион строк будет держать lock и потенциально завалит реплики.
Паттерн 2: переименование колонки
Самый коварный сценарий. Кажется, что ALTER COLUMN RENAME — мгновенно. Но если у тебя одновременно работает старый код (читает старое имя) и новый (читает новое имя), один из них упадёт. Решается expand/contract:
- Expand. Добавляем новую колонку. Делаем триггер, который синхронизирует старую и новую при записи.
- Backfill. Копируем старые значения в новую колонку батчами.
- Migrate code. Деплоим код, который пишет в новую и читает из новой.
- Contract. Удаляем триггер и старую колонку.
-- step 1: новая колонка + sync trigger
ALTER TABLE users ADD COLUMN email_address text;
CREATE OR REPLACE FUNCTION sync_email() RETURNS trigger AS $$
BEGIN
IF NEW.email IS DISTINCT FROM OLD.email THEN
NEW.email_address = NEW.email;
END IF;
IF NEW.email_address IS DISTINCT FROM OLD.email_address THEN
NEW.email = NEW.email_address;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_email_sync
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_email();
-- step 2: backfill
UPDATE users SET email_address = email WHERE email_address IS NULL;
-- ...деплой кода, который пишет в email_address...
-- step 4 (через несколько релизов):
DROP TRIGGER users_email_sync ON users;
DROP FUNCTION sync_email();
ALTER TABLE users DROP COLUMN email;Это много работы ради переименования, поэтому чаще всего лучше не переименовывать вовсе. Если переименование диктует именно DBA (например, чистка нейминга) — обсуждай заранее, выделяй спринт.
Паттерн 3: смена типа
Изменение типа в Postgres иногда бесплатно (varchar → text), иногда полное переписывание таблицы (int → bigint). На больших таблицах второе — это локированная таблица на минуты или часы. Безопасно — через expand/contract:
- Добавляем новую колонку с нужным типом.
- Триггер синхронизирует.
- Бэкфилл.
- Меняем код на новую колонку.
- Удаляем старую и переименовываем (или живём с двумя именами).
Особенно тонкий случай — изменение PRIMARY KEY с serial на bigserial. У многих сервисов id таблиц упирается в 2.1 миллиарда позже, чем кажется. Лечить — целая отдельная статья. Если ловишь близко к лимиту, переключайся на bigserial заранее.
Паттерн 4: создание индекса
Базовая команда:
CREATE INDEX posts_author_idx ON posts(author_id);На большой таблице эта команда блокирует запись на всё время построения. Безопасный вариант:
CREATE INDEX CONCURRENTLY posts_author_idx ON posts(author_id);CONCURRENTLY строит индекс без эксклюзивного локa — приложение продолжает работать. Минусы: дольше; не работает внутри транзакции; если процесс прервётся, останется invalid-индекс, который надо удалить и попробовать снова.
-- проверка
SELECT relname, indisvalid FROM pg_class
JOIN pg_index ON indexrelid = oid
WHERE relname = 'posts_author_idx';
-- удаление неудачного
DROP INDEX CONCURRENTLY IF EXISTS posts_author_idx;Из-за невозможности использовать CONCURRENTLY внутри транзакции, миграционный фреймворк должен уметь это понимать. У drizzle-kit, knex и большинства серьёзных миграторов есть опции «не оборачивать в транзакцию» — обязательно используй для CREATE INDEX CONCURRENTLY.
Паттерн 5: удаление колонки
DROP COLUMN — операция мгновенная: Postgres помечает колонку как удалённую, физически данные остаются до vacuum full или перестройки. Но для приложения это означает, что любое чтение или запись по этой колонке упадёт.
Безопасно:
- Деплоим код, который не читает и не пишет в колонку.
- Ждём, пока этот код пройдёт по всем инстансам.
- Удаляем колонку миграцией.
«Подождать» — это серьёзный шаг. Если у тебя rolling deploy идёт час, а миграция накатилась за минуту до завершения — кусок инстансов будет писать в несуществующее поле. Поэтому либо делаем миграцию ПОСЛЕ полного деплоя, либо разносим по нескольким релизам.
Паттерн 6: добавление foreign key
Простой способ — ALTER TABLE ADD CONSTRAINT FOREIGN KEY .... Но он валидирует все существующие строки и держит лок. Безопасно:
-- 1. Добавляем без валидации
ALTER TABLE posts
ADD CONSTRAINT posts_author_fk FOREIGN KEY (author_id)
REFERENCES users(id) ON DELETE CASCADE
NOT VALID;
-- 2. Валидируем (медленно, но без эксклюзивного лока на запись)
ALTER TABLE posts VALIDATE CONSTRAINT posts_author_fk;NOT VALID применяется к новым и обновлённым строкам, но старые не проверяются. VALIDATE проверяет постфактум; на это время берётся SHARE UPDATE EXCLUSIVE, который не мешает обычным операциям записи.
Паттерн 7: длинные UPDATE и DELETE
Никаких UPDATE huge_table SET ... на миллионах строк одной командой. Делать порциями с задержкой:
DO $$
DECLARE
total integer := 0;
BEGIN
LOOP
UPDATE huge_table
SET status = 'archived'
WHERE id IN (
SELECT id FROM huge_table
WHERE status = 'old' AND created_at < '2025-01-01'
LIMIT 5000
);
GET DIAGNOSTICS total = ROW_COUNT;
EXIT WHEN total = 0;
PERFORM pg_sleep(0.5);
END LOOP;
END $$;5000 строк за раз с паузой — щадящий темп для большинства баз. Если база с активной репликой — снижай батч до 1000 и пауза побольше, чтобы не возникал большой replication lag.
Откат
Каждая миграция должна иметь рабочий откат. Не «теоретический», а реально протестированный на стейджинге. Я нередко вижу, что откатные SQL никто не запускал, и в момент инцидента выясняется, что они невалидны. Прогон up→down→up на dev — обязательный пункт code review.
Не все миграции откатываются полностью. Удаление колонки — нет: данные потеряны. В таких случаях откат документируется отдельно: «недоступен, нужен restore из бэкапа».
Темп деплоя и феррофайлы
Большие миграции лучше деплоить через feature flag в коде. Сценарий:
- Накатили expand-миграцию (новые таблицы/колонки).
- Задеплоили код за флагом — пишет в новую структуру, читает по старой.
- Постепенно переключаем флаг для процентов трафика.
- Когда флаг везде включён — задеплоили код, который читает только из новой.
- Накатили contract-миграцию.
Это медленно, но безопасно. На больших проектах ничего другого не работает — слишком велик риск.
Чек-лист перед миграцией на проде
- Бэкап БД свежий и проверенный (хотя бы что pg_restore -l не падает).
- Миграция backward-compatible со старым кодом или есть план в несколько шагов.
- Если есть длинный UPDATE/DELETE — разбит на батчи.
- Если есть CREATE INDEX — CONCURRENTLY, и фреймворк это поддерживает.
- Если есть FK — NOT VALID + VALIDATE отдельно.
- Down-миграция написана и проверена на стейджинге.
- Канареечное окно: накатили на staging, посмотрели на план запросов, нет регресса.
- Окно деплоя: не во время пиковой нагрузки.
- Алерты включены, кто-то наблюдает.
Этот список висит у меня на стенке отдельным плакатом. Каждая выкатка с миграцией — пробежка по нему. Лень не стоит дауна на проде, лучше потратить лишних 20 минут на проверку.
Главное, что я вынес из этих лет: миграции — это не «допиши SQL и накати». Это часть продуктовой архитектуры. Чем раньше ты приучишь команду думать в терминах expand/contract, тем меньше будет инцидентов в субботу ночью.