Миграции БД без даунтайма: рабочие паттерны
Раскатывать схему БД без остановки сервиса — это не магия и не специальный фреймворк. Это набор правил: сначала добавляем, потом начинаем читать и писать обратно совместимо, и только потом удаляем старое. Если соблюдаешь порядок — деплой идёт без даунтайма даже на боевой базе с миллионами строк.
Покажу паттерны, которые у меня прижились на рабочих проектах. Большая часть — про Postgres, но идея универсальна.
Главное правило: схема и код движутся разными релизами
Соблазн — за один релиз и колонку добавить, и код переключить, и старое выкинуть. На бою это превращается в окно даунтайма: пока миграция идёт, старая версия кода уже не работает, а новая ещё не выкачена. На небольшой таблице это секунды, на большой — минуты с заблокированными запросами.
Я делю каждое изменение схемы на три фазы:
- Expand — добавили в схему то, что нужно для новой логики, никого не сломав.
- Migrate — переключили код на новую структуру, дописали данные.
- Contract — удалили то, что больше не нужно.
Каждая фаза — отдельный деплой. Между фазами проходит хотя бы день, чтобы откатиться, если что-то полыхнуло.
Добавление новой колонки
Простой случай — nullable без дефолта
В Postgres >= 11 добавление nullable-колонки — мгновенная операция. Лок берётся, но он не переписывает таблицу.
ALTER TABLE users ADD COLUMN locale text;Это можно делать прямо на бою.
Колонка с дефолтом
В Postgres 11+ ADD COLUMN ... DEFAULT тоже не переписывает таблицу — дефолт сохраняется как метаданные. До 11 версии это была боль с переписыванием всех строк под write-лок. На современных версиях — одна команда, без сюрпризов.
ALTER TABLE users ADD COLUMN locale text DEFAULT 'ru';NOT NULL с дефолтом
Прямой ADD COLUMN ... NOT NULL DEFAULT 'ru' в Postgres 11+ работает без переписывания. Но если хочется быть осторожным или работаешь со старой версией — делаю в три шага:
-- Релиз 1: добавили nullable
ALTER TABLE users ADD COLUMN locale text;
-- Backfill пакетами
UPDATE users SET locale = 'ru' WHERE locale IS NULL AND id BETWEEN 1 AND 10000;
-- ... батчами по 10k
-- Релиз 2: накладываем NOT NULL
ALTER TABLE users ALTER COLUMN locale SET NOT NULL;Между шагами — проверка, что код корректно пишет в обе схемы.
Удаление колонки
Поспешишь — узнаешь о несостыковке прямо в проде. Я делаю так:
- Релиз 1: код перестаёт читать и писать в колонку, но колонка остаётся.
- Релиз 2 (через несколько дней):
ALTER TABLE users DROP COLUMN old_field;
Между релизами я смотрю на логи и метрики: точно ли в код больше никто не лезет в это поле. На монолите это легко, на распределённом сервисе — особенно важно убедиться, что обновились все консьюмеры.
Переименование колонки
Это любимая ловушка. ALTER TABLE ... RENAME COLUMN — атомарная операция, но она моментально ломает старую версию кода. Если деплой идёт частями, между двумя версиями возникает окно несовместимости.
Я не переименовываю напрямую. Я делаю expand/contract на уровне колонок:
-- Expand: добавили новую
ALTER TABLE users ADD COLUMN display_name text;
UPDATE users SET display_name = full_name WHERE display_name IS NULL;
-- Триггер на синхронизацию (на время переходного периода)
CREATE OR REPLACE FUNCTION sync_user_name() RETURNS trigger AS $$
BEGIN
NEW.display_name = COALESCE(NEW.display_name, NEW.full_name);
NEW.full_name = COALESCE(NEW.display_name, NEW.full_name);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_name_sync
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();
-- Migrate: код пишет в display_name
-- Contract: убираем триггер и старую колонку
DROP TRIGGER users_name_sync ON users;
DROP FUNCTION sync_user_name();
ALTER TABLE users DROP COLUMN full_name;Чуть длиннее, зато никакого даунтайма и никаких выпадающих запросов.
Создание индекса
Это самая частая причина непредсказуемого даунтайма у новичков. Обычный CREATE INDEX блокирует таблицу от записи на всё время постройки. На таблице на несколько миллионов строк это десятки секунд минимум.
Решение — CONCURRENTLY:
CREATE INDEX CONCURRENTLY users_email_idx ON users (email);Особенности:
- не запускается внутри транзакции;
- может оставить «битый» индекс, если упал на середине — проверяй
indisvalidвpg_index, при необходимости дропни и пересоздай; - некоторые миграционные тулы (Drizzle Kit, например) специально не оборачивают команду в транзакцию, если видят
CONCURRENTLY.
Изменение типа колонки
Прямой ALTER TABLE ... ALTER COLUMN ... TYPE ... в Postgres часто переписывает всю таблицу под exclusive-локом. На большой базе — даунтайм.
Я делаю через теневую колонку:
-- Expand
ALTER TABLE orders ADD COLUMN amount_minor bigint;
-- Backfill пакетами
UPDATE orders SET amount_minor = (amount * 100)::bigint WHERE amount_minor IS NULL AND id >= 1 AND id < 100000;
-- В коде временно пишем оба поля
-- Когда всё бэкфилнули и код переехал, дропаем старое
ALTER TABLE orders DROP COLUMN amount;
ALTER TABLE orders RENAME COLUMN amount_minor TO amount;Альтернатива для типов, которые Postgres конвертирует на месте без переписывания (например, varchar(50) -> varchar(100)) — можно ALTER TYPE делать сразу. Главное — заранее проверить.
Удаление таблицы
То же самое: сначала код перестаёт ходить в неё, потом — удаление. На всякий случай — переименование вместо drop:
ALTER TABLE legacy_logs RENAME TO legacy_logs_to_drop_2026_06_01;Так у меня есть пара недель «безопасной паузы», когда я ещё могу вернуть таблицу обратно одним RENAME. И только если за две недели никто не пожаловался — DROP TABLE.
Работа с длинными миграциями данных
Backfill миллиона строк одним UPDATE — плохая идея. Долгая транзакция держит локи, мешает VACUUM и наращивает раздутие таблицы.
Я разбиваю на пакеты:
DO $$
DECLARE
rows_updated int;
BEGIN
LOOP
UPDATE users
SET locale = 'ru'
WHERE locale IS NULL
AND id IN (
SELECT id FROM users WHERE locale IS NULL LIMIT 5000
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
PERFORM pg_sleep(0.1);
END LOOP;
END;
$$;В реальной жизни такие штуки я обычно гоняю не миграцией, а отдельным CLI-скриптом, который умеет ставить чек-пойнты, чтобы можно было дропнуть и продолжить.
Чек-лист перед миграцией на проде
- Будет ли это переписывать таблицу? Если да — батчевать.
- Возьмёт ли это AccessExclusiveLock? Если да — что произойдёт с долгими транзакциями приложения?
- Готов ли код одновременно работать со старой и новой схемой?
- Есть ли у меня план отката?
- Есть ли свежий бэкап?
Если на каждый пункт ответ «да» — катаю. Если хотя бы на один «не уверен» — переписываю миграцию.