Alembic в реальной команде: миграции без конфликтов
Команда из пяти бэкендеров, две ветки уже неделю в ревью, и кто-то наконец мерджит свою. Через час прилетает второй мердж — и Alembic ругается на multiple heads. CI красный, продовая миграция стоит, в чате паника. Знакомо?
Конфликты миграций — почти неизбежная штука, если в репо больше одного человека. Но почти все эти конфликты предсказуемы: они берутся из одних и тех же мест и решаются одними и теми же приёмами. Расскажу, как мы у себя в команде довели поток миграций до состояния, когда о них вспоминают только в день релиза.
Почему вообще случаются конфликты
Alembic хранит историю как односвязный список ревизий. У каждой ревизии есть down_revision — родитель. Когда ты делаешь alembic revision --autogenerate, текущая голова становится родителем новой ревизии. Если параллельно в другой ветке кто-то сделал то же самое, на main приходят две ревизии с одинаковым down_revision. Это и есть multiple heads.
Сам по себе случай не катастрофа: Alembic умеет сливать головы через merge-ревизию. Проблема в том, что merge-ревизия не делает ничего полезного — она просто склеивает граф. А вот реальные изменения в схеме могут конфликтовать на уровне БД: две ветки добавили колонку с одинаковым именем, переименовали одну и ту же таблицу, накатили несовместимые ограничения. Граф ты починишь за минуту, а семантический конфликт может всплыть только на стейдже, когда миграция упадёт на реальных данных.
Базовое правило: одна ревизия — одна задача
Самая частая ошибка — пихать в одну миграцию пять несвязанных изменений. Добавил колонку, заодно переименовал индекс, заодно поменял тип в другой таблице. Когда такая миграция конфликтует с чужой, разобраться, что с чем рассорилось, становится больно.
У нас правило: одна задача в Jira — одна миграция. Если фича добавляет три колонки в одну таблицу, это всё ещё одна миграция (одна логическая операция). А вот если фича трогает таблицу заказов и параллельно правит таблицу платежей — это две миграции, и они должны быть разделены.
Бонус: маленькие миграции проще ревьюить, проще откатить и проще читать через год, когда ты пытаешься вспомнить, зачем мы вообще добавили этот хитрый частичный индекс.
Слот ревизии резервируем заранее
Главный приём, который убирает 80% конфликтов: не давай Alembic самому выбирать down_revision. Делай ревизию руками с явным указанием родителя, причём родителем берёшь текущую голову main, а не свою локальную.
git fetch origin main
alembic revision --autogenerate -m "add user phone column" \
--head $(git show origin/main:alembic/versions/.head 2>/dev/null || alembic heads --resolve-dependencies | head -1)
На практике мы пошли проще: в каждом PR с миграцией разработчик пишет в описании «занимаю слот после ревизии a1b2c3». Ревьюер проверяет, что этот слот действительно свободен. Если не свободен — автор перебазирует свою миграцию: меняет down_revision в файле и переименовывает файл. Это ручная работа, но занимает минуту, и она в разы дешевле, чем разбираться с merge-ревизиями постфактум.
Хук в CI, который спасает
Самая полезная инвестиция в инфраструктуру миграций — это проверка в CI, которая падает при появлении нескольких голов. У нас это первый шаг в pipeline бэкенда:
#!/usr/bin/env bash
set -euo pipefail
heads=$(alembic heads | wc -l)
if [ "$heads" -gt 1 ]; then
echo "Multiple heads detected:"
alembic heads
exit 1
fi
# Проверяем, что миграции применяются на чистую базу
alembic upgrade head
# И откатываются обратно
alembic downgrade base
Этот скрипт ловит две вещи: появление multiple heads и кривой downgrade. Второе важно: люди ленятся писать downgrade, и в продовом инциденте выясняется, что откатиться нельзя. Если у тебя в команде это табу — пускай хотя бы CI следит, что код в downgrade хотя бы синтаксически работает на пустой базе.
Как мы пишем сами миграции
Autogenerate — полезная штука, но смотреть на её результат глазами обязательно. SQLAlchemy не всегда корректно ловит изменения в server defaults, типах enum, частичных индексах. Если ты увидел в diff что-то странное — скорее всего, autogenerate ошибся, а не ты.
Пример из жизни. Мы добавили server_default=text("now()") к существующей колонке. Autogenerate выдал такое:
def upgrade() -> None:
op.alter_column(
"orders",
"created_at",
existing_type=sa.DateTime(timezone=True),
server_default=sa.text("now()"),
existing_nullable=False,
)
Выглядит безобидно. На стейдже всё прошло. На проде миграция повисла, потому что таблица orders на 200 миллионов строк, и Postgres под каждое ALTER COLUMN ... SET DEFAULT снимает AccessExclusiveLock, плюс на старых версиях бывает full table rewrite. Тут уже не про конфликты, а про то, что миграцию надо читать и понимать, что она делает на реальной базе.
С тех пор у нас есть короткий чек-лист в шаблоне PR с миграцией:
- Что блокирует — какие
LOCK-уровни берутся? - Сколько примерно строк затронет?
- Можно ли разбить на несколько шагов с временной совместимостью старого и нового кода?
- Есть ли осмысленный
downgrade?
Опасные операции и двухфазный подход
Удаление колонки, переименование таблицы, смена типа — это всё операции, которые ломают совместимость со старым кодом. Если ты деплоишь миграцию и приложение одновременно — у тебя есть окно в несколько секунд, когда новая миграция уже накатилась, а часть подов ещё работает на старом коде. И этот старый код будет валиться с UndefinedColumn.
Простое правило: ломающие изменения делаются в два релиза.
- Релиз A: добавляем новое (новая колонка, новая таблица), код пишет и в старое, и в новое. Миграция данных идёт фоном.
- Релиз B: код переключается на новое полностью, старое становится мёртвым.
- Релиз C: дропаем старое.
Да, это три деплоя вместо одного. Но альтернатива — даунтайм или ночная миграция с feature freeze. У нас этого нет уже два года, и это того стоит.
Куда складывать миграции в монорепе
Если у тебя несколько сервисов в одном репозитории, не клади все миграции в общий alembic/versions. Каждый сервис — свой alembic.ini, свой env.py, свой каталог ревизий. Иначе ты получишь конфликты не только внутри команды, но и между командами разных сервисов, у которых нет общей картины схемы.
Структура примерно такая:
services/
orders/
alembic.ini
alembic/
env.py
versions/
users/
alembic.ini
alembic/
env.py
versions/
Если базы общие, а сервисы разные — это уже архитектурная проблема, которую миграциями не решить. Сначала разделите данные, потом возвращайтесь к Alembic.
Как разрулить multiple heads, когда они уже есть
Если конфликт всё-таки прорвался в main, действуй по шагам. Сначала смотришь головы:
alembic heads
Видишь две ревизии. Дальше два варианта.
Вариант 1: перебазировать одну ветку поверх другой. Подходит, если изменения независимы и одну из миграций ещё не катали ни на одном окружении. Открываешь файл ревизии, меняешь down_revision на хвост другой ветки, переименовываешь файл (для аккуратности). Проверяешь alembic upgrade head на чистой базе. Это самый чистый способ.
Вариант 2: merge-ревизия. Подходит, если обе миграции уже накатили в каком-нибудь окружении. Тогда переписать историю нельзя — придётся склеивать.
alembic merge -m "merge feature-x and feature-y heads" head1 head2
Появится пустая ревизия с двумя down_revision. Дальше всё штатно: alembic upgrade head.
В обоих случаях нужно глазами проверить семантику: не конфликтуют ли изменения на уровне схемы. Alembic про это ничего не знает.
Что мы вынесли для себя
Миграции — это не история про инструмент, а история про процесс. Alembic делает ровно то, о чём его просят, и хорошо логирует свою работу. Все боли вокруг него — это боли несогласованной работы команды: никто не сказал, что меняет таблицу, никто не проверил, как ляжет на проде, никто не потрудился написать downgrade.
У нас работающая комбинация выглядит так: маленькие миграции по одной задаче, явная резервация слота в PR, CI с проверкой на multiple heads и тестовый прогон upgrade/downgrade, двухфазный подход для ломающих изменений и обязательный код-ревью на содержание миграции, а не только на python-код. Конфликтов почти не осталось, а те, что случаются, разруливаются за пять минут вместо полудня.
Если у вас сейчас миграции — больная тема, не пытайтесь сразу всё внедрить. Начните с CI-проверки на multiple heads. Это пятиминутная задача, которая даст самый заметный эффект. Дальше уже видно будет, куда двигаться.