SQLLab
Все статьи

Миграции базы данных: как менять схему без даунтайма

Zero-downtime миграции PostgreSQL: как добавить колонку, создать индекс CONCURRENTLY, переименовать таблицу без блокировок. Практические паттерны.

10 марта 2026 г.·5 мин чтения·

Изменение схемы базы данных в продакшне — одна из самых рискованных операций. Неправильно выполненная миграция может заблокировать таблицу на минуты, пока сервер отвечает пользователям ошибками или таймаутами. Разберём, как менять схему PostgreSQL безопасно, не останавливая приложение.

Почему миграции опасны: блокировки

PostgreSQL использует систему блокировок для обеспечения консистентности. Большинство DDL-операций требуют ACCESS EXCLUSIVE блокировку — самый строгий уровень, несовместимый с любыми другими операциями на таблице.

-- Эта операция заблокирует таблицу orders на всё время выполнения
ALTER TABLE orders ADD COLUMN notes text NOT NULL DEFAULT '';
-- На таблице с 10 млн строк — несколько минут простоя!

-- Проверить текущие блокировки
SELECT
    pid,
    relname,
    mode,
    granted,
    left(query, 80) AS query
FROM pg_locks
JOIN pg_class ON pg_locks.relation = pg_class.oid
JOIN pg_stat_activity USING (pid)
WHERE relname = 'orders';

CREATE INDEX CONCURRENTLY

Создание обычного индекса блокирует запись в таблицу. CONCURRENTLY строит индекс в фоне, не мешая работе:

-- ОПАСНО на prod: блокирует INSERT/UPDATE/DELETE
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- БЕЗОПАСНО: работает параллельно с приложением
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

-- Если сборка прервалась — остаётся невалидный индекс
-- Найти и удалить его:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY idx_orders_user_id;
-- Затем создать заново

Ограничение: CREATE INDEX CONCURRENTLY нельзя выполнить внутри транзакции. Большинство инструментов миграций (Flyway, Liquibase) по умолчанию оборачивают каждый скрипт в транзакцию — для CONCURRENTLY нужно отключить это поведение.

Добавление колонки: правильный подход

Добавление NOT NULL колонки с DEFAULT до PostgreSQL 11 требовало перезаписи всей таблицы. Начиная с PostgreSQL 11 — это мгновенная операция для константных значений по умолчанию.

-- PostgreSQL 11+: мгновенно (не перезаписывает строки)
ALTER TABLE orders ADD COLUMN is_gift boolean NOT NULL DEFAULT false;

-- Для PostgreSQL < 11 или вычисляемых DEFAULT — безопасный паттерн:

-- Шаг 1: добавить nullable колонку (мгновенно)
ALTER TABLE orders ADD COLUMN is_gift boolean;

-- Шаг 2: заполнить батчами, чтобы не держать длинную транзакцию
DO $$
DECLARE
    batch_size int := 10000;
    max_id bigint;
    cur_id bigint := 0;
BEGIN
    SELECT max(id) INTO max_id FROM orders;
    WHILE cur_id < max_id LOOP
        UPDATE orders
        SET is_gift = false
        WHERE id > cur_id AND id <= cur_id + batch_size
          AND is_gift IS NULL;
        cur_id := cur_id + batch_size;
        PERFORM pg_sleep(0.01);  -- небольшая пауза
    END LOOP;
END $$;

-- Шаг 3: добавить NOT NULL ограничение (мгновенно в Pg 12+)
ALTER TABLE orders ALTER COLUMN is_gift SET NOT NULL;

Переименование колонки: expand-contract паттерн

Прямое ALTER TABLE ... RENAME COLUMN требует обновления кода и базы одновременно. Безопасная альтернатива — expand/contract (расширение и сжатие):

-- ФАЗА 1: Expand (добавляем новую колонку)
ALTER TABLE users ADD COLUMN full_name text;

-- Синхронизируем данные через триггер
CREATE OR REPLACE FUNCTION sync_full_name() RETURNS trigger AS $$
BEGIN
    IF TG_OP = 'INSERT' OR NEW.name IS DISTINCT FROM OLD.name THEN
        NEW.full_name := NEW.name;
    END IF;
    IF TG_OP = 'INSERT' OR NEW.full_name IS DISTINCT FROM OLD.full_name THEN
        NEW.name := NEW.full_name;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_full_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_full_name();

-- Бэкфилл существующих данных
UPDATE users SET full_name = name WHERE full_name IS NULL;

-- ФАЗА 2: Деплоим новую версию приложения, которая читает full_name
-- (старая версия продолжает работать с name, триггер синхронизирует)

-- ФАЗА 3: Contract (убираем старую колонку после полного деплоя)
DROP TRIGGER trg_sync_full_name ON users;
ALTER TABLE users DROP COLUMN name;

ADD CONSTRAINT с отложенной валидацией

Добавление FOREIGN KEY или CHECK constraint блокирует таблицу на время валидации всех существующих строк.

-- МЕДЛЕННО: блокирует таблицу и проверяет все строки сразу
ALTER TABLE orders ADD CONSTRAINT fk_orders_users
    FOREIGN KEY (user_id) REFERENCES users(id);

-- БЫСТРО: сначала добавляем NOT VALID (только новые строки проверяются)
ALTER TABLE orders ADD CONSTRAINT fk_orders_users
    FOREIGN KEY (user_id) REFERENCES users(id)
    NOT VALID;

-- Затем валидируем в фоне (не блокирует запись)
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_users;
-- VALIDATE CONSTRAINT берёт ShareUpdateExclusiveLock — чтение и запись не блокируются

Flyway и Liquibase: организация миграций

Для управления миграциями в команде используйте специализированные инструменты:

-- Flyway: файл V20260401__add_is_gift_column.sql
-- Liquibase: changeSet в XML/YAML/SQL формате

-- Принципы безопасных миграций в любом инструменте:
-- 1. Каждая миграция — одно атомарное изменение
-- 2. CONCURRENTLY операции — вне транзакций (отдельный скрипт)
-- 3. Большие backfill — батчами, не в одной транзакции
-- 4. Тестируйте на копии production данных перед деплоем

Чеклист zero-downtime миграции

ОперацияБезопасноВместо
Добавить nullable колонкуALTER TABLE ADD COLUMN col type
Добавить NOT NULL с DEFAULTPostgreSQL 11+: мгновенноExpand/contract для < 11
Создать индексCREATE INDEX CONCURRENTLYCREATE INDEX
Удалить индексDROP INDEX CONCURRENTLYDROP INDEX
Добавить FKADD ... NOT VALID + VALIDATEСразу с валидацией
Переименовать колонкуExpand/contract + триггерRENAME COLUMN
Удалить колонкуПосле обновления кодаОдновременно с кодом

Итог

Zero-downtime миграции требуют дисциплины: разбивайте изменения на маленькие шаги, используйте CONCURRENTLY для индексов, NOT VALID для constraints и expand/contract для переименований. Всегда тестируйте миграции на реплике с production-объёмом данных — это единственный способ предсказать реальное время выполнения.


Хотите глубже разобраться в работе PostgreSQL? Практикуйтесь на реальных задачах в нашем тренажёре.

Похожие статьи

Попробуй на практике

Тренажёр с реальными задачами — бесплатно и без регистрации

Открыть тренажёр →