lenec ru

← все посты

Alembic в реальной команде: миграции без конфликтов merge

13K

Alembic в одиночной разработке — простая штука: alembic revision --autogenerate, alembic upgrade head, поехали. Но как только в репозитории появляются ещё трое разработчиков, начинается боль: две ветки одновременно создают ревизии, в main приезжает merge, и при upgrade head прилетает Multiple head revisions are present. Это не баг Alembic, это нормальная ситуация — но команда должна заранее договориться, как её разруливать.

Ниже — мой набор правил, который мы за пару лет довели до состояния «новый человек в команде ни разу не сломал базу». Без магии, без своих обёрток, всё на стандартном Alembic.

Как возникает конфликт миграций

Граф ревизий в Alembic — это связный список с одним головой (head). Когда ты делаешь alembic revision -m "add user table", Alembic смотрит текущий head, ставит его в down_revision новой ревизии и делает её новым head.

Теперь представь параллельную работу. Маша в ветке feat/orders создаёт ревизию a1b2c3 на базе текущего head=000abc. В то же время Петя в ветке feat/payments создаёт d4e5f6 тоже на базе 000abc. Обе ревизии имеют одинаковый down_revision. Когда обе ветки мерджатся в main, граф разветвляется — появляются две головы.

Команда alembic upgrade head в этот момент падает с ошибкой:

ERROR [alembic.util.messaging] Multiple head revisions are present for given argument 'head'; please specify a specific target revision, '<branchname>@head' to narrow to a specific head, or 'heads' for all heads

Что делать? Создавать merge-ревизию.

Merge-ревизия: как и зачем

Alembic умеет объединять две головы одной командой:

alembic merge -m "merge orders and payments" a1b2c3 d4e5f6

Это создаёт пустую (без операций) ревизию, у которой down_revision = ("a1b2c3", "d4e5f6"). После этого upgrade head снова работает: сначала применяется одна ветка, потом вторая, потом merge-ревизия (которая ничего не делает, просто склеивает граф).

Звучит просто. Но в реальной команде merge-ревизии плодятся как кролики, и через полгода история выглядит так, что в ней без поллитра не разберёшься. Поэтому правило номер один:

Merge-ревизии — это нормально, но они должны создаваться тем, кто мерджит ветку в main, а не отдельным коммитом «потом».

Правило 1: один разработчик — одна активная миграция

В фиче ты обычно создаёшь одну-две миграции. Правило: пока ветка не вмерджена, ты не делаешь git pull origin main и не пытаешься «синхронизировать» миграции вручную, переписывая down_revision. Так делать не надо никогда. Если в main что-то приехало, ты:

  1. Делаешь git rebase origin/main.
  2. Если в main появилась миграция, которая стала новым head, ты пересоздаёшь свою миграцию.

Пересоздать — значит не переименовать файл, а реально удалить и сгенерировать заново:

rm migrations/versions/abc123_add_orders.py
alembic revision --autogenerate -m "add orders"

Это безопасно, если у тебя нет данных в локальной базе, которые нужны. На CI и в проде ничего не сломается, потому что миграция ещё не была применена нигде, кроме твоей dev-базы.

Альтернатива — рисковать тем, что ты вручную выставишь down_revision и забудешь обновить ID или ссылку в комментарии. Я видел такое в проде. Не делай так.

Правило 2: автогенерация — это черновик, не финал

Команда alembic revision --autogenerate сравнивает метаданные SQLAlchemy с актуальной базой и пытается выдать миграцию. Она работает хорошо для простых случаев — добавил колонку, удалил таблицу. Но у неё есть слепые зоны:

  • Не видит переименования. Колонка name переименованная в full_name — это DROP COLUMN name и ADD COLUMN full_name. Данные потеряны.
  • Не различает NULL с дефолтом и без. Иногда генерирует server_default=None, что после ревью выглядит ок, но в проде оказывается, что колонка не может быть NOT NULL без дефолта при наличии данных.
  • Не видит изменений в CheckConstraint и custom ServerDefault.
  • Криво работает с Enum в Postgres: при изменении значений выдаёт ALTER TYPE, который в транзакции не выполнится.

Поэтому правило: автогенерация — это первая версия. После неё ты обязательно читаешь файл миграции глазами, правишь, дописываешь руками. Особенно для прода — миграция всегда должна быть совместимой со старым приложением (rolling deploy без даунтайма).

Правило 3: миграция должна быть безопасной для прода

Тут начинается самое интересное. Postgres умеет почти всё в транзакции, но некоторые операции блокируют таблицу надолго или вообще не работают:

  • ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT 'x' на большой таблице — длинная блокировка (в старых версиях Postgres переписывает таблицу целиком, в современных всё ещё блокирует на чтение метаданных при коротком окне).
  • CREATE INDEX без CONCURRENTLY блокирует записи в таблицу.
  • ALTER TYPE ... ADD VALUE для enum нельзя в транзакции.
  • Любой DROP COLUMN в Postgres дёшев (метаданные), но если приложение продолжает писать в эту колонку — упадёт.

Правило: разделяй миграции по фазам.

  1. Expand: добавляешь новую структуру (колонку, таблицу, индекс) совместимо со старым кодом.
  2. Migrate: код пишет в обе структуры (старую и новую), фоновая задача переносит данные.
  3. Contract: убираешь старое.

Это три отдельных релиза с тремя отдельными миграциями. Не объединяй их в одну.

Правило 4: индексы — отдельной миграцией с CONCURRENTLY

Создание индекса на боевой таблице без CONCURRENTLY блокирует запись. На таблице в 200 миллионов строк это означает «база лежит 20 минут». Проверено.

Alembic поддерживает CREATE INDEX CONCURRENTLY, но операция не работает внутри транзакции. Поэтому в миграции делаешь так:

from alembic import op

# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "000abc111222"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.execute("COMMIT")
    op.execute(
        "CREATE INDEX CONCURRENTLY IF NOT EXISTS "
        "ix_orders_user_id_created_at "
        "ON orders (user_id, created_at DESC)"
    )


def downgrade() -> None:
    op.execute("COMMIT")
    op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_orders_user_id_created_at")

Дополнительно нужно отключить транзакцию для миграции, иначе Alembic откатит всё в случае ошибки:

# в env.py или в самом файле миграции
def run_migrations_online():
    ...
    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,
            transaction_per_migration=True,
        )
        ...

С transaction_per_migration=True каждая миграция получает свою транзакцию, и ту, где есть CONCURRENTLY, проще всего вынести в отдельную ревизию, где основная операция — этот индекс, и ничего больше.

Правило 5: тесты на миграции

Это то, что обычно не делают, а зря. Минимальный тест на миграции — это:

  1. Поднять чистую базу.
  2. Прогнать alembic upgrade head.
  3. Прогнать alembic downgrade base.
  4. Снова upgrade head.

Если на любом шаге упало — миграции сломаны. Это ловит большую часть проблем с downgrade, которые иначе всплывают только в проде, когда что-то срочно надо откатить.

import asyncio
import pytest
from alembic.config import Config
from alembic import command
from sqlalchemy.ext.asyncio import create_async_engine


@pytest.fixture(scope="session")
def alembic_config(tmp_path_factory) -> Config:
    cfg = Config("alembic.ini")
    cfg.set_main_option("sqlalchemy.url", TEST_DB_URL)
    return cfg


def test_migrations_up_down(alembic_config: Config) -> None:
    command.upgrade(alembic_config, "head")
    command.downgrade(alembic_config, "base")
    command.upgrade(alembic_config, "head")

На больших проектах downgrade до base может занимать минуты. Тогда делай тест умнее: запоминай голову до миграции, накатывай новую ревизию, откатывай на одну назад, накатывай снова. Я обычно в CI на каждый PR запускаю такой тест на тех ревизиях, которые добавлены в этом PR.

Правило 6: data-миграции — отдельный разговор

Иногда нужно не только поменять схему, но и переложить данные. Например, разделить колонку full_name на first_name и last_name. Соблазн — сделать всё в одной миграции:

def upgrade() -> None:
    op.add_column("users", sa.Column("first_name", sa.String()))
    op.add_column("users", sa.Column("last_name", sa.String()))
    op.execute("""
        UPDATE users SET
            first_name = split_part(full_name, ' ', 1),
            last_name = split_part(full_name, ' ', 2)
    """)
    op.drop_column("users", "full_name")

На таблице из миллиона строк UPDATE заблокирует таблицу на минуты. И это всё в одной транзакции с DDL — если в середине что-то пойдёт не так, ты получишь долгий rollback.

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

Правило 7: alembic.ini и env.py живут в репозитории, но не трогают прод-конфиг

В alembic.ini по умолчанию sqlalchemy.url прописан строкой. В реальном проекте ты не хочешь хранить пароль в репозитории. Один из вариантов:

# env.py
import os
from alembic import context
from myapp.config import settings

config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)

Тогда alembic.ini не содержит реального URL, а Alembic берёт его из переменных окружения через settings. Если у тебя async-приложение на SQLAlchemy 2.0, обрати внимание: Alembic по умолчанию работает синхронно. Можно прописать драйвер postgresql+psycopg вместо postgresql+asyncpg только для миграций — это проще, чем городить async env.py. Если хочется async — в Alembic 1.13+ есть готовый шаблон async.

Что обычно ломается в команде

Самые частые косяки, которые я видел:

  • Кто-то правит уже применённую в проде миграцию (например, исправляет опечатку). После этого alembic_version в проде не совпадает с тем, что в коде, и следующий upgrade может пройти криво. Правило: применённую в проде миграцию никогда не редактируем. Если есть ошибка — новая миграция, которая её исправляет.
  • Кто-то делает alembic stamp head на проде, чтобы «починить» расхождение. Это работает, если ты на 100% уверен, что схема в базе совпадает со схемой, описанной в моделях. Если не уверен — не надо.
  • Кто-то коммитит файл миграции, забыв обновить down_revision у соседней. Поэтому в pre-commit хук добавь проверку alembic check — она ловит расхождения между моделями и миграциями (но не все).

Ещё одна полезная вещь — линтер для миграций. Готового идеального нет, но squawk для Postgres-совместимых SQL-файлов умеет ругаться на опасные операции типа ALTER TABLE ... ADD COLUMN ... NOT NULL. Можно подключить как pre-commit или как шаг CI.

Что запомнить

Alembic — это инструмент, а правила работы с ним — это договорённости команды. Без договорённостей ты получишь хаос даже на самом продвинутом инструменте. Минимум, на котором стоит остановиться:

  • Конфликты решаются alembic merge, а не правкой down_revision.
  • Автогенерация — черновик, всегда читай файл руками.
  • Большие изменения — три фазы (expand/migrate/contract), не одна миграция.
  • Индексы на проде — только CREATE INDEX CONCURRENTLY, в отдельной миграции.
  • Тесты upgrade/downgrade в CI — обязательны.
  • Применённые в проде миграции не редактируем никогда.

Куда копать дальше: документация Alembic по branch_labels и depends_on — иногда удобно, если у вас в монорепо несколько подсистем со своими ветками миграций. И обязательно почитай про zero-downtime миграции в Postgres — там много нюансов, которые в Alembic-документации не описаны, но влияют на то, как ты пишешь миграцию.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.