Legan Studio
Все статьи
~ 7 мин чтения

Миграции БД для бота MAX: Alembic, безопасный rollout и блокировки

Как делать миграции PostgreSQL для бота MAX без даунтайма: expand-contract, безопасные ALTER, миграции данных, Alembic, обратная совместимость, типичные ошибки.

  • MAX
  • PostgreSQL
  • DevOps

Миграция базы — самый рискованный момент жизни бота. Один ALTER TABLE messages ADD COLUMN ... NOT NULL на таблице с 50 млн строк — и бот лежит 40 минут. Один наивный DROP COLUMN — и старая версия приложения, ещё крутящаяся на канареечной ноде, падает с 500. В этой статье разберём прикладную стратегию миграций PostgreSQL для бота MAX: Alembic как стандарт, паттерн expand-contract для zero-downtime изменений схемы, безопасные ALTER, миграция больших данных батчами, обратная совместимость во время rolling deploy, тестирование и rollback.

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

Особенности контекста:

  • Бот работает 24/7, окно maintenance — миф. Пользователи пишут в час ночи и ожидают ответ.
  • Пиковая нагрузка непредсказуема — массовая рассылка или вирусная история взрывают трафик в 10 раз.
  • Долгие блокировки таблицы users или messages означают timeout webhook'а, ретраи платформы, дубликаты.
  • Изменение схемы FSM-state (если он в БД) ломает живые диалоги.
  • Откат миграции часто невозможен — данные уже изменились в новой структуре.

Поэтому золотое правило: миграции не должны блокировать таблицу больше чем на 100–200 мс.

Инструмент: Alembic

В Python-стеке (FastAPI/aiogram + SQLAlchemy) стандарт — Alembic. В Go — golang-migrate или goose. Принципы одинаковые.

alembic.ini минимум:

[alembic]
script_location = migrations
sqlalchemy.url = postgresql+psycopg://botmax:secret@localhost/botmax

[loggers]
keys = root,sqlalchemy,alembic

Шаблон миграции:

# migrations/versions/2026_01_23_add_users_phone.py
"""add users.phone column

Revision ID: 7c1e5b8a4f12
Revises:    f3a2c7e9d845
Create Date: 2026-01-23 10:15:00
"""
from alembic import op
import sqlalchemy as sa

revision = "7c1e5b8a4f12"
down_revision = "f3a2c7e9d845"

def upgrade() -> None:
    op.add_column("users", sa.Column("phone", sa.String(20), nullable=True))
    op.create_index("ix_users_phone", "users", ["phone"])

def downgrade() -> None:
    op.drop_index("ix_users_phone", table_name="users")
    op.drop_column("users", "phone")

Это безопасная миграция — добавление nullable-колонки и индекса. Опасное появляется, когда добавляем NOT NULL без дефолта или меняем тип.

Опасные операции и их безопасные эквиваленты

ОпасноБезопасно
ALTER TABLE users ADD COLUMN x INT NOT NULL DEFAULT 0 (PG < 11)PG ≥ 11 безопасно благодаря fast default
ALTER TABLE users ADD COLUMN x INT NOT NULL (без default)сначала ADD nullable, потом backfill, потом SET NOT NULL
CREATE INDEX ON users(...)CREATE INDEX CONCURRENTLY ...
DROP INDEX ix_xDROP INDEX CONCURRENTLY ix_x
ALTER TABLE users ALTER COLUMN email TYPE TEXTновая колонка + триггер копирования + переключение
DROP COLUMNсначала перестать читать/писать в коде, потом drop в отдельной миграции
RENAME COLUMNновая колонка + триггер + переключение + дроп старой

Expand-contract: классика zero-downtime

Любое разрушительное изменение делается в три развёртывания:

  1. Expand: добавить новую структуру параллельно старой. Код пишет в обе.
  2. Migrate: перенести данные в новую структуру.
  3. Contract: код переходит на новую структуру, удалить старую.

Пример: переименование users.tg_idusers.messenger_user_id.

Шаг 1 — expand (release N):

def upgrade():
    op.add_column("users", sa.Column("messenger_user_id", sa.BigInteger(), nullable=True))
    op.create_index("ix_users_messenger_user_id", "users", ["messenger_user_id"])

В коде:

class User(Base):
    tg_id = Column(BigInteger, index=True)
    messenger_user_id = Column(BigInteger, index=True)

    @property
    def user_id(self) -> int:
        return self.messenger_user_id or self.tg_id

    @user_id.setter
    def user_id(self, v: int):
        self.tg_id = v
        self.messenger_user_id = v

Шаг 2 — backfill (release N+1):

def upgrade():
    op.execute("""
        UPDATE users SET messenger_user_id = tg_id
        WHERE messenger_user_id IS NULL
    """)
    op.alter_column("users", "messenger_user_id", nullable=False)

Для большой таблицы (50M+ строк) backfill батчами, а не одним UPDATE — об этом ниже.

Шаг 3 — contract (release N+2):

def upgrade():
    op.drop_index("ix_users_tg_id", table_name="users")
    op.drop_column("users", "tg_id")

В коде убираем tg_id. Между релизами обязательно ждать, пока выкатится 100% инстансов и проверить логи.

Большие backfill: батчи

UPDATE users SET messenger_user_id = tg_id на 50M строк забирает таблицу на 20 минут и душит вакуум. Правильно — батчами:

def upgrade():
    conn = op.get_bind()
    BATCH = 5000
    while True:
        result = conn.execute(sa.text("""
            WITH batch AS (
                SELECT id FROM users
                WHERE messenger_user_id IS NULL
                ORDER BY id
                LIMIT :n
                FOR UPDATE SKIP LOCKED
            )
            UPDATE users u
            SET messenger_user_id = u.tg_id
            FROM batch b
            WHERE u.id = b.id
        """), {"n": BATCH})
        if result.rowcount == 0:
            break

Ещё лучше — вынести backfill в отдельный воркер или отдельную миграцию данных, которая не блокирует основной деплой:

# В пайплайне:
alembic upgrade head            # быстрая schema-миграция
python -m migrations.backfill_messenger_user_id    # медленный backfill в фоне
alembic upgrade head            # contract-миграция, после того как backfill завершён

CONCURRENTLY — обязательный модификатор для индексов

-- Плохо: лочит таблицу
CREATE INDEX ix_orders_status ON orders(status);

-- Хорошо: онлайн, не блокирует чтение/запись
CREATE INDEX CONCURRENTLY ix_orders_status ON orders(status);

Alembic так умеет:

def upgrade():
    with op.get_context().autocommit_block():
        op.create_index(
            "ix_orders_status", "orders", ["status"],
            postgresql_concurrently=True,
        )

autocommit_block обязателен — CREATE INDEX CONCURRENTLY нельзя в транзакции.

Lock timeout и statement timeout в миграциях

Чтобы случайная блокировка не уронила бота, оборачивайте миграции:

def upgrade():
    op.execute("SET LOCAL lock_timeout = '5s'")
    op.execute("SET LOCAL statement_timeout = '60s'")
    op.add_column("users", sa.Column("phone", sa.String(20), nullable=True))

Если получить блокировку не удаётся за 5 секунд — миграция падает, не уронив бота.

Многошаговые миграции с проверками

def upgrade():
    conn = op.get_bind()

    # 1. Проверяем, что нет конфликтующих данных
    bad = conn.execute(sa.text("""
        SELECT count(*) FROM users
        WHERE phone IS NOT NULL AND phone !~ '^\\+?\\d{10,15}$'
    """)).scalar()
    if bad > 0:
        raise RuntimeError(f"Cannot add CHECK: {bad} rows have invalid phone")

    # 2. Добавляем CHECK
    op.create_check_constraint(
        "ck_users_phone_format",
        "users",
        "phone IS NULL OR phone ~ '^\\+?\\d{10,15}$'",
    )

Лучше падать на этапе миграции, чем оставить мусор в данных.

Миграции FSM-state

Если состояние диалога хранится в БД (а не в Redis), миграция должна сохранять открытые диалоги. Например, переход с одной структуры state на другую:

def upgrade():
    # Старая структура: state TEXT, payload JSONB
    # Новая: state_name TEXT, state_data JSONB
    op.add_column("user_state", sa.Column("state_name", sa.Text(), nullable=True))
    op.add_column("user_state", sa.Column("state_data", postgresql.JSONB(), nullable=True))

    op.execute("""
        UPDATE user_state
        SET state_name = state,
            state_data = COALESCE(payload, '{}'::jsonb)
        WHERE state_name IS NULL
    """)

    op.alter_column("user_state", "state_name", nullable=False)
    op.alter_column("user_state", "state_data", nullable=False, server_default="{}")

В коде на N+1 релизе сначала умеем читать обе структуры, потом только новую.

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

CI должен прогонять миграции на копии прода. Минимум:

# GitLab CI
test-migrations:
  image: postgres:16
  services:
    - name: postgres:16
      alias: pg
  variables:
    POSTGRES_DB: botmax_test
    POSTGRES_PASSWORD: test
  script:
    - pip install -r requirements.txt
    - psql -h pg -U postgres botmax_test < tests/fixtures/sample_dump.sql
    - alembic upgrade head
    - alembic downgrade -1
    - alembic upgrade head
    - python -m pytest tests/migrations/

Sample dump должен содержать данные с edge-case'ами, которые могут сломать миграцию.

Rollback стратегия

Идеальный downgrade редко возможен — данные уже изменились. Реалистичные стратегии:

  1. Forward fix: вместо отката — новая миграция, исправляющая поломку. Это чаще быстрее и безопаснее.
  2. Snapshot до миграции: для критичных миграций — pg_basebackup или snapshot диска перед деплоем. Откат = restore.
  3. Feature flag: новая логика включается флагом. При проблеме — выключаем флаг, миграция остаётся.
  4. Blue/green БД: две БД с двусторонней репликацией. При сбое переключаем DNS обратно. Дорого, оправдано для финтеха.

Миграции в Docker / Kubernetes

В монорепо с Docker Compose:

services:
  migrate:
    build: ./backend
    command: alembic upgrade head
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy

docker compose run --rm migrate перед основным деплоем.

В Kubernetes — Job с initContainer-style паттерном:

apiVersion: batch/v1
kind: Job
metadata:
  name: botmax-migrate-2026-01-23
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.example.ru/botmax-backend:1.4.2
          command: ["alembic", "upgrade", "head"]
          envFrom:
            - secretRef:
                name: botmax-env

Argo CD/Helm поднимает Job, ждёт успешного завершения, только потом обновляет Deployment бота.

Common pitfalls

  1. Миграция через SQLAlchemy.create_all() в проде — теряете контроль за изменениями.
  2. NOT NULL без дефолта на большой таблице — table rewrite, downtime.
  3. DROP COLUMN до выкатки всех нод — старые ноды падают.
  4. CASCADE без понимания, что отсыпется — потеряли половину foreign keys.
  5. Миграция без транзакции — упала на середине, БД в полусостоянии.
  6. Backfill в одной транзакции на 50M строк — лог реплики раздувается, slave отстаёт.
  7. Нет lock_timeout — миграция висит часами.
  8. Не проверили миграцию на копии прода — на тестовых 1000 строках всё летает, на 50M — 40 минут lock.

Итого

Миграции бота MAX делайте через Alembic с обязательным паттерном expand-contract: добавили — забэкфилили — переключили — удалили, в три отдельных релиза. Опасные операции (ADD COLUMN NOT NULL, изменение типа, переименование) разбивайте на безопасные шаги. Индексы — только CONCURRENTLY. Большой backfill — батчами по 5000 с FOR UPDATE SKIP LOCKED, лучше отдельным воркером. В каждой миграции SET LOCAL lock_timeout = '5s'. CI прогоняет миграции на копии прода. Перед критичной миграцией — snapshot. Откат — чаще через forward fix или feature flag, чем downgrade. С таким подходом бот в MAX переживёт сотни миграций без даунтайма и потерянных диалогов.

Частые вопросы

Что такое expand-contract и зачем разбивать миграцию на 3 релиза?

Expand-contract — стандартный паттерн zero-downtime миграции. В первом релизе (expand) добавляете новую структуру параллельно старой, код пишет в обе. Во втором — переносите данные и переключаете чтение на новую. В третьем (contract) удаляете старую структуру. Это нужно потому, что во время rolling deploy несколько секунд–минут одновременно крутятся старая и новая версии кода — обе должны работать с актуальной схемой. Прямой rename column в одном релизе уронит старые поды.

Почему CREATE INDEX CONCURRENTLY обязателен в проде?

Обычный CREATE INDEX берёт SHARE-лок на таблицу — чтение разрешено, но запись блокируется до конца индексации. На таблице 50 млн строк это 5–20 минут даунтайма для записи. CREATE INDEX CONCURRENTLY работает в фоне без блокировки записи — может быть в 2–3 раза медленнее по wall time, но не ломает прод. Минусы — нельзя в транзакции, может оставить «invalid» индекс при падении (его нужно дропнуть и пересоздать), занимает больше места во время сборки.

Как мигрировать большую таблицу без блокировок?

Делайте backfill батчами по 1000–5000 строк через WITH ... LIMIT FOR UPDATE SKIP LOCKED. Это даёт короткие транзакции, которые не душат вакуум и репликацию. Между батчами — sleep 50–100ms, чтобы не съесть весь IO. Лучше вынести backfill в отдельный воркер или отдельный шаг пайплайна, чтобы он не блокировал основной деплой. Только после завершения backfill делать contract-миграцию (SET NOT NULL, drop старого столбца).

Зачем lock_timeout в миграции?

Без lock_timeout миграция, не получив лок (потому что какая-то транзакция держит таблицу), будет ждать сколько угодно — часы. Все новые транзакции, которые ждут эту миграцию, тоже встанут в очередь, бот ляжет. SET LOCAL lock_timeout = '5s' заставит миграцию упасть через 5 секунд, если лок недоступен. Лучше упасть и разобраться, чем уронить прод. statement_timeout '60s' — страховка от слишком долгих ALTER, которые проскочили ревью.

Можно ли откатить миграцию в Alembic через downgrade?

Технически alembic downgrade -1 это умеет, но в проде на боте с миллионами записей чаще всего нерабочий путь: данные уже преобразованы, старый код их не понимает, или новые столбцы заполнены значениями, не вписывающимися в старую схему. Реалистичные стратегии rollback: forward fix (новая миграция, исправляющая поломку), snapshot БД до миграции (восстановление в случае катастрофы), feature flag (новая логика включается флагом, при проблеме отключаете флаг).

Где запускать alembic upgrade в Kubernetes?

Идиома — отдельный Job в Helm chart с helm.sh/hook: pre-upgrade. Argo CD/Helm создаёт Job, дожидается успешного завершения, и только потом обновляет Deployment бота. Если миграция упала — Deployment не обновляется, на проде остаётся старая версия. Альтернатива — initContainer в самом Deployment, но тогда параллельные поды могут одновременно стартовать миграцию, что чревато дедлоками. Job c parallelism: 1 и backoffLimit: 0 — самый предсказуемый вариант.

Как тестировать миграции в CI?

В CI поднимайте Postgres-контейнер, заливайте sample dump (или anonymized snapshot прода), прогоняйте alembic upgrade head, потом downgrade -1, потом снова upgrade head — проверяете идемпотентность. Дополнительно — функциональные тесты после миграции: убеждаетесь, что бот продолжает корректно создавать заказы, читать FSM-state, отправлять рассылки. Sample dump должен содержать edge-case-данные: пустые JSONB, длинные строки, NULL в полях, которые станут NOT NULL — это ловит большую часть ошибок.