lenec ru

← все посты

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

11K

Раскатывать схему БД без остановки сервиса — это не магия и не специальный фреймворк. Это набор правил: сначала добавляем, потом начинаем читать и писать обратно совместимо, и только потом удаляем старое. Если соблюдаешь порядок — деплой идёт без даунтайма даже на боевой базе с миллионами строк.

Покажу паттерны, которые у меня прижились на рабочих проектах. Большая часть — про 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? Если да — что произойдёт с долгими транзакциями приложения?
  • Готов ли код одновременно работать со старой и новой схемой?
  • Есть ли у меня план отката?
  • Есть ли свежий бэкап?

Если на каждый пункт ответ «да» — катаю. Если хотя бы на один «не уверен» — переписываю миграцию.

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

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

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