Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

Миграции схемы без простоя в PostgreSQL: как менять базу без ночных окон и простоев

Разработка и технологии18 февраля 2026 г.
Схема базы меняется чаще, чем кажется: новые поля, индексы, связи. Но каждое «ALTER TABLE» на проде — риск блокировок, падения SLA и потерянных заказов. Разбираем практики миграций без простоя в PostgreSQL: какие операции опасны, как их обезвредить, шаблоны пошаговых изменений, автоматизацию и план отката.
Миграции схемы без простоя в PostgreSQL: как менять базу без ночных окон и простоев

• Оглавление

  • Зачем бизнесу миграции без простоя
  • Базовые принципы: сначала расширяем, потом сужаем
  • Что в PostgreSQL блокирует и «переписывает» таблицы
  • Готовые шаблоны безопасных миграций
  • Пошаговый процесс релиза (A → B → C)
  • Автоматизация: обёртка для безопасных DDL
  • Наблюдаемость и поиск блокировок
  • Как оценить длительность миграции заранее
  • План отката и обратимые шаги
  • Чек-лист перед запуском

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

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

  • выпускать изменения днём, в предсказуемые слоты;
  • не терять заказы из‑за минутных блокировок «ALTER TABLE»;
  • легче катить мелкие, частые релизы вместо «больших взрывов»;
  • сократить инциденты и стоимость релиза.

Базовые принципы: сначала расширяем, потом сужаем

Ключевая идея — совместимость. Приложение и схема должны некоторое время работать вместе в двух версиях данных.

  • Расширяй: добавь новое поле, индекс или таблицу максимально безопасно.
  • Дублируй записи/запросы, если надо (например, писать в два поля).
  • Переключи чтения на новую схему.
  • Сужай: убери старое только когда уверен, что оно не используется.

Такой подход называют «expand → migrate → contract». Он уменьшает единичный риск и даёт пространство для отката.

Что в PostgreSQL блокирует и «переписывает» таблицы

Зная, какие DDL дорогие, можно заранее выбрать безопасный путь.

  • CREATE/DROP INDEX без CONCURRENTLY: берут эксклюзивные блокировки. Делайте только CONCURRENTLY.
  • ALTER TABLE ... ADD COLUMN с DEFAULT: начиная с PostgreSQL 11 добавление колонки с константным DEFAULT не переписывает таблицу. В старших версиях до 11 — переписывает. Универсально безопасно: добавить без DEFAULT, затем поставить DEFAULT отдельно.
  • ALTER TABLE ... SET NOT NULL: выполняет полное сканирование таблицы для проверки, может идти долго. Безопаснее — сначала CHECK ... NOT VALID и VALIDATE (см. ниже), а строгий NOT NULL — в «тихий час» или оставить CHECK.
  • FOREIGN KEY: создавайте «NOT VALID», затем VALIDATE CONSTRAINT — валидация длительная, но не блокирует записи.
  • ALTER TYPE ... ADD VALUE: короткая блокировка типа, обычно безопасно. Удалить/переименовать значения нельзя — требуется новая колонка/тип и миграция данных.
  • РENAME COLUMN/TABLE: берёт кратковременный эксклюзивный блок, обычно безопасно, но приложение должно быть готово к новому имени.

Готовые шаблоны безопасных миграций

1) Добавление нового обязательного поля с дефолтом

Цель: добавить колонку, заполнить исторические данные, сделать поле обязательным — без простоя.

Шаги:

  1. Добавляем колонку как допускающую NULL, без DEFAULT.
SET lock_timeout = '3s';
SET statement_timeout = '30s';
ALTER TABLE orders ADD COLUMN source TEXT; -- пока NULL допустим
  1. Ставим DEFAULT для новых строк (операция быстрая и не сканирует всю таблицу).
ALTER TABLE orders ALTER COLUMN source SET DEFAULT 'web';
  1. Заполняем исторические данные батчами, маленькими порциями, чтобы не создавать длинные блокировки и пики IO.
-- Обновляем по 1000 строк за итерацию, делая паузы
DO $$
DECLARE
  _batch_size int := 1000;
  _updated int := 0;
BEGIN
  LOOP
    WITH cte AS (
      SELECT ctid FROM orders WHERE source IS NULL LIMIT _batch_size
    )
    UPDATE orders o
      SET source = 'web'
      FROM cte
      WHERE o.ctid = cte.ctid;

    GET DIAGNOSTICS _updated = ROW_COUNT;
    EXIT WHEN _updated = 0;

    PERFORM pg_sleep(0.05); -- 50 мс пауза между батчами
  END LOOP;
END$$ LANGUAGE plpgsql;
  1. Добавляем ограничение «не пусто» как CHECK NOT VALID, потом валидируем отдельно. Это позволяет не блокировать записи на время валидации.
ALTER TABLE orders ADD CONSTRAINT orders_source_present CHECK (source IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_source_present; -- может идти долго, но без простоя
  1. (Опционально) Устанавливаем NOT NULL в низкую нагрузку. Учтите: это дополнительное сканирование. В большинстве случаев CHECK достаточно.
-- Выполнять в низкую нагрузку
ALTER TABLE orders ALTER COLUMN source SET NOT NULL;

2) Создание индекса на большой таблице

Используем только CONCURRENTLY. Важно: команда должна выполняться вне транзакции.

-- Нельзя внутри BEGIN ... COMMIT
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_at ON orders (created_at);

Удаление тоже делайте CONCURRENTLY:

DROP INDEX CONCURRENTLY IF EXISTS idx_orders_created_at;

3) Переименование или замена колонки без «ломания» клиентов

Если надо заменить column_a на column_b:

  1. Добавьте column_b, начните двойную запись в приложении (в транзакциях писать и в a, и в b).
  2. Мигрируйте исторические данные батчами.
  3. Переключите чтение на b.
  4. Через релиз удалите a (в «тихий час» или с CHECK, что оно не используется).

Пример миграции данных:

DO $$
DECLARE
  _batch int := 5000;
  _moved int;
BEGIN
  LOOP
    WITH moved AS (
      SELECT ctid, a FROM items WHERE b IS NULL LIMIT _batch
    )
    UPDATE items i
       SET b = moved.a
      FROM moved
     WHERE i.ctid = moved.ctid;

    GET DIAGNOSTICS _moved = ROW_COUNT;
    EXIT WHEN _moved = 0;
    PERFORM pg_sleep(0.05);
  END LOOP;
END$$ LANGUAGE plpgsql;

4) Добавление внешнего ключа без простоя

-- Сначала без проверки существующих строк
ALTER TABLE payments
  ADD CONSTRAINT payments_order_fk
  FOREIGN KEY (order_id) REFERENCES orders(id) NOT VALID;

-- Отдельно валидируем: долго, но не блокирует INSERT/UPDATE
ALTER TABLE payments VALIDATE CONSTRAINT payments_order_fk;

5) Замена ENUM: добавляем значения без падений

Добавить новое значение:

ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'refunded';

Если нужно удалить/переименовать значение, ENUM придётся заменить: завести новую колонку с новым типом, мигрировать данные, переключиться, потом удалить старую — по шаблону «замены колонки».

Пошаговый процесс релиза (A → B → C)

  • Релиз A (расширение):

    • добавить новые колонки/индексы безопасно (CONCURRENTLY, без NOT NULL);
    • включить двойную запись в коде (если нужно);
    • поставить DEFAULT только для новых строк;
    • запустить фоновую миграцию исторических данных батчами;
    • добавить CHECK ... NOT VALID и VALIDATE.
  • Релиз B (переключение):

    • перевести чтение на новые колонки/индексы;
    • убедиться по метрикам, что запросы используют нужные индексы (EXPLAIN, pg_stat_statements).
  • Релиз C (сужение):

    • удалить старые пути записи/чтения;
    • удалить больше не нужные индексы/колонки (CONCURRENTLY, в низкую нагрузку);
    • при необходимости проставить строгие NOT NULL/UNIQUE.

Автоматизация: обёртка для безопасных DDL

Полезно запускать DDL через скрипт, который сразу ставит ограничители и проверяет блокировки.

#!/usr/bin/env bash
set -euo pipefail

DB_URL="${DB_URL:-postgres://user:pass@localhost:5432/app?sslmode=disable}"
SQL_FILE="$1"

# Базовые предохранители
cat > /tmp/_ddl_header.sql <<'SQL'
SET lock_timeout = '3s';
SET statement_timeout = '30s';
SET idle_in_transaction_session_timeout = '10s';
SET maintenance_work_mem = '512MB';
SQL

cat /tmp/_ddl_header.sql "$SQL_FILE" | psql "$DB_URL" --single-transaction --set ON_ERROR_STOP=1 || {
  echo "DDL failed. Showing blockers..." >&2
  psql "$DB_URL" -c "
    SELECT pid, usename, state, wait_event_type, wait_event, query
    FROM pg_stat_activity
    WHERE state <> 'idle' AND pid <> pg_backend_pid()
    ORDER BY query_start;
  "
  exit 1
}

Важно: команды с CONCURRENTLY нельзя исполнять внутри транзакции. Для них делайте отдельные файлы и запускайте psql без --single-transaction.

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

Набор полезных запросов в Postgres для диагностики.

Кто блокирует кого:

SELECT bl.pid AS blocked_pid,
       ka.query AS blocking_query,
       now() - ka.query_start AS blocking_for,
       kl.locktype, kl.mode
FROM pg_locks bl
JOIN pg_locks kl ON bl.locktype = kl.locktype AND bl.locked_object = kl.locked_object AND bl.pid <> kl.pid
JOIN pg_stat_activity ka ON ka.pid = kl.pid
WHERE bl.granted = false;

Долгие транзакции:

SELECT pid, usename, now() - xact_start AS tx_age, query
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start IS NOT NULL
ORDER BY tx_age DESC
LIMIT 10;

Использование индексов по факту:

SELECT relname AS table,
       idx_scan, seq_scan,
       round(100.0 * idx_scan / NULLIF(idx_scan + seq_scan, 0), 2) AS idx_usage_pct
FROM pg_stat_user_tables
ORDER BY idx_usage_pct ASC NULLS LAST
LIMIT 20;

Как оценить длительность миграции заранее

  • Клонируйте прод в тестовую базу (pg_dump/pg_restore либо снапшот). Прогоняйте миграцию целиком.
  • Измеряйте скорость апдейта батчей (строк/сек), учитывайте паузы — прикиньте общее время.
  • Для CREATE INDEX CONCURRENTLY ориентируйтесь на объём таблицы и скорость диска: индексация — это последовательное чтение + запись индекса.
  • Прогоните EXPLAIN (ANALYZE, BUFFERS) для ключевых запросов до/после — проверяйте, что план действительно стал лучше.

План отката и обратимые шаги

  • Всегда делайте изменения, которые можно откатить без простоя: добавление новых объектов откатывается удалением; замены колонок — возвратом чтения на старую.
  • Не удаляйте старые данные/колонки до подтверждения метриками, что новая схема в проде полностью используется и стабильна.
  • Снимайте снапшоты (LVM/ZFS, бэкап в облаке) перед дорогими операциями.
  • Для ENUM/типов: храните «сырое» значение параллельно в TEXT до полной уверенности.

Чек-лист перед запуском

  • Разбили изменение на «расширение → переключение → сужение».
  • Все CREATE/DROP INDEX идут с CONCURRENTLY в отдельных транзакциях.
  • Новые колонки добавляем без NOT NULL, сначала заполняем, потом CHECK/VALIDATE.
  • Есть фоновый скрипт миграции, работающий батчами с паузами.
  • Выставлены lock_timeout и statement_timeout.
  • Подготовлены запросы для диагностики блокировок.
  • Прогнали миграцию на копии прод-данных и оценили длительность.
  • Прописан план отката на каждом шаге.

Итоги

Миграции без простоя — это не «магия», а дисциплина небольших шагов и знание свойств PostgreSQL. Добавляйте новое безопасно, мигрируйте исторические данные батчами, валидируйте ограничения отдельно, индексы создавайте CONCURRENTLY, старое удаляйте в самом конце. В результате релизы станут короче, риски — ниже, а бизнес перестанет ждать «ночных окон», чтобы выкатить простое поле или индекс.


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