← все статьи
8 мин

Миграции базы данных: как не положить production

В пятницу в 18:00 разработчик запускает миграцию: ALTER TABLE orders ADD COLUMN discount_pct NUMERIC DEFAULT 0. Таблица — 50 млн строк. PostgreSQL берёт AccessExclusiveLock, перезаписывает каждую строку, блокирует все операции чтения и записи. Через 40 минут прилетает PagerDuty. Это не выдуманная история.

Проблема не в том, что миграции опасны. Проблема в том, что большинство из них пишутся для маленькой базы и не тестируются на реальных объёмах. Разберём, как делать миграции правильно.

Почему ALTER TABLE блокирует всё

PostgreSQL использует систему блокировок MVCC. Большинство DDL-операций требуют AccessExclusiveLock — самый жёсткий уровень, несовместимый вообще ни с чем: ни с SELECT, ни с INSERT, ни с другими блокировками.

-- Опасно на большой таблице: полная блокировка
ALTER TABLE orders ADD COLUMN discount_pct NUMERIC DEFAULT 0 NOT NULL;

-- Безопасно: добавление nullable-колонки без DEFAULT
-- не требует перезаписи строк в PostgreSQL 11+
ALTER TABLE orders ADD COLUMN discount_pct NUMERIC;

-- Безопасно в PostgreSQL 11+: DEFAULT без NOT NULL
-- сохраняется в метаданных, не перезаписывает строки
ALTER TABLE orders ADD COLUMN discount_pct NUMERIC DEFAULT 0;

PostgreSQL 11 изменил поведение для ADD COLUMN ... DEFAULT без NOT NULL: значение по умолчанию теперь хранится в системных таблицах и применяется при чтении, без физической перезаписи всех строк. Но NOT NULL всё равно требует сканирования таблицы для проверки — это снова блокировка.

Проблемные операции:

-- Всё это блокирует таблицу на всё время выполнения:
ALTER TABLE t ADD COLUMN c TEXT NOT NULL DEFAULT 'x';  -- перезапись строк
ALTER TABLE t ALTER COLUMN c TYPE BIGINT;               -- перезапись строк
ALTER TABLE t DROP COLUMN c;                            -- быстро, но ExclusiveLock
CREATE INDEX ON t (col);                                -- блокирует запись
ALTER TABLE t ADD CONSTRAINT ... FOREIGN KEY ...;       -- сканирование + блокировка

Expand-Contract: единственный надёжный паттерн

Суть: никогда не удаляйте и не переименовывайте что-то за один шаг. Вместо этого — три фазы деплоя.

Пример: переименовываем колонку user_nameusername.

Фаза 1: Expand (расширение) — добавляем новое, не трогаем старое:

-- Миграция: добавляем новую колонку
ALTER TABLE users ADD COLUMN username TEXT;

-- Код приложения начинает писать в обе колонки
UPDATE users SET username = user_name WHERE username IS NULL;

Фаза 2: Migrate (синхронизация) — переводим приложение на новую колонку, заполняем данные:

# Alembic-миграция: бэкфилл батчами, не одним UPDATE
def upgrade():
    op.execute("""
        DO $$
        DECLARE
            batch_size INT := 10000;
            max_id BIGINT;
            cur_id BIGINT := 0;
        BEGIN
            SELECT MAX(id) INTO max_id FROM users;
            WHILE cur_id < max_id LOOP
                UPDATE users
                SET username = user_name
                WHERE id > cur_id
                  AND id <= cur_id + batch_size
                  AND username IS NULL;
                cur_id := cur_id + batch_size;
                PERFORM pg_sleep(0.05); -- пауза между батчами
            END LOOP;
        END $$;
    """)

Фаза 3: Contract (сжатие) — когда весь трафик использует новую колонку, удаляем старую:

-- Только после того, как старый код выведен из эксплуатации
ALTER TABLE users DROP COLUMN user_name;

Между фазами — полноценные деплои. Нельзя объединять их в одну миграцию: если откатываете деплой, база должна быть совместима с предыдущей версией кода.

Online DDL в PostgreSQL: CREATE INDEX CONCURRENTLY и pg_repack

CREATE INDEX CONCURRENTLY строит индекс без блокировки записи. Делает два прохода по таблице и отслеживает изменения во время построения. Работает медленнее обычного CREATE INDEX, но не мешает production-трафику.

-- Блокирует запись на всё время построения:
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- Не блокирует запись, только два коротких ExclusiveLock в начале и конце:
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

-- Если индекс уже существует (идемпотентность):
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_user_id ON orders(user_id);

-- Аналогично для удаления:
DROP INDEX CONCURRENTLY idx_orders_user_id;

Ограничения CONCURRENTLY: нельзя использовать внутри транзакции. Alembic по умолчанию оборачивает каждую миграцию в транзакцию — нужно явно отключать:

# В файле миграции Alembic:
# alembic/versions/20250801_add_index.py

from alembic import op

# Критически важно: без этого CONCURRENTLY не работает
def upgrade():
    op.execute("COMMIT")  # завершаем автоматическую транзакцию Alembic
    op.execute(
        "CREATE INDEX CONCURRENTLY IF NOT EXISTS "
        "idx_orders_user_id ON orders(user_id)"
    )


def downgrade():
    op.execute("COMMIT")
    op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_orders_user_id")

pg_repack — расширение для перестройки таблиц и индексов без длительных блокировок. Полезно, когда таблица сильно раздута (bloat) после массовых UPDATE/DELETE, или нужно сменить тип колонки без простоя.

# Установка расширения
CREATE EXTENSION IF NOT EXISTS pg_repack;

# Перестройка таблицы без блокировок (онлайн)
pg_repack -h localhost -d mydb -t orders

# Перестройка только индексов
pg_repack -h localhost -d mydb -t orders --only-indexes

# Проверка bloat перед принятием решения
SELECT
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
    pg_size_pretty(
        pg_total_relation_size(schemaname||'.'||tablename) -
        pg_relation_size(schemaname||'.'||tablename)
    ) AS index_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 10;

Alembic: ловушки autogenerate и best practices

alembic revision --autogenerate удобен, но обманчив. Он сравнивает модели SQLAlchemy с текущей схемой БД и генерирует diff. Что он не видит:

  • Изменения в CHECK-constraints по умолчанию (зависит от версии SQLAlchemy)
  • Функции и триггеры — не часть ORM-модели
  • Партиционирование таблиц
  • Индексы, созданные вручную вне моделей
  • Различия между TEXT и VARCHAR (PostgreSQL считает их эквивалентными)

Правило: всегда просматривайте autogenerate-миграцию вручную перед применением. Никогда не делайте alembic upgrade head сразу после --autogenerate без код-ревью.

# alembic/env.py: правильная настройка для production

from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig

config = context.config
fileConfig(config.config_file_name)

target_metadata = Base.metadata


def run_migrations_online():
    connectable = engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,  # не держим соединение открытым
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,
            # Включаем сравнение типов — autogenerate заметит изменения
            compare_type=True,
            # Включаем сравнение nullable
            compare_server_default=True,
            # Транзакционные DDL: для большинства PostgreSQL-операций
            transaction_per_migration=True,
        )

        with context.begin_transaction():
            context.run_migrations()


def run_migrations_offline():
    """Offline mode: генерирует SQL без подключения к БД.
    Удобно для ревью и применения DBA вручную."""
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        compare_type=True,
    )
    with context.begin_transaction():
        context.run_migrations()


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

Offline mode — недооценённый инструмент. Генерирует чистый SQL-файл, который DBA может проверить и применить вручную в maintenance window. Для production с жёсткими требованиями к изменениям это стандарт.

# Генерация SQL без применения
alembic upgrade head --sql > migration_20250801.sql

# Проверяем что внутри, применяем вручную
cat migration_20250801.sql
psql -h prod-db -d mydb -f migration_20250801.sql

Тестирование миграций и rollback-стратегии

Большинство команд тестируют upgrade, но не downgrade. В момент инцидента выясняется, что downgrade не написан или сломан.

# tests/test_migrations.py: тест upgrade + downgrade для каждой ревизии

import pytest
from alembic.config import Config
from alembic import command
from sqlalchemy import create_engine, text


@pytest.fixture(scope="session")
def alembic_config():
    cfg = Config("alembic.ini")
    cfg.set_main_option("sqlalchemy.url", "postgresql://test:test@localhost/test_db")
    return cfg


def test_migrations_upgrade_downgrade(alembic_config):
    """Прогоняем все миграции вверх и обратно до base."""
    # Применяем все миграции
    command.upgrade(alembic_config, "head")

    # Откатываем все миграции
    command.downgrade(alembic_config, "base")

    # Снова применяем — проверяем идемпотентность
    command.upgrade(alembic_config, "head")


def test_migration_step_by_step(alembic_config):
    """Тест каждой ревизии по отдельности."""
    from alembic.script import ScriptDirectory
    from alembic.runtime.environment import EnvironmentContext

    script = ScriptDirectory.from_config(alembic_config)
    revisions = list(script.walk_revisions())

    command.downgrade(alembic_config, "base")
    for rev in reversed(revisions):
        command.upgrade(alembic_config, rev.revision)
        command.downgrade(alembic_config, rev.down_revision or "base")
        command.upgrade(alembic_config, rev.revision)

Blue-green для схемы — более радикальный подход: держите две схемы в одной БД (schema_blue и schema_green), переключайте search_path. Позволяет откатиться мгновенно без DDL-операций. Сложнее в управлении, оправдано для критических сервисов.

Стратегия rollback в порядке возрастания сложности:

# 1. Простой откат Alembic (работает, если downgrade написан)
alembic downgrade -1

# 2. Откат до конкретной ревизии
alembic downgrade 20250715_initial

# 3. Если миграция уже применена частично — восстановление из backup
# pg_restore из WAL-архива или физического backup
pg_restore --host=prod-db --dbname=mydb --verbose backup_20250801_0500.dump

# 4. Point-in-time recovery (PITR) через pg_basebackup + WAL
# Требует настроенного continuous archiving заранее

PITR — ваша страховка последнего уровня. Если он не настроен, при серьёзной ошибке в миграции потеря данных неизбежна. Настройте WAL-архивирование до того, как понадобится.


Безопасная миграция — это три деплоя вместо одного, батчинг вместо одного большого UPDATE, и downgrade, который тестируется так же строго, как upgrade. Всё остальное — оптимизация поверх этого фундамента.

← ПредыдущаяNode.js, Deno, Bun: выбор рантайма Следующая →WebSocket vs SSE vs Long Polling