Модель поменялась, в таблице нужна новая колонка. Кто её создаст? Не 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спасает прод. - Модели и маппинг — соглашения об именах, без которых миграции не знают, что удалять.