Изменение схемы базы данных в продакшне — одна из самых рискованных операций. Неправильно выполненная миграция может заблокировать таблицу на минуты, пока сервер отвечает пользователям ошибками или таймаутами. Разберём, как менять схему 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 с DEFAULT | PostgreSQL 11+: мгновенно | Expand/contract для < 11 |
| Создать индекс | CREATE INDEX CONCURRENTLY | CREATE INDEX |
| Удалить индекс | DROP INDEX CONCURRENTLY | DROP INDEX |
| Добавить FK | ADD ... NOT VALID + VALIDATE | Сразу с валидацией |
| Переименовать колонку | Expand/contract + триггер | RENAME COLUMN |
| Удалить колонку | После обновления кода | Одновременно с кодом |
Итог
Zero-downtime миграции требуют дисциплины: разбивайте изменения на маленькие шаги, используйте CONCURRENTLY для индексов, NOT VALID для constraints и expand/contract для переименований. Всегда тестируйте миграции на реплике с production-объёмом данных — это единственный способ предсказать реальное время выполнения.
Хотите глубже разобраться в работе PostgreSQL? Практикуйтесь на реальных задачах в нашем тренажёре.