Zero-downtime миграции важны там, где сервисы деплоятся rolling-ом: API, workers, async jobs. Частая ошибка - считать, что
alembic upgrade head перед релизом решает совместимость схемы и кода.Expand/contract
Схему меняем не одним ударом, а фазами:
*
expand - добавляем новое так, чтобы старый код не сломался* деплоим код, совместимый со старой и новой схемой
* делаем backfill, dual-write, переключение чтения
*
contract - удаляем старое только после ухода всех старых инстансовВ Kubernetes, Nomad или systemd rolling deployment в проде какое-то время живут две версии сервиса. Миграция должна быть совместима минимум с текущим и следующим кодом.
Пример: first_name/last_name -> full_name
Плохой вариант: добавить
full_name NOT NULL, удалить старые колонки и выкатить код. Старая версия сервиса начнет писать в удаленные поля и упадет.Нормальный expand:
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"users",
sa.Column("full_name", sa.Text(), nullable=True),
)
with op.get_context().autocommit_block():
op.create_index(
"ix_users_full_name",
"users",
["full_name"],
postgresql_concurrently=True,
)
Новый код сначала живет в переходном режиме:
def get_display_name(user) -> str:
return user.full_name or f"{user.first_name} {user.last_name}"
user.first_name = first_name
user.last_name = last_name
user.full_name = f"{first_name} {last_name}"
Практические правила
* backfill делайте отдельным job, батчами, с лимитами, паузами и метриками
* не запускайте тяжелые data migration внутри DDL-миграции Alembic
*
DROP COLUMN, RENAME COLUMN, SET NOT NULL и смену типа выносите в contract*
NOT NULL добавляйте после заполнения данных и проверки консистентности* Alembic в проде должен запускать один контролируемый runner, а не каждый инстанс приложения
Вывод:
Zero-downtime миграция - это не SQL-команда, а протокол совместимости между схемой, кодом, деплоем и данными.
