lenec ru

← все посты

Миграции БД без даунтайма: рабочие паттерны

13K

«Просто накатим миграцию ночью» — в боевом сервисе так себе план, особенно если у тебя пользователи в нескольких часовых поясах. Я набил несколько шишек с миграциями на горячих таблицах и хочу собрать паттерны, которые позволяют деплоить без даунтайма. Большинство примеров будет на 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:

  1. Expand. Добавляем новую колонку. Делаем триггер, который синхронизирует старую и новую при записи.
  2. Backfill. Копируем старые значения в новую колонку батчами.
  3. Migrate code. Деплоим код, который пишет в новую и читает из новой.
  4. 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:

  1. Добавляем новую колонку с нужным типом.
  2. Триггер синхронизирует.
  3. Бэкфилл.
  4. Меняем код на новую колонку.
  5. Удаляем старую и переименовываем (или живём с двумя именами).

Особенно тонкий случай — изменение 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 или перестройки. Но для приложения это означает, что любое чтение или запись по этой колонке упадёт.

Безопасно:

  1. Деплоим код, который не читает и не пишет в колонку.
  2. Ждём, пока этот код пройдёт по всем инстансам.
  3. Удаляем колонку миграцией.

«Подождать» — это серьёзный шаг. Если у тебя 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 в коде. Сценарий:

  1. Накатили expand-миграцию (новые таблицы/колонки).
  2. Задеплоили код за флагом — пишет в новую структуру, читает по старой.
  3. Постепенно переключаем флаг для процентов трафика.
  4. Когда флаг везде включён — задеплоили код, который читает только из новой.
  5. Накатили contract-миграцию.

Это медленно, но безопасно. На больших проектах ничего другого не работает — слишком велик риск.

Чек-лист перед миграцией на проде

  • Бэкап БД свежий и проверенный (хотя бы что pg_restore -l не падает).
  • Миграция backward-compatible со старым кодом или есть план в несколько шагов.
  • Если есть длинный UPDATE/DELETE — разбит на батчи.
  • Если есть CREATE INDEX — CONCURRENTLY, и фреймворк это поддерживает.
  • Если есть FK — NOT VALID + VALIDATE отдельно.
  • Down-миграция написана и проверена на стейджинге.
  • Канареечное окно: накатили на staging, посмотрели на план запросов, нет регресса.
  • Окно деплоя: не во время пиковой нагрузки.
  • Алерты включены, кто-то наблюдает.

Этот список висит у меня на стенке отдельным плакатом. Каждая выкатка с миграцией — пробежка по нему. Лень не стоит дауна на проде, лучше потратить лишних 20 минут на проверку.

Главное, что я вынес из этих лет: миграции — это не «допиши SQL и накати». Это часть продуктовой архитектуры. Чем раньше ты приучишь команду думать в терминах expand/contract, тем меньше будет инцидентов в субботу ночью.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.