
Рост продукта почти всегда тянет за собой изменения схемы базы: новые поля, индексы, переработка типов, разнос таблиц. Обычная «быстрая» миграция может повиснуть на блокировке и остановить запись — за 2–10 минут вы теряете заказы, портите конверсию и репутацию. Ночные окна тоже бьют по скорости выпуска: команда замедляется, фичи застревают, растёт риск человеческой ошибки.
Миграции без простоя позволяют:
Expand/contract — это двухэтапная тактика изменения схемы:
Ключевой принцип совместимости: сначала выкатываем изменения, которые никого не ломают; затем переводим чтение/запись на новую схему; только потом — чистка. Часто это 2–4 релиза вместо одного «рубильника», зато без простоя.
Важно понимать, какие DDL-операции берут тяжёлые блокировки и могут остановить запросы.
-- Создание индекса без блокировки записи
CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders (created_at);
Это не запускается внутри транзакции. Инструмент миграций должен уметь делать такие шаги вне общего BEGIN/COMMIT.
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_created_at;
SELECT * FROM pg_stat_progress_create_index;
ALTER TABLE users ADD COLUMN phone_hash text;
Значение по умолчанию: в PostgreSQL 11+ добавление DEFAULT-константы не переписывает всю таблицу (метаданные). Но будьте внимательны с функциями в DEFAULT — они могут вычисляться на записи.
NOT NULL — потенциально опасно (проверка всей таблицы). Без простоя используйте «проверку без валидации», бэкфилл и последующую валидацию:
-- 1) Добавляем CHECK без проверки прошлых строк
ALTER TABLE users ADD CONSTRAINT users_phone_hash_nn CHECK (phone_hash IS NOT NULL) NOT VALID;
-- 2) Бэкфилл (ниже — как делать порциями)
-- 3) Валидируем ограничение: блокирует только DDL, не мешает обычным INSERT/UPDATE
ALTER TABLE users VALIDATE CONSTRAINT users_phone_hash_nn;
После валидации можно оставить CHECK вместо NOT NULL, чтобы избежать тяжёлой блокировки SET NOT NULL. Для планировщика разницы почти нет.
Добавляйте внешние ключи «без валидации», затем валидируйте:
ALTER TABLE payments
ADD CONSTRAINT payments_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
ALTER TABLE payments
VALIDATE CONSTRAINT payments_user_id_fkey;
Так вы не остановите запись в обе таблицы.
Удаление берёт «тяжёлую» блокировку, но обычно на очень короткое время. Запускайте в период меньшей нагрузки и только после того, как код гарантированно не обращается к объекту:
ALTER TABLE users DROP COLUMN IF EXISTS phone;
Задача: было users.phone (text), хотим users.phone_e164 (text), затем избавиться от старого поля. Нужна миграция без простоя.
-- Рекомендуется ограничить время ожидания блокировок этой сессии
SET lock_timeout = '3s';
SET statement_timeout = '10m';
-- 1. Добавляем новую колонку
ALTER TABLE users ADD COLUMN phone_e164 text;
-- 2. Индекс (если нужен для поиска)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_phone_e164 ON users (phone_e164);
-- 3. Функция-триггер: поддерживать обе колонки согласованными
CREATE OR REPLACE FUNCTION users_sync_phone_cols()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
-- Если пришло только старое поле — копируем в новое
IF NEW.phone_e164 IS NULL AND NEW.phone IS NOT NULL THEN
NEW.phone_e164 := NEW.phone; -- здесь предполагаем, что валидация/нормализация будет на уровне приложения
END IF;
-- Если пришло только новое — дублируем в старое (для обратной совместимости)
IF NEW.phone IS NULL AND NEW.phone_e164 IS NOT NULL THEN
NEW.phone := NEW.phone_e164;
END IF;
RETURN NEW;
END
$$;
DROP TRIGGER IF EXISTS trg_users_sync_phone_cols ON users;
CREATE TRIGGER trg_users_sync_phone_cols
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION users_sync_phone_cols();
Теперь любые записи/обновления будут держать обе колонки в актуальном состоянии.
Гонять UPDATE всей таблицы за раз — плохая идея: рост нагрузки, блокировки, блоут. Лучше идти батчами по первичному ключу.
Пример бэкфилла порциями (по 1000 строк) через анонимный блок:
DO $$
DECLARE
batch_size integer := 1000;
updated_rows integer := 0;
BEGIN
LOOP
WITH cte AS (
SELECT id FROM users
WHERE phone_e164 IS NULL AND phone IS NOT NULL
ORDER BY id
LIMIT batch_size
)
UPDATE users u
SET phone_e164 = u.phone
FROM cte
WHERE u.id = cte.id
RETURNING 1 INTO updated_rows;
IF updated_rows = 0 THEN
EXIT; -- всё готово
END IF;
PERFORM pg_sleep(0.05); -- короткая пауза, чтобы не душить прод
END LOOP;
END $$;
Проверка результата:
SELECT count(*) AS remaining
FROM users
WHERE phone_e164 IS NULL AND phone IS NOT NULL;
На этом этапе приложение должно читать из новой колонки. В запись продолжаем поддерживать обе для обратной совместимости — на случай если где-то ещё остался старый путь.
Если двойная запись реализована триггером, в приложении достаточно перейти на чтение из phone_e164. Если вы предпочитаете контроль на уровне кода, пример на SQL-процедуре уже решает большинство кейсов без изменения бэкенда.
Когда код в проде везде читает из phone_e164, а бэкфилл завершён:
-- (опционально) делаем поле обязательным безопасным способом
ALTER TABLE users
ADD CONSTRAINT users_phone_e164_nn CHECK (phone_e164 IS NOT NULL) NOT VALID;
ALTER TABLE users
VALIDATE CONSTRAINT users_phone_e164_nn;
-- Удаляем старые артефакты
DROP INDEX CONCURRENTLY IF EXISTS idx_users_phone;
ALTER TABLE users DROP COLUMN IF EXISTS phone;
DROP TRIGGER IF EXISTS trg_users_sync_phone_cols ON users;
DROP FUNCTION IF EXISTS users_sync_phone_cols();
Готово: схема приведена к нужному виду, без остановки записи.
Две системные настройки помогают «быстро отвалиться», если кто-то держит конфликтную блокировку, и не подвесить прод:
-- В начале каждой миграции-сессии
SET lock_timeout = '3s'; -- ждать блокировку не дольше 3 секунд
SET statement_timeout = '10m'; -- не выполнять один шаг миграции дольше 10 минут
Следить за блокировками на лету:
SELECT a.pid, a.usename, a.query, a.state, a.wait_event_type, a.wait_event,
now() - a.query_start AS running_for
FROM pg_stat_activity a
WHERE a.state <> 'idle'
ORDER BY a.query_start;
И кто кого блокирует:
SELECT bl.pid AS blocked_pid, ka.query AS blocking_query, now() - ka.query_start AS blocking_for
FROM pg_locks bl
JOIN pg_locks kl ON bl.locktype = kl.locktype
AND bl.database IS NOT DISTINCT FROM kl.database
AND bl.relation IS NOT DISTINCT FROM kl.relation
AND bl.page IS NOT DISTINCT FROM kl.page
AND bl.tuple IS NOT DISTINCT FROM kl.tuple
AND bl.virtualxid IS NOT DISTINCT FROM kl.virtualxid
AND bl.transactionid IS NOT DISTINCT FROM kl.transactionid
AND bl.classid IS NOT DISTINCT FROM kl.classid
AND bl.objid IS NOT DISTINCT FROM kl.objid
AND bl.objsubid IS NOT DISTINCT FROM kl.objsubid
AND bl.pid <> kl.pid
JOIN pg_stat_activity ka ON ka.pid = kl.pid
JOIN pg_stat_activity ba ON ba.pid = bl.pid
WHERE NOT bl.granted;
Подойдут Flyway, Liquibase, golang-migrate, Sqitch — неважно что, важно КАК:
Пример структуры миграций:
Полезно добавить «сухой запуск» — проверку конфликта блокировок через EXPLAIN и попытку взять короткую lock в тестовом режиме.
Вместо одного опасного шага «переименовать поле и перестроить индекс» вы получаете цепочку маленьких безопасных изменений: добавить, синхронизировать, проверить, переключить, удалить. Это и есть expand/contract. Привычка «класть в один релиз только то, что не ломает никого» быстро окупается: меньше ручной работы, меньше пожарных чатов и больше времени на развитие продукта.