Миграции базы данных: как не положить production
В пятницу в 18:00 разработчик запускает миграцию: ALTER TABLE orders ADD COLUMN discount_pct NUMERIC DEFAULT 0. Таблица — 50 млн строк. PostgreSQL берёт AccessExclusiveLock, перезаписывает каждую строку, блокирует все операции чтения и записи. Через 40 минут прилетает PagerDuty. Это не выдуманная история.
Проблема не в том, что миграции опасны. Проблема в том, что большинство из них пишутся для маленькой базы и не тестируются на реальных объёмах. Разберём, как делать миграции правильно.
Почему ALTER TABLE блокирует всё
PostgreSQL использует систему блокировок MVCC. Большинство DDL-операций требуют AccessExclusiveLock — самый жёсткий уровень, несовместимый вообще ни с чем: ни с SELECT, ни с INSERT, ни с другими блокировками.
-- Опасно на большой таблице: полная блокировка
ALTER TABLE orders ADD COLUMN discount_pct NUMERIC DEFAULT 0 NOT NULL;
-- Безопасно: добавление nullable-колонки без DEFAULT
-- не требует перезаписи строк в PostgreSQL 11+
ALTER TABLE orders ADD COLUMN discount_pct NUMERIC;
-- Безопасно в PostgreSQL 11+: DEFAULT без NOT NULL
-- сохраняется в метаданных, не перезаписывает строки
ALTER TABLE orders ADD COLUMN discount_pct NUMERIC DEFAULT 0;
PostgreSQL 11 изменил поведение для ADD COLUMN ... DEFAULT без NOT NULL: значение по умолчанию теперь хранится в системных таблицах и применяется при чтении, без физической перезаписи всех строк. Но NOT NULL всё равно требует сканирования таблицы для проверки — это снова блокировка.
Проблемные операции:
-- Всё это блокирует таблицу на всё время выполнения:
ALTER TABLE t ADD COLUMN c TEXT NOT NULL DEFAULT 'x'; -- перезапись строк
ALTER TABLE t ALTER COLUMN c TYPE BIGINT; -- перезапись строк
ALTER TABLE t DROP COLUMN c; -- быстро, но ExclusiveLock
CREATE INDEX ON t (col); -- блокирует запись
ALTER TABLE t ADD CONSTRAINT ... FOREIGN KEY ...; -- сканирование + блокировка
Expand-Contract: единственный надёжный паттерн
Суть: никогда не удаляйте и не переименовывайте что-то за один шаг. Вместо этого — три фазы деплоя.
Пример: переименовываем колонку user_name → username.
Фаза 1: Expand (расширение) — добавляем новое, не трогаем старое:
-- Миграция: добавляем новую колонку
ALTER TABLE users ADD COLUMN username TEXT;
-- Код приложения начинает писать в обе колонки
UPDATE users SET username = user_name WHERE username IS NULL;
Фаза 2: Migrate (синхронизация) — переводим приложение на новую колонку, заполняем данные:
# Alembic-миграция: бэкфилл батчами, не одним UPDATE
def upgrade():
op.execute("""
DO $$
DECLARE
batch_size INT := 10000;
max_id BIGINT;
cur_id BIGINT := 0;
BEGIN
SELECT MAX(id) INTO max_id FROM users;
WHILE cur_id < max_id LOOP
UPDATE users
SET username = user_name
WHERE id > cur_id
AND id <= cur_id + batch_size
AND username IS NULL;
cur_id := cur_id + batch_size;
PERFORM pg_sleep(0.05); -- пауза между батчами
END LOOP;
END $$;
""")
Фаза 3: Contract (сжатие) — когда весь трафик использует новую колонку, удаляем старую:
-- Только после того, как старый код выведен из эксплуатации
ALTER TABLE users DROP COLUMN user_name;
Между фазами — полноценные деплои. Нельзя объединять их в одну миграцию: если откатываете деплой, база должна быть совместима с предыдущей версией кода.
Online DDL в PostgreSQL: CREATE INDEX CONCURRENTLY и pg_repack
CREATE INDEX CONCURRENTLY строит индекс без блокировки записи. Делает два прохода по таблице и отслеживает изменения во время построения. Работает медленнее обычного CREATE INDEX, но не мешает production-трафику.
-- Блокирует запись на всё время построения:
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Не блокирует запись, только два коротких ExclusiveLock в начале и конце:
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
-- Если индекс уже существует (идемпотентность):
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_user_id ON orders(user_id);
-- Аналогично для удаления:
DROP INDEX CONCURRENTLY idx_orders_user_id;
Ограничения CONCURRENTLY: нельзя использовать внутри транзакции. Alembic по умолчанию оборачивает каждую миграцию в транзакцию — нужно явно отключать:
# В файле миграции Alembic:
# alembic/versions/20250801_add_index.py
from alembic import op
# Критически важно: без этого CONCURRENTLY не работает
def upgrade():
op.execute("COMMIT") # завершаем автоматическую транзакцию Alembic
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS "
"idx_orders_user_id ON orders(user_id)"
)
def downgrade():
op.execute("COMMIT")
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_orders_user_id")
pg_repack — расширение для перестройки таблиц и индексов без длительных блокировок. Полезно, когда таблица сильно раздута (bloat) после массовых UPDATE/DELETE, или нужно сменить тип колонки без простоя.
# Установка расширения
CREATE EXTENSION IF NOT EXISTS pg_repack;
# Перестройка таблицы без блокировок (онлайн)
pg_repack -h localhost -d mydb -t orders
# Перестройка только индексов
pg_repack -h localhost -d mydb -t orders --only-indexes
# Проверка bloat перед принятием решения
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
pg_size_pretty(
pg_total_relation_size(schemaname||'.'||tablename) -
pg_relation_size(schemaname||'.'||tablename)
) AS index_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 10;
Alembic: ловушки autogenerate и best practices
alembic revision --autogenerate удобен, но обманчив. Он сравнивает модели SQLAlchemy с текущей схемой БД и генерирует diff. Что он не видит:
- Изменения в CHECK-constraints по умолчанию (зависит от версии SQLAlchemy)
- Функции и триггеры — не часть ORM-модели
- Партиционирование таблиц
- Индексы, созданные вручную вне моделей
- Различия между
TEXTиVARCHAR(PostgreSQL считает их эквивалентными)
Правило: всегда просматривайте autogenerate-миграцию вручную перед применением. Никогда не делайте alembic upgrade head сразу после --autogenerate без код-ревью.
# alembic/env.py: правильная настройка для production
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
config = context.config
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool, # не держим соединение открытым
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
# Включаем сравнение типов — autogenerate заметит изменения
compare_type=True,
# Включаем сравнение nullable
compare_server_default=True,
# Транзакционные DDL: для большинства PostgreSQL-операций
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_offline():
"""Offline mode: генерирует SQL без подключения к БД.
Удобно для ревью и применения DBA вручную."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
Offline mode — недооценённый инструмент. Генерирует чистый SQL-файл, который DBA может проверить и применить вручную в maintenance window. Для production с жёсткими требованиями к изменениям это стандарт.
# Генерация SQL без применения
alembic upgrade head --sql > migration_20250801.sql
# Проверяем что внутри, применяем вручную
cat migration_20250801.sql
psql -h prod-db -d mydb -f migration_20250801.sql
Тестирование миграций и rollback-стратегии
Большинство команд тестируют upgrade, но не downgrade. В момент инцидента выясняется, что downgrade не написан или сломан.
# tests/test_migrations.py: тест upgrade + downgrade для каждой ревизии
import pytest
from alembic.config import Config
from alembic import command
from sqlalchemy import create_engine, text
@pytest.fixture(scope="session")
def alembic_config():
cfg = Config("alembic.ini")
cfg.set_main_option("sqlalchemy.url", "postgresql://test:test@localhost/test_db")
return cfg
def test_migrations_upgrade_downgrade(alembic_config):
"""Прогоняем все миграции вверх и обратно до base."""
# Применяем все миграции
command.upgrade(alembic_config, "head")
# Откатываем все миграции
command.downgrade(alembic_config, "base")
# Снова применяем — проверяем идемпотентность
command.upgrade(alembic_config, "head")
def test_migration_step_by_step(alembic_config):
"""Тест каждой ревизии по отдельности."""
from alembic.script import ScriptDirectory
from alembic.runtime.environment import EnvironmentContext
script = ScriptDirectory.from_config(alembic_config)
revisions = list(script.walk_revisions())
command.downgrade(alembic_config, "base")
for rev in reversed(revisions):
command.upgrade(alembic_config, rev.revision)
command.downgrade(alembic_config, rev.down_revision or "base")
command.upgrade(alembic_config, rev.revision)
Blue-green для схемы — более радикальный подход: держите две схемы в одной БД (schema_blue и schema_green), переключайте search_path. Позволяет откатиться мгновенно без DDL-операций. Сложнее в управлении, оправдано для критических сервисов.
Стратегия rollback в порядке возрастания сложности:
# 1. Простой откат Alembic (работает, если downgrade написан)
alembic downgrade -1
# 2. Откат до конкретной ревизии
alembic downgrade 20250715_initial
# 3. Если миграция уже применена частично — восстановление из backup
# pg_restore из WAL-архива или физического backup
pg_restore --host=prod-db --dbname=mydb --verbose backup_20250801_0500.dump
# 4. Point-in-time recovery (PITR) через pg_basebackup + WAL
# Требует настроенного continuous archiving заранее
PITR — ваша страховка последнего уровня. Если он не настроен, при серьёзной ошибке в миграции потеря данных неизбежна. Настройте WAL-архивирование до того, как понадобится.
Безопасная миграция — это три деплоя вместо одного, батчинг вместо одного большого UPDATE, и downgrade, который тестируется так же строго, как upgrade. Всё остальное — оптимизация поверх этого фундамента.