Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

Безостановочные миграции PostgreSQL: как менять схему без даунтайма и рисков для выручки

Разработка и технологии8 января 2026 г.
Даже маленькая миграция схемы может подвесить базу и остановить заказы. Разберём проверенную практику expand–contract, безопасные приёмы в PostgreSQL и пошаговый план миграций без простоя — с примерами SQL и контролем рисков.
Безостановочные миграции PostgreSQL: как менять схему без даунтайма и рисков для выручки

Оглавление

  • Зачем бизнесу безостановочные миграции
  • Где рождается даунтайм при изменениях в БД
  • Принцип expand–contract и двухшаговые выкаты
  • Что опасно в PostgreSQL и как делать безопасно
    • Индексы
    • Добавление колонки с дефолтом
    • NOT NULL и CHECK
    • Внешние ключи
    • Смена типа и переименование
    • Массовый апдейт
  • Пошаговый пример: переименование колонки и смена типа
  • Пакетная миграция данных: пример скрипта
  • Контроль рисков: таймауты, мониторинг, откаты
  • Чек-лист перед продом
  • Итоги и рекомендации

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

Миграции базы — рутина, которая может внезапно превратиться в остановку продукта. Достаточно «невинного» ALTER TABLE, и бэкенд встанет на минуту-другую — для онлайнового бизнеса это потерянные заказы, звонки в поддержку и тревожные метрики. Хорошая новость: большинство изменений схемы можно проводить без даунтайма. Для этого не нужны дорогие решения — достаточно дисциплины, пары безопасных паттернов и правильных настроек.

Что получает бизнес:

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

Где рождается даунтайм при изменениях в БД

Типичные причины зависаний и блокировок:

  • Долгие эксклюзивные блокировки при ALTER TABLE (перестройка таблицы, смена типа).
  • Перестроение больших индексов без «параллельного» режима.
  • Массовые UPDATE на миллионы строк одним оператором.
  • Добавление NOT NULL/FOREIGN KEY с полной проверкой всех строк.
  • Падение кэша и всплеск нагрузки на диск из‑за «переписывания» таблицы.

Почти всё это лечится дроблением изменений и использованием «безопасных» вариантов команд.

Принцип expand–contract и двухшаговые выкаты

Ключевая идея: система должна быть совместима вперёд и назад, а изменения — обратимыми. Делим изменения на две фазы:

  • Expand (расширение): добавляем новые сущности (колонки, индексы, таблицы) так, чтобы старый код продолжал работать. Пишем данные и в старую, и в новую структуру. Запускаем фоновую миграцию для бэкфила. Валидируем.
  • Contract (сжатие): когда всё переписано и проверено, переключаем чтение на новую структуру и удаляем старую. Это делается отдельным релизом, не сразу.

Такой подход даёт:

  • Нулевой простой — каждый шаг быстрый и безопасный.
  • Простую возможность откатить код без отката БД.

Что опасно в PostgreSQL и как делать безопасно

Ниже — наиболее «колючие» операции и безопасные альтернативы.

Индексы

  • Опасно: CREATE INDEX ... и DROP INDEX ... — берут блокировки, мешающие записи/чтению.
  • Безопасно: CREATE INDEX CONCURRENTLY ... и DROP INDEX CONCURRENTLY ... — почти не блокируют пользователей (требуют отдельной транзакции).
-- правильно
CREATE INDEX CONCURRENTLY idx_order_created_at ON orders (created_at);
-- удаление тоже «параллельно»
DROP INDEX CONCURRENTLY IF EXISTS idx_order_created_at;

Добавление колонки с дефолтом

  • До PostgreSQL 11: ADD COLUMN ... DEFAULT переписывает всю таблицу — больно.
  • Начиная с 11: если дефолт — константа, таблица не переписывается, а значение подставляется «на лету».
  • В любом случае, NOT NULL сразу не ставим — сначала nullable + бэкфил + валидация.
-- безопасно на PG 11+: дефолт — константа, без переписывания
ALTER TABLE users ADD COLUMN is_active boolean DEFAULT true;
-- NOT NULL — позже, после бэкфила

NOT NULL и CHECK

  • Прямая установка NOT NULL сканирует всю таблицу и может идти долго.
  • Приём: сначала добавляем ограничение как проверку «не валидировано», потом валидируем без полной блокировки записи.
-- даёт семантику NOT NULL без долгой блокировки
ALTER TABLE users ADD CONSTRAINT users_email_nn CHECK (email IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_email_nn;
-- по желанию потом можно превратить в NOT NULL, если допустимо недолгое окно

Внешние ключи

  • Опасно: ADD CONSTRAINT ... FOREIGN KEY с полной проверкой.
  • Безопасно: добавить NOT VALID, затем VALIDATE CONSTRAINT.
ALTER TABLE orders
  ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;

Смена типа и переименование

  • ALTER TABLE ... ALTER COLUMN ... TYPE ... USING ... может переписать столбец целиком и занять много времени.
  • Безопасно: новый столбец нужного типа → двойная запись → пакетный бэкфил → переключение чтения → удаление старого.

Массовый апдейт

  • Опасно: один UPDATE на миллионы строк.
  • Безопасно: бэкфил пачками по ключу с паузами и лимитами по времени.

Пошаговый пример: переименование колонки и смена типа

Задача: в таблице orders поле total_cents integer заменить на total numeric(12,2). Нужно, чтобы старый код не упал, а новый перешёл на точный decimal.

Шаг 0. Подготовим сессию миграции: таймауты и безопасность блокировок.

-- выполняем в начале каждой миграции
SET statement_timeout = '5min';       -- общий предел на запрос
SET lock_timeout = '3s';              -- не ждём долгих блокировок
SET idle_in_transaction_session_timeout = '30s';

Шаг 1. Expand: добавляем новый столбец и индекс.

ALTER TABLE orders ADD COLUMN total numeric(12,2);
-- если часто фильтруем по total
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_total ON orders (total);

Шаг 2. Обновляем приложение (релиз A):

  • Все новые записи и апдейты пишем в оба столбца: total_cents и total.
  • Чтение пока из старого поля total_cents, чтобы не ломать существующие клиенты.

Пример на сервере (условно на Go):

// запись заказа с двойной записью
func SaveOrder(db *sql.DB, o Order) error {
    // вычисляем оба значения
    totalCents := o.TotalCents
    totalDecimal := decimal.NewFromInt(int64(totalCents)).Div(decimal.NewFromInt(100))

    _, err := db.Exec(`
        INSERT INTO orders(id, user_id, total_cents, total)
        VALUES($1, $2, $3, $4)
        ON CONFLICT (id) DO UPDATE
          SET total_cents = EXCLUDED.total_cents,
              total = EXCLUDED.total`,
        o.ID, o.UserID, totalCents, totalDecimal,
    )
    return err
}

// чтение пока из старого поля
func (o *Order) TotalMoney() int64 { return o.TotalCents }

Шаг 3. Пакетный бэкфил старых данных. Делаем фоновую задачу: обновляет 5–10 тысяч строк за проход, с паузами, чтобы не мешать продовым запросам.

-- создадим вспомогательный индекс для сканирования пачками по первичному ключу
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_backfill ON orders (id) WHERE total IS NULL;

Простой скрипт бэкфила на стороне БД (исполняем многократно):

WITH cte AS (
  SELECT id, total_cents
  FROM orders
  WHERE total IS NULL
  ORDER BY id
  LIMIT 10000
)
UPDATE orders o
SET total = (cte.total_cents::numeric / 100.0)
FROM cte
WHERE o.id = cte.id;

Или фоновый процесс на Python с паузой и метриками:

import time
import psycopg2

conn = psycopg2.connect(dsn="postgres://...")
conn.autocommit = True

BATCH = 10000
SLEEP = 0.2

while True:
    with conn.cursor() as cur:
        cur.execute("""
            WITH cte AS (
              SELECT id, total_cents FROM orders
              WHERE total IS NULL
              ORDER BY id
              LIMIT %s
            )
            UPDATE orders o
            SET total = (cte.total_cents::numeric / 100.0)
            FROM cte
            WHERE o.id = cte.id
            RETURNING o.id;
        """, (BATCH,))
        rows = cur.fetchall()
        if not rows:
            break
    time.sleep(SLEEP)

Шаг 4. Валидация: убеждаемся, что расхождений нет.

-- должно вернуть 0
SELECT COUNT(*) AS mismatches
FROM orders
WHERE total IS NOT NULL AND (total * 100)::bigint != total_cents;

Шаг 5. Переключение чтения (релиз B):

  • Приложение начинает читать total как основное поле.
  • Временная двойная запись сохраняется.
// чтение теперь из decimal
func (o *Order) TotalMoney() int64 {
    // бизнес-логика переехала на точный decimal
    return o.Total.Mul(decimal.NewFromInt(100)).IntPart()
}

Шаг 6. Contract: наведение порядка.

  • Фиксируем ограничения: добавим «не валидированное» ограничение и провалидируем.
ALTER TABLE orders ADD CONSTRAINT orders_total_nn CHECK (total IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_nn;
  • Удаляем старый столбец (отдельная короткая миграция, когда убедились, что откат приложения уже не нужен):
ALTER TABLE orders DROP COLUMN total_cents;
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_backfill;

Таким образом, ни на одном шаге не было долгих эксклюзивных блокировок или массовых операций, которые «замораживают» таблицу.

Пакетная миграция данных: пример скрипта

Полезно иметь универсальный «шаблон» бэкфила, который уважает таймауты и не держит блокировки.

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

export PGOPTIONS="-c statement_timeout=5min -c lock_timeout=3s -c idle_in_transaction_session_timeout=30s"

# Пример: бэкфил колонки orders.total из total_cents
BATCH=10000
SLEEP=0.2

while true; do
  UPDATED=$(psql "$DATABASE_URL" -At -c "
    WITH cte AS (
      SELECT id, total_cents FROM orders
      WHERE total IS NULL
      ORDER BY id
      LIMIT $BATCH
    )
    UPDATE orders o
    SET total = (cte.total_cents::numeric / 100.0)
    FROM cte
    WHERE o.id = cte.id
    RETURNING 1;" | wc -l)

  echo "updated: $UPDATED"
  if [ "$UPDATED" -eq 0 ]; then break; fi
  sleep "$SLEEP"
done

Замечания:

  • Обязательно задавайте lock_timeout, чтобы миграция не висела в ожидании блокировки и не мешала продовым транзакциям.
  • Разбивайте задачи по ключу и ограничивайте партии.

Контроль рисков: таймауты, мониторинг, откаты

Что включить в стандарт миграций:

  • Таймауты сессии: statement_timeout, lock_timeout, idle_in_transaction_session_timeout.
  • Индикаторы в дашборде: время отклика БД, число активных блокировок (pg_locks), репликационная задержка, длительные транзакции.
  • Запуск бэкфила при заниженной приоритетности: паузы между батчами, лимиты на параллелизм.
  • Логи проверки: счётчик расхождений, количество обновлённых строк за минуту.
  • План отката: код всегда должен уметь работать со старой и новой схемой в течение переходного периода. Удаление старых объектов — последним этапом, отдельной поставкой.

Пример запросов для диагностики:

-- кто держит блокировки
SELECT pid, locktype, relation::regclass, mode, granted, query
FROM pg_locks l LEFT JOIN pg_stat_activity a USING (pid)
WHERE NOT granted;

-- длительные запросы
SELECT pid, now() - query_start AS dur, state, query
FROM pg_stat_activity
WHERE state <> 'idle' AND (now() - query_start) > interval '30s';

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

  • Подготовлен план expand–contract с явной точкой возможного отката.
  • Все DDL‑операции проверены на необходимость «параллельных» вариантов (CONCURRENTLY, NOT VALID/VALIDATE).
  • Заданы таймауты блокировок и запросов на уровне сессии миграций.
  • Есть безопасный скрипт бэкфила партиями и план throttling (паузы, лимиты).
  • Тест пройден на копии прод‑данных с измерением времени и нагрузки.
  • Метрики и алерты готовы: блокировки, долгие транзакции, лаг репликации.
  • План удаления старых объектов — отдельной поставкой через время, когда откат приложения точно не нужен.

Итоги и рекомендации

  • Думайте не о «миграции», а об «эволюции схемы»: два коротких шага (expand → contract) надёжнее одного большого.
  • Блокировки — главный источник даунтайма. Используйте CONCURRENTLY, NOT VALID/VALIDATE, избегайте массовых переписей.
  • Все тяжёлые операции — пакетно и с таймаутами. Никаких «одним UPDATE на десятки миллионов строк».
  • Валидация и метрики — ваша страховка. Сравнивайте старое и новое представление данных перед переключением.

Придерживаясь этих правил, можно регулярно менять схему в PostgreSQL без остановок и с предсказуемыми рисками — а значит, выпускать продукт быстрее и увереннее.


PostgreSQLмиграции БДбез даунтайма