Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

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

Разработка и технологии23 декабря 2025 г.
Даже одна «тяжёлая» миграция может остановить продажи, уронить SLA и съесть бюджет на поддержку. Разбираем практики и шаблоны, как эволюционировать схему PostgreSQL без остановки сервиса: что безопасно, что опасно, как готовить и откатывать изменения. Даём пошаговые примеры для Django и Alembic, SQL-рецепты, мониторинг и чек‑лист для CI.
Миграции без простоя в PostgreSQL: как обновлять схему и не терять деньги на простоях

  • Содержание
    • Зачем бизнесу миграции без простоя
    • Опасные и безопасные изменения в PostgreSQL
    • Паттерн expand–migrate–contract
    • Техники PostgreSQL: индексы, NOT NULL, внешние ключи, типы
    • Примеры: Django и Alembic
    • Массовая заливка (backfill) без блокировок
    • Мониторинг, лимиты и план отката
    • Автоматизация в CI и чек‑лист

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

Схема базы меняется постоянно: новые поля, индексы, связи, исправления. Каждая неподготовленная миграция — риск:

  • простаивания и потерянных заказов;
  • просадки SLA из‑за блокировок и очередей;
  • раздувания инфраструктуры «на всякий случай» из‑за непредсказуемых откатов.

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

Опасные и безопасные изменения в PostgreSQL

PostgreSQL бережно относится к данным, но ряд DDL‑операций берёт тяжёлые блокировки. Главное — понимать, что именно блокирует чтение/запись и как этого избежать.

Относительно безопасные изменения

  • Добавить новый столбец NULL без значения по умолчанию.
  • Добавить новый индекс с CONCURRENTLY.
  • Добавить CHECK/FOREIGN KEY как NOT VALID, затем VALIDATE.
  • Добавить новую таблицу.
  • Переименовать индекс (короткая блокировка).
  • Установить NOT NULL, если в столбце уже нет NULL (требует сканирования, но без блокировки записи).

Опасные изменения (так делать нельзя «напрямую»)

  • CREATE INDEX без CONCURRENTLY на больших таблицах — блокирует запись.
  • ALTER TABLE ... ADD COLUMN ... DEFAULT <выражение> в старых версиях — может переписать таблицу. Лучше выставлять значение в приложении и/или делать DEFAULT позже.
  • ALTER TABLE ... ALTER COLUMN TYPE — часто вызывает перепись таблицы.
  • Переименование столбца, от которого зависит приложение, без совместимости по чтению/записи.
  • Удаление столбца/индекса «в лоб» во время трафика без измерения влияния.

Паттерн expand–migrate–contract

Классический шаблон без простоя:

  1. Expand (расширить)
  • Добавить новые объекты: столбцы, индексы (с CONCURRENTLY), ограничения NOT VALID.
  • Обновить код, чтобы он умел работать и со старой, и с новой схемой (двойная запись, совместимое чтение).
  1. Migrate (перенести данные)
  • Массово переложить данные в новые структуры батчами.
  • Провалидировать ограничения (VALIDATE CONSTRAINT), убедиться, что новая схема корректна.
  1. Contract (сузить)
  • Переключить чтение на новые поля.
  • Удалить старые поля/индексы/ограничения (CONCURRENTLY там, где возможно).

Каждый шаг — отдельный релиз. Между ними сервис остаётся работоспособным и обратимым.

Техники PostgreSQL: индексы, NOT NULL, внешние ключи, типы

Индексы без блокировок

  • Создавайте/переиндексируйте так:
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
REINDEX INDEX CONCURRENTLY idx_users_email;
DROP INDEX CONCURRENTLY idx_users_email;

CONCURRENTLY нельзя выполнять внутри транзакции — это важно для инструментов миграций.

NOT NULL без боли

  • Добавляем столбец как NULL:
ALTER TABLE orders ADD COLUMN external_id text; -- NULL разрешён
  • Заполняем значениями батчами (см. раздел про backfill).
  • Проверяем, что NULL больше нет, и только потом ставим ограничение:
ALTER TABLE orders ALTER COLUMN external_id SET NOT NULL;

Для PostgreSQL ≥ 11 установка DEFAULT константой метаданная и быстрая, но безопаснее всё равно выставлять значение приложением, пока идёт backfill.

Внешние ключи и CHECK без блокировок

  • Сначала NOT VALID:
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;

Аналогично с CHECK:

ALTER TABLE orders
  ADD CONSTRAINT orders_amount_positive CHECK (amount > 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_positive;

Переименование и смена типа

  • Переименование столбца безопасно только при совместимой логике в коде. Часто лучше добавить новый столбец, копировать данные, переключить чтение и удалить старый.
  • Смена типа почти всегда переписывает таблицу. Надёжный путь такой же: новый столбец нужного типа → backfill → переключение → удаление старого.

Лимиты блокировок и времени выполнения

Запускайте DDL с ограничениями, чтобы миграция не зависла и не блокировала продакшен:

SET lock_timeout = '1s';
SET statement_timeout = '5min';
SET idle_in_transaction_session_timeout = '30s';

Примеры: Django и Alembic

Django: индекс CONCURRENTLY

Django поддерживает операции для PostgreSQL, позволяющие создавать/удалять индексы конкурентно. Важно: миграция должна быть atomic = False.

# myapp/migrations/0023_add_email_index.py
from django.db import migrations, models
from django.contrib.postgres.operations import AddIndexConcurrently, RemoveIndexConcurrently

class Migration(migrations.Migration):
    atomic = False  # нужно для CONCURRENTLY

    dependencies = [
        ("myapp", "0022_previous"),
    ]

    operations = [
        AddIndexConcurrently(
            "user",
            models.Index(fields=["email"], name="idx_user_email"),
        )
    ]

Удаление индекса:

class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ("myapp", "0023_add_email_index"),
    ]

    operations = [
        RemoveIndexConcurrently("user", "idx_user_email"),
    ]

Django: NOT NULL через backfill и валидацию

  1. Добавляем колонку как NULL:
# 0024_add_external_id.py
from django.db import migrations, models

class Migration(migrations.Migration):
    dependencies = [("myapp", "0023_add_email_index")]

    operations = [
        migrations.AddField(
            model_name="order",
            name="external_id",
            field=models.CharField(max_length=64, null=True),
        ),
    ]
  1. Заполняем данными батчами (management command), затем фиксируем NOT NULL:
# 0025_enforce_external_id_not_null.py
from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [("myapp", "0024_add_external_id")]

    operations = [
        migrations.RunSQL(
            "ALTER TABLE orders ALTER COLUMN external_id SET NOT NULL;",
            reverse_sql="ALTER TABLE orders ALTER COLUMN external_id DROP NOT NULL;",
        )
    ]

Alembic: индекс CONCURRENTLY

# versions/20240101_add_user_email_index.py
from alembic import op

def upgrade():
    # CONCURRENTLY — вне транзакции
    with op.get_context().autocommit_block():
        op.create_index(
            "ix_user_email",
            "user",
            ["email"],
            unique=False,
            postgresql_concurrently=True,
        )

def downgrade():
    with op.get_context().autocommit_block():
        op.drop_index("ix_user_email", table_name="user", postgresql_concurrently=True)

Alembic: внешний ключ NOT VALID → VALIDATE

from alembic import op

def upgrade():
    op.execute(
        "ALTER TABLE orders ADD CONSTRAINT orders_user_fk "
        "FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;"
    )
    op.execute("ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;")

def downgrade():
    op.execute("ALTER TABLE orders DROP CONSTRAINT orders_user_fk;")

Массовая заливка (backfill) без блокировок

Основные принципы:

  • Делать маленькими порциями.
  • Сортироваться по первичному ключу.
  • Использовать SKIP LOCKED, чтобы не драться за строки с онлайн‑трафиком.
  • Ограничить длительность запросов и задержки между порциями.

Чистый SQL с батчами

-- Пример батчевого обновления 10k строк за проход
DO $$
DECLARE
    batch_size integer := 10000;
    updated_rows integer := 1;
BEGIN
  WHILE updated_rows > 0 LOOP
    WITH cte AS (
      SELECT id
      FROM orders
      WHERE external_id IS NULL
      ORDER BY id
      LIMIT batch_size
      FOR UPDATE SKIP LOCKED
    )
    UPDATE orders o
    SET external_id = 'legacy_' || o.id::text
    FROM cte
    WHERE o.id = cte.id;

    GET DIAGNOSTICS updated_rows = ROW_COUNT;
    PERFORM pg_sleep(0.05); -- дать продакшену подышать
  END LOOP;
END$$;

Python‑скрипт для backfill

import time
import psycopg2

conn = psycopg2.connect(dsn="postgresql://app:pass@db/prod")
conn.autocommit = True

BATCH = 5000
SLEEP = 0.05

query = """
WITH cte AS (
  SELECT id FROM orders
  WHERE external_id IS NULL
  ORDER BY id
  LIMIT %s
  FOR UPDATE SKIP LOCKED
)
UPDATE orders o
SET external_id = 'legacy_' || o.id::text
FROM cte
WHERE o.id = cte.id;
"""

with conn.cursor() as cur:
    while True:
        cur.execute(query, (BATCH,))
        if cur.rowcount == 0:
            break
        time.sleep(SLEEP)

print("done")

Мониторинг, лимиты и план отката

Лок‑мониторинг во время миграции

Смотрите, кто на кого ждёт:

SELECT a.pid, a.usename, a.state, a.query, a.wait_event_type, a.wait_event
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock'
ORDER BY a.query_start;

Сводка по блокировкам:

SELECT bl.pid AS blocked_pid,
       ka.query AS blocking_query,
       now() - ka.query_start AS blocking_duration,
       a.query AS blocked_query
FROM pg_locks bl
JOIN pg_stat_activity a ON a.pid = bl.pid
JOIN pg_locks kl ON kl.transactionid = bl.transactionid AND kl.pid != bl.pid
JOIN pg_stat_activity ka ON ka.pid = kl.pid
WHERE NOT bl.granted;

Прогресс индекса (PostgreSQL ≥ 12):

SELECT * FROM pg_stat_progress_create_index;

Безопасные сессии миграций

Запускайте миграции с ограничениями:

SET lock_timeout = '1s';
SET statement_timeout = '10min';
SET idle_in_transaction_session_timeout = '30s';
SET application_name = 'migrations';

Если упёрлись в блокировку — миграция быстро отвалится и не положит продакшен.

План отката (roll back план, а не ROLLBACK)

  • Если на этапе Expand что‑то пошло не так — удаляем новые объекты (CONCURRENTLY для индексов) и возвращаем код.
  • На этапе Migrate отменяем джобы backfill, оставляем оба столбца и откатываем код.
  • На этапе Contract удалённые объекты можно вернуть как в Expand, если есть необходимость.

Важно: держите нумерованные, короткие миграции, не сливайте много рискованных шагов в один файл.

Автоматизация в CI и чек‑лист

Линтер миграций (простая проверка SQL)

Поймаем опасные конструкции до продакшена:

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

grep -R "CREATE INDEX " migrations | grep -v "CONCURRENTLY" && {
  echo "Найдены индексы без CONCURRENTLY"; exit 1; }

grep -R "ALTER TABLE .* ADD COLUMN .* DEFAULT" migrations && {
  echo "ADD COLUMN с DEFAULT — проверьте стратегию"; exit 1; }

Пример GitHub Actions для миграций

name: Migrations CI
on: [push]
jobs:
  check-migrations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Lint SQL migrations
        run: |
          bash ci/lint_migrations.sh
      - name: Spin up Postgres
        uses: harmon758/postgresql-action@v1
        with:
          postgresql version: '15'
          postgresql db: 'test'
          postgresql user: 'postgres'
          postgresql password: 'postgres'
      - name: Apply migrations
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
        run: |
          export PGPASSWORD=postgres
          psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "SET lock_timeout='1s'; SET statement_timeout='5min';"
          # здесь запускайте ваши миграции (manage.py migrate / alembic upgrade head)

Чек‑лист перед любой миграцией

  • Оценить объём таблицы и время операции: на стейджинге с реалистичными данными.
  • Поставить lock_timeout/statement_timeout в сессии миграции.
  • Для индекса — только CONCURRENTLY.
  • Для FK/CHECK — NOT VALID → VALIDATE.
  • Для NOT NULL — сначала backfill, затем ограничение.
  • Для смены типа — новый столбец → backfill → переключение → удаление старого.
  • Подготовить план отката на каждом шаге.
  • Наблюдать pg_stat_activity/pg_locks, иметь алерты на рост времени ответа.

Выводы

Миграции без простоя — это не магия, а набор дисциплин: правильные блокировки, разбиение на шаги, прозрачный backfill, ограничение времени операций и автоматические проверки. Применив шаблон expand–migrate–contract и техники PostgreSQL (CONCURRENTLY, NOT VALID/VALIDATE, аккуратный NOT NULL), вы снижаете риск инцидентов, ускоряете вывод изменений и экономите на инфраструктуре. Это чувствуется и в SLA, и в бюджете.


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