lenec ru

← все посты

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

13K

Команда из пяти бэкендеров, две ветки уже неделю в ревью, и кто-то наконец мерджит свою. Через час прилетает второй мердж — и 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.

Простое правило: ломающие изменения делаются в два релиза.

  1. Релиз A: добавляем новое (новая колонка, новая таблица), код пишет и в старое, и в новое. Миграция данных идёт фоном.
  2. Релиз B: код переключается на новое полностью, старое становится мёртвым.
  3. Релиз 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. Это пятиминутная задача, которая даст самый заметный эффект. Дальше уже видно будет, куда двигаться.

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

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

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