← назад к разделу

Модель поменялась, в таблице нужна новая колонка. Кто её создаст? Не Base.metadata.create_all: он умеет создавать таблицы с нуля и не умеет менять существующие. Для изменений схемы в проекте на SQLAlchemy берут Alembic, и его главный приём, автогенерация, одновременно и самый полезный, и самый опасный: он пишет черновик миграции по разнице между моделями и базой, а дальше думает человек.

Обязательно

Устройство

alembic init migrations создаёт каталог с env.py (как подключиться к базе и откуда взять модели), script.py.mako (шаблон файла миграции) и папкой versions/ для ревизий. Настройки в alembic.ini или, с Alembic 1.16 и новее, в pyproject.toml через шаблон pyproject.

В env.py две вещи, без которых автогенерация слепа: target_metadata = Base.metadata и импорт всех модулей с моделями, чтобы они успели зарегистрироваться в Base. Адрес базы берут из тех же настроек, что и приложение, а не дублируют в alembic.ini:

from app.settings import get_settings
from app.models import Base

config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata

Каждая ревизия это файл с revision, down_revision, функциями upgrade() и downgrade(). Текущее положение базы Alembic хранит в таблице alembic_version.

Автогенерация и что она не видит

alembic revision --autogenerate -m "order: add shipped_at"
alembic upgrade head

Alembic сравнивает Base.metadata с реальной схемой и пишет операции: добавить колонку, создать индекс, удалить таблицу. Этот файл читают целиком перед коммитом, потому что сравнение знает не всё.

Переименование колонки или таблицы автогенерация видит как «удалить старое, создать новое», и миграция уничтожит данные. Изменение типа колонки, длины VARCHAR или server_default не замечается по умолчанию; включается параметрами compare_type=True и compare_server_default=True в context.configure, и даже тогда с оговорками. Изменения значений нативного ENUM не видны вовсе. CHECK-ограничения распознаются частично. Таблицы, которых нет в Base.metadata (служебные, созданные руками, принадлежащие другим сервисам в той же базе), автогенерация предложит удалить, и include_object в env.py должен их отфильтровать.

Поэтому черновик правят руками: переименование записывают как op.alter_column(..., new_column_name=...), лишние удаления убирают, а данные переносят отдельной операцией. И перед коммитом прогоняют alembic check: он падает, если модели и миграции разошлись, и его ставят в CI рядом с тестами.

Расширение и сжатие вместо ломающих изменений

При выкате без простоя в какой-то момент работают одновременно старая и новая версии приложения. Миграция, которая удаляет или переименовывает колонку, ломает старую версию, пока та ещё обслуживает запросы. Поэтому ломающие изменения делают в три шага, которые называют расширением и сжатием.

Переименовать total в amount: первая миграция добавляет amount и, если нужно, триггер или двойную запись из кода; вторая версия приложения пишет в обе и читает из новой; третья миграция удаляет total, когда ни одна живая версия её не читает. Удалить колонку: сначала версия кода, которая её не трогает, потом миграция удаления. Сделать колонку обязательной: сначала добавить с nullable=True, заполнить, потом NOT NULL через CHECK ... NOT VALID и VALIDATE, чтобы не держать блокировку на всю таблицу.

Это удлиняет изменение на два выката, зато ни один из них не роняет прод. Подробный разбор на стороне базы в статье про миграции PostgreSQL.

Операции, которые блокируют

Alembic выполняет миграцию в транзакции, и это удобно: ошибка посередине откатит всё. Но некоторые операции PostgreSQL в транзакции либо невозможны, либо опасны.

CREATE INDEX без CONCURRENTLY блокирует запись в таблицу на всё время построения; с CONCURRENTLY не блокирует, но не может выполняться в транзакции. В Alembic это отдельная ревизия с отключённой транзакцией:

def upgrade() -> None:
    with op.get_context().autocommit_block():
        op.create_index("ix_orders_customer_id", "orders", ["customer_id"], postgresql_concurrently=True)

ALTER TABLE ... ADD COLUMN с умолчанием в PostgreSQL 11 и новее мгновенный, а вот ADD COLUMN ... NOT NULL без умолчания на непустой таблице упадёт. ALTER COLUMN TYPE переписывает таблицу целиком под эксклюзивной блокировкой, на большой таблице это минуты простоя записи, и его заменяют новой колонкой с переносом. Перед любым ALTER TABLE ставят op.execute("SET LOCAL lock_timeout = '3s'"): если таблицу держит долгий запрос, миграция упадёт через три секунды, а не выстроит за собой очередь из всех запросов приложения.

Какие блокировки берёт каждая операция, разбирает статья про блокировки PostgreSQL.

Данные в миграции

Миграция схемы и миграция данных это разные вещи, даже если Alembic позволяет написать op.execute("UPDATE ..."). Обновление миллиона строк одним выражением в транзакции миграции держит блокировки и раздувает WAL. Большие переносы делают пакетами по первичному ключу из кода приложения или отдельного задания, а миграция только готовит колонку.

Миграции данных небольшого объёма (справочники, заполнение новой колонки на тысячах строк) допустимы, но пишут их через Core, а не через модели приложения: модель через год изменится, а миграция должна выполняться как в день написания. В файле ревизии объявляют лёгкую sa.table(...) с нужными колонками и работают с ней.

Ветки, слияние и запуск при выкате

Два разработчика сделали миграции от одной ревизии, и после слияния веток у Alembic две головы. alembic heads покажет обе, alembic merge -m "merge" <rev1> <rev2> создаст пустую ревизию с двумя родителями. В CI полезна проверка, что голова одна: alembic heads выводит одну строку.

Где запускать alembic upgrade head при выкате: не при старте каждого пода. Несколько подов стартуют одновременно, и хотя Alembic держит alembic_version под блокировкой, гонка остаётся неприятной, а под, упавший на миграции, уходит в перезапуск вместе с остальными. Миграцию запускают отдельным шагом конвейера или Job в Kubernetes перед сменой образа подов, и приложение стартует только после её успеха. Если шаг упал, выкат останавливается, а старые поды продолжают работать на старой схеме, которая по правилу расширения совместима с ними.

downgrade() пишут, но на него не рассчитывают: откат схемы после того, как в новую колонку попали данные, это потеря данных. Откат в проде это новая миграция вперёд, которая чинит проблему.

Дополнительно: при первом чтении можно пропустить

Глубже: асинхронный движок и несколько базрасширенное

Если приложение работает через create_async_engine, Alembic инициализируют шаблоном alembic init -t async migrations: его env.py оборачивает синхронную логику миграции в connection.run_sync на асинхронном соединении. Сами файлы ревизий остаются синхронными, op.add_column и прочее не меняются. Для тестов и локальной разработки удобнее держать в env.py синхронный адрес (postgresql+psycopg://) и не тащить async в миграции вовсе: схема одна, и драйвер миграции не обязан совпадать с драйвером приложения.

Когда в сервисе две базы (основная и отчётная), шаблон multidb создаёт по ветке ревизий на базу с общим env.py, и alembic upgrade head прогоняет обе. Альтернатива проще: два независимых каталога Alembic с двумя alembic.ini, по одному на базу, и явный выбор при запуске. Второй путь понятнее на чтение и не требует помнить, какая ревизия к какой базе относится.

Ещё одна команда на случай расхождения: alembic stamp head отмечает базу как мигрированную, не выполняя миграций. Нужна при подключении Alembic к базе, схему которой уже создали иначе, и это единственный честный случай её использования.

Коротко

  • env.py указывает target_metadata = Base.metadata и импортирует все модели; адрес базы из настроек приложения, не из alembic.ini.
  • Автогенерация это черновик: переименование видит как удаление и создание, типы и умолчания сравнивает только с compare_type и compare_server_default, чужие таблицы фильтруют include_object; alembic check в CI.
  • Ломающие изменения через расширение и сжатие: добавить новое, перевести код, удалить старое тремя выкатами.
  • CREATE INDEX только CONCURRENTLY в autocommit_block, перед ALTER TABLE ставят lock_timeout, смену типа большой колонки делают новой колонкой.
  • Миграция данных на тысячи строк через лёгкую sa.table в файле ревизии, на миллионы строк пакетами из кода, не из миграции.
  • Две головы сливают alembic merge; upgrade head отдельным шагом конвейера или Job перед сменой подов, не при старте каждого пода.
  • downgrade не план отката: откат в проде это новая миграция вперёд.
  • Асинхронный шаблон оборачивает миграции в run_sync; для миграций можно оставить синхронный драйвер.

Что почитать дальше

  • Миграции PostgreSQL — расширение и сжатие, NOT VALID, что безопасно делать на живой таблице.
  • Блокировки PostgreSQL — какую блокировку берёт каждая операция и как lock_timeout спасает прод.
  • Модели и маппинг — соглашения об именах, без которых миграции не знают, что удалять.