Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Миграции PostgreSQL без простоя (expand/contract): новые фичи без ночных окон и потерь данных

Разработка и технологии19 января 2026 г.
Как менять схему базы и выкатывать функциональность без пауз в работе сервиса? Разбираем стратегию expand/contract, безопасные и опасные операции в PostgreSQL, пошаговый план для типичных изменений, батчевые бэкфиллы, триггеры для двойной записи и контроль блокировок. Результат — меньше инцидентов, предсказуемые релизы и никаких «ночных» работ.
Миграции PostgreSQL без простоя (expand/contract): новые фичи без ночных окон и потерь данных

  • Содержание
  • Зачем бизнесу миграции без простоя
  • Что такое стратегия expand/contract
  • Опасные и безопасные операции в PostgreSQL
    • Индексы
    • Колонки и ограничения
    • Внешние ключи
    • Переименование и изменение типа
    • Удаление колонок и таблиц
  • Пошаговый рецепт: «переименовать колонку с бэкфиллом»
    • 1) Расширяем схему (expand)
    • 2) Бэкфилл данными порциями
    • 3) Переключаем чтение и пишем в обе колонки
    • 4) Контрактим схему (contract)
  • Управление блокировками и тайм-аутами
  • Наблюдаемость и откат
  • Автоматизация миграций: инструменты и практика
  • Чек-лист перед миграцией
  • Итоги и ожидаемая экономия

Зачем бизнесу миграции без простоя

Рост продукта почти всегда тянет за собой изменения схемы базы: новые поля, индексы, переработка типов, разнос таблиц. Обычная «быстрая» миграция может повиснуть на блокировке и остановить запись — за 2–10 минут вы теряете заказы, портите конверсию и репутацию. Ночные окна тоже бьют по скорости выпуска: команда замедляется, фичи застревают, растёт риск человеческой ошибки.

Миграции без простоя позволяют:

  • выпускать фичи днём, когда вся команда на месте;
  • не ставить бизнес на паузу и не потерять деньги;
  • снижать частоту инцидентов и ускорить релизы.

Что такое стратегия expand/contract

Expand/contract — это двухэтапная тактика изменения схемы:

  • Expand: добавляем всё новое так, чтобы старая и новая схемы работали параллельно. Ничего не ломаем, только расширяем.
  • Contract: когда код уже использует новую схему и старое больше не нужно — аккуратно удаляем лишнее.

Ключевой принцип совместимости: сначала выкатываем изменения, которые никого не ломают; затем переводим чтение/запись на новую схему; только потом — чистка. Часто это 2–4 релиза вместо одного «рубильника», зато без простоя.

Опасные и безопасные операции в PostgreSQL

Важно понимать, какие 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), затем избавиться от старого поля. Нужна миграция без простоя.

1) Расширяем схему (expand)

  • Добавляем новую колонку и индексы, если нужно.
  • Включаем триггеры для синхронизации значений при вставке/обновлении.
-- Рекомендуется ограничить время ожидания блокировок этой сессии
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();

Теперь любые записи/обновления будут держать обе колонки в актуальном состоянии.

2) Бэкфилл данными порциями

Гонять 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;

3) Переключаем чтение и пишем в обе колонки

На этом этапе приложение должно читать из новой колонки. В запись продолжаем поддерживать обе для обратной совместимости — на случай если где-то ещё остался старый путь.

Если двойная запись реализована триггером, в приложении достаточно перейти на чтение из phone_e164. Если вы предпочитаете контроль на уровне кода, пример на SQL-процедуре уже решает большинство кейсов без изменения бэкенда.

4) Контрактим схему (contract)

Когда код в проде везде читает из phone_e164, а бэкфилл завершён:

  • валидируем NOT NULL (по желанию — через CHECK, как описано выше);
  • удаляем индекс/колонку, завязанные на старое поле;
  • снимаем триггер синхронизации.
-- (опционально) делаем поле обязательным безопасным способом
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 минут
  • Для долгих операций используйте «фоновое» создание индексов (CONCURRENTLY).
  • Большие обновления делайте порциями, с паузами.
  • Если шаг упёрся в lock_timeout — откладывайте и пробуйте позже (инструмент должен уметь ретраи).

Следить за блокировками на лету:

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;

Наблюдаемость и откат

  • Метрики: длительность миграций, доля «хвостовых» записей в бэкфилле, нагрузка на CPU диски и репликацию, время создания индексов.
  • Логи: фиксируйте начало/конец шага, количество обработанных строк в каждом батче, количество ретраев.
  • Откат: стратегия отката — это такая же expand/contract в обратную сторону. Сохраните код двойной записи до полного уверенного перехода.

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

Подойдут Flyway, Liquibase, golang-migrate, Sqitch — неважно что, важно КАК:

  • Поддержка «безтранзакционных» шагов для CREATE/DROP INDEX CONCURRENTLY и VALIDATE CONSTRAINT.
  • Возможность устанавливать lock_timeout/statement_timeout на уровне шага.
  • Разделение миграций на «расширение» и «сужение» (expand/contract) с явной маркировкой.
  • Поштучный прогон по окружениям: тест → стейдж → прод, с проверками (кол-во оставшихся строк на бэкфилле, наличие индексов, отсутствие зависимостей на удаляемые объекты).

Пример структуры миграций:

  • V20260101_1000_expand_add_phone_e164.sql
  • V20260101_1100_backfill_phone_e164.sql
  • V20260101_1200_switch_reads_to_phone_e164.md (операционная заметка для команды)
  • V20260101_1300_contract_drop_phone.sql

Полезно добавить «сухой запуск» — проверку конфликта блокировок через EXPLAIN и попытку взять короткую lock в тестовом режиме.

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

  • Понимаете ли вы тип блокировок, которые возьмёт операция? Если нет — замените на безопасный эквивалент.
  • Есть ли отдельные шаги expand и contract? План переключения чтения/записи?
  • Настроены ли lock_timeout и statement_timeout?
  • Готов ли бэкфилл батчами, оценены объёмы и длительность?
  • Создаёте ли индексы CONCURRENTLY? Предусмотрен откат при падении?
  • Добавляете ли внешние ключи через NOT VALID + VALIDATE?
  • Мониторинг: есть ли алерты на рост блокировок и длительных запросов?
  • Реплика: не перегрузите ли её (особенно при массовых апдейтах)?
  • Документация: записали ли в вики, когда можно удалять старую колонку?

Итоги и ожидаемая экономия

  • Без простоя: доход не проседает во время релизов, не нужны «ночные окна».
  • Меньше инцидентов: риск блокировок и стопа записи уходит в ноль при корректном применении стратегии.
  • Быстрее релизы: команда перестаёт бояться DDL, фичи двигаются в прод быстрее.
  • Прозрачность: миграции становятся предсказуемым процессом с понятными метриками и шагами.

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


PostgreSQLмиграциибез простоя