Alembic в реальной команде: миграции без конфликтов merge
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 что-то приехало, ты:
- Делаешь
git rebase origin/main. - Если в 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и customServerDefault. - Криво работает с
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 дёшев (метаданные), но если приложение продолжает писать в эту колонку — упадёт.
Правило: разделяй миграции по фазам.
- Expand: добавляешь новую структуру (колонку, таблицу, индекс) совместимо со старым кодом.
- Migrate: код пишет в обе структуры (старую и новую), фоновая задача переносит данные.
- 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: тесты на миграции
Это то, что обычно не делают, а зря. Минимальный тест на миграции — это:
- Поднять чистую базу.
- Прогнать
alembic upgrade head. - Прогнать
alembic downgrade base. - Снова
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-документации не описаны, но влияют на то, как ты пишешь миграцию.