10.4. Database migrations
Цели
После этого материала вы сможете:
- выбрать между тремя способами запуска миграций и обосновать выбор;
- объяснить, почему миграции при старте приложения ломаются при нескольких репликах;
- защитить миграции от одновременного выполнения консультативной блокировкой;
- дождаться готовности базы правильно, а не через
sleep; - обеспечить идемпотентность и возможность отката;
- организовать тестовую базу, пересоздаваемую за секунды.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
миграция | Изменение схемы базы, применяемое версионированно |
advisory lock | Консультативная блокировка PostgreSQL, не связанная с таблицами |
expand/contract | Схема совместимых миграций: сначала добавить, потом убрать |
head | Последняя миграция в цепочке (терминология Alembic) |
идемпотентность | Повторный запуск не меняет результат |
Теория
Три способа и их цена
| Способ | Когда выполняется | Плюсы | Минусы |
|---|---|---|---|
| При старте приложения | В коде, до приёма запросов | Ничего не настраивать | Гонка при репликах; медленный старт; провал роняет приложение |
| Отдельный сервис Compose | Между базой и приложением | Один раз; провал останавливает запуск | Требует конфигурации |
| Вручную | По команде оператора | Полный контроль | Забудут; расхождение сред |
Рекомендация для Compose — отдельный сервис (урок 9.4). Он даёт единственное выполнение и превращает провал миграции в отказ запуска, а не в приложение со старой схемой.
Ручной запуск остаётся нужным для разрушительных операций: удаление колонки, переливка данных, длительное построение индекса. Такие миграции не должны выполняться автоматически при деплое.
Почему «при старте» ломается
# Кажется удобным
@asynccontextmanager
async def lifespan(app):
run_migrations() # ← опасно
yield
При deploy.replicas: 3 три процесса начинают миграцию одновременно:
| Что происходит | Результат |
|---|---|
Три CREATE TABLE подряд | Двое получают ошибку «уже существует» |
Три ALTER TABLE | Взаимоблокировка или частично применённая схема |
| Одна реплика упала на середине | Схема в неопределённом состоянии |
Проблема не гипотетическая: она проявляется именно при масштабировании, то есть в production, а не при разработке с одной репликой.
Если способ всё же выбран, обязательна консультативная блокировка.
Консультативная блокировка
PostgreSQL позволяет взять блокировку по произвольному числовому ключу:
SELECT pg_advisory_lock(hashtext('schema_migrations'));
-- миграции
SELECT pg_advisory_unlock(hashtext('schema_migrations'));
| Свойство | Значение |
|---|---|
| Область действия | Соединение; освобождается при разрыве |
| Блокирует таблицы | Нет — только другие вызовы с тем же ключом |
| Ожидание | pg_advisory_lock ждёт, pg_try_advisory_lock возвращает false |
| Автоматическое освобождение | При закрытии соединения |
Последняя строка важна: если процесс умрёт, блокировка снимется сама, и следующий запуск не зависнет.
Вариант с pg_try_advisory_lock даёт другое поведение: реплика, не получившая блокировку, не ждёт, а сразу продолжает работу, считая, что миграции выполнит кто-то другой. Это быстрее, но требует, чтобы приложение переживало временно старую схему.
Ожидание готовности базы
Три способа, из которых правильный один:
| Способ | Оценка |
|---|---|
sleep 10 | Плохо. На медленной машине мало, на быстрой — потеря времени |
depends_on: service_healthy | Хорошо для Compose |
| Повторы с задержкой в коде | Обязательно — работает везде |
Первые два не заменяют третий: база может стать недоступной после старта (урок 9.4).
def connect_with_retry(dsn: str, attempts: int = 30) -> Connection:
delay = 0.25
for _ in range(attempts):
try:
return psycopg.connect(dsn, connect_timeout=3)
except psycopg.OperationalError:
time.sleep(delay)
delay = min(delay * 2, 3.0) # экспоненциальная задержка
raise RuntimeError("база недоступна")
Экспоненциальная задержка важнее, чем кажется: постоянные повторы каждые 100 мс создают нагрузку на восстанавливающуюся базу.
Идемпотентность
Повторный docker compose up выполнит сервис миграций заново. Значит, миграции обязаны выдерживать повтор.
| Приём | Пример |
|---|---|
IF NOT EXISTS | CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS |
| Таблица версий | Применять только то, что новее текущей версии |
ON CONFLICT DO NOTHING | Для вставки справочных данных |
| Проверка перед изменением | IF NOT EXISTS (SELECT ...) THEN |
Таблица версий — основной механизм: инструменты вроде Alembic ведут её сами. Самописный runner должен делать то же.
Откат
| Ситуация | Что делать |
|---|---|
| Миграция не применилась | Ничего: транзакция откатилась |
| Применилась, но код не готов | Откатить миграцию: alembic downgrade -1 |
| Применилась и данные изменены | Откат может потерять данные — восстановление из копии |
| Нужен zero-downtime | Схема expand/contract |
expand/contract — приём совместимых миграций:
1. expand: добавить новую колонку, оставив старую
2. deploy: код пишет в обе, читает из новой
3. backfill: заполнить новую колонку из старой
4. deploy: код использует только новую
5. contract: удалить старую колонку
Каждый шаг обратим по отдельности, и на любом из них старая и новая версии кода работают одновременно. Это единственный способ обновлять схему без остановки при нескольких репликах.
Не все миграции обратимы: удаление колонки теряет данные. Поэтому шаг 5 выполняют вручную и после резервной копии (урок 7.6).
Тестовая база
Требования к ней другие: скорость важнее сохранности.
services:
db-test:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
tmpfs:
- /var/lib/postgresql/data:size=512m # в памяти, исчезает
command:
- postgres
- -c
- fsync=off # ТОЛЬКО для тестов
- -c
- full_page_writes=off
- -c
- synchronous_commit=off
| Настройка | Эффект | Почему только для тестов |
|---|---|---|
tmpfs для данных | Записи в память | Данные исчезают при перезапуске |
fsync=off | Нет ожидания диска | Повреждение при сбое питания |
synchronous_commit=off | Быстрее фиксация | Потеря последних транзакций |
Ускорение получается существенным, а риск равен нулю: тестовые данные не жаль.
Размер tmpfs учитывается в лимите памяти container'а (урок 7.4).
Внутренний механизм
Что делает транзакционный DDL
PostgreSQL поддерживает DDL внутри транзакций: CREATE TABLE, ALTER TABLE и большинство операций откатываются при ошибке.
BEGIN;
CREATE TABLE a (...);
ALTER TABLE b ADD COLUMN c int;
-- ошибка здесь откатит обе операции
COMMIT;
Отсюда практическое следствие: упавшая миграция не оставляет схему наполовину изменённой — если всё выполнялось в одной транзакции.
Исключения есть: CREATE INDEX CONCURRENTLY не может выполняться в транзакции. Такие операции выделяют в отдельную миграцию.
MySQL транзакционного DDL не имеет — там частично применённая миграция реальна, и требования к идемпотентности жёстче.
Как Alembic отслеживает версию
Таблица alembic_version с единственной строкой — идентификатор текущей миграции. upgrade head применяет всё, что после неё; downgrade -1 откатывает одну.
Самописный runner обычно хранит числовую версию — этого достаточно для линейной цепочки миграций.
Команды и примеры
Гонка при миграциях в приложении
mkdir -p /tmp/migr && cd /tmp/migr
cat > racy.py <<'PY'
"""Миграция БЕЗ блокировки — воспроизводит гонку."""
import os
import sys
import time
import psycopg
DSN = os.environ["DSN"]
NAME = os.environ.get("REPLICA", "?")
def connect_with_retry(attempts: int = 40):
delay = 0.25
for _ in range(attempts):
try:
return psycopg.connect(DSN, connect_timeout=3)
except psycopg.OperationalError:
time.sleep(delay)
delay = min(delay * 2, 3.0)
raise RuntimeError("база недоступна")
def main() -> int:
conn = connect_with_retry()
conn.autocommit = True
try:
with conn.cursor() as cur:
# Намеренно БЕЗ IF NOT EXISTS и без блокировки
cur.execute("CREATE TABLE items (id serial PRIMARY KEY, name text)")
print(f"{NAME}: таблица создана", flush=True)
return 0
except psycopg.errors.DuplicateTable:
print(f"{NAME}: ОШИБКА — таблица уже существует", flush=True)
return 1
except Exception as exc:
print(f"{NAME}: ОШИБКА — {type(exc).__name__}: {exc}", flush=True)
return 1
finally:
conn.close()
if __name__ == "__main__":
sys.exit(main())
PY
cat > compose.yaml <<'EOF'
name: migr
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
tmpfs:
- /var/lib/postgresql/data:size=512m
command:
- postgres
- -c
- fsync=off
- -c
- synchronous_commit=off
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 2s
timeout: 3s
retries: 20
start_period: 15s
start_interval: 1s
racy:
image: python:3.13-slim
volumes:
- ./racy.py:/racy.py:ro
environment:
DSN: "postgresql://postgres:secret@db:5432/appdb"
REPLICA: "${REPLICA:-r}"
command: ["sh", "-c", "pip install -q 'psycopg[binary]==3.3.4' && python -u /racy.py"]
depends_on:
db:
condition: service_healthy
deploy:
replicas: 3
restart: "no"
EOF
docker compose down -v > /dev/null 2>&1
docker compose up -d db > /dev/null 2>&1
for _ in $(seq 60); do
[ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ] && break
sleep 1
done
echo "═══ три реплики выполняют миграцию одновременно ═══"
docker compose up racy 2>&1 | grep -E 'таблица создана|ОШИБКА|exited' | sed 's/^/ /'
Ожидаемый вывод:
═══ три реплики выполняют миграцию одновременно ═══
migr-racy-1 | racy-1: таблица создана
migr-racy-2 | racy-2: ОШИБКА — таблица уже существует
migr-racy-3 | racy-3: ОШИБКА — таблица уже существует
migr-racy-1 exited with code 0
migr-racy-2 exited with code 1
migr-racy-3 exited with code 1
Две реплики из трёх завершились с ошибкой. При миграциях в lifespan приложения это означало бы, что два из трёх экземпляров не поднялись.
На простом CREATE TABLE последствия ограничены ошибкой. На ALTER TABLE с переливкой данных результат был бы хуже — частично изменённая схема.
Исправление 1: идемпотентность
cd /tmp/migr
cat > idempotent.py <<'PY'
"""Идемпотентная миграция: повтор безвреден."""
import os
import sys
import time
import psycopg
DSN = os.environ["DSN"]
NAME = os.environ.get("REPLICA", "?")
def connect_with_retry(attempts: int = 40):
delay = 0.25
for _ in range(attempts):
try:
return psycopg.connect(DSN, connect_timeout=3)
except psycopg.OperationalError:
time.sleep(delay)
delay = min(delay * 2, 3.0)
raise RuntimeError("база недоступна")
def main() -> int:
with connect_with_retry() as conn, conn.cursor() as cur:
cur.execute("CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, name text)")
cur.execute("CREATE INDEX IF NOT EXISTS items_name_idx ON items (name)")
conn.commit()
print(f"{NAME}: миграция применена", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
PY
python3 - <<'PY'
import pathlib
p = pathlib.Path("compose.yaml")
t = p.read_text().replace("./racy.py:/racy.py:ro", "./idempotent.py:/m.py:ro") \
.replace("python -u /racy.py", "python -u /m.py")
p.write_text(t)
PY
docker compose down > /dev/null 2>&1
docker compose up -d db > /dev/null 2>&1
for _ in $(seq 60); do
[ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ] && break
sleep 1
done
echo "═══ три реплики, идемпотентная миграция ═══"
docker compose up racy 2>&1 | grep -E 'применена|ОШИБКА|exited' | sed 's/^/ /'
Ожидаемый вывод:
═══ три реплики, идемпотентная миграция ═══
migr-racy-1 | racy-1: миграция применена
migr-racy-2 | racy-2: миграция применена
migr-racy-3 | racy-3: миграция применена
migr-racy-1 exited with code 0
migr-racy-2 exited with code 0
migr-racy-3 exited with code 0
Все три завершились успешно. Но обратите внимание: миграция выполнялась трижды. Для CREATE TABLE IF NOT EXISTS это дёшево, для переливки миллиона строк — нет.
Исправление 2: консультативная блокировка
cd /tmp/migr
cat > locked.py <<'PY'
"""Миграция под консультативной блокировкой: выполняется ровно один раз."""
import os
import sys
import time
import psycopg
DSN = os.environ["DSN"]
NAME = os.environ.get("REPLICA", "?")
LOCK_KEY = 987654321 # произвольный, но одинаковый у всех реплик
TARGET_VERSION = 2
def connect_with_retry(attempts: int = 40):
delay = 0.25
for _ in range(attempts):
try:
return psycopg.connect(DSN, connect_timeout=3)
except psycopg.OperationalError:
time.sleep(delay)
delay = min(delay * 2, 3.0)
raise RuntimeError("база недоступна")
MIGRATIONS = {
1: [
"CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, name text)",
"CREATE INDEX IF NOT EXISTS items_name_idx ON items (name)",
],
2: [
"ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at timestamptz DEFAULT now()",
],
}
def main() -> int:
with connect_with_retry() as conn:
with conn.cursor() as cur:
# Блокировка освобождается автоматически при закрытии соединения
cur.execute("SELECT pg_advisory_lock(%s)", (LOCK_KEY,))
print(f"{NAME}: блокировка получена", flush=True)
cur.execute("""
CREATE TABLE IF NOT EXISTS schema_version (
version integer PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)
""")
cur.execute("SELECT coalesce(max(version), 0) FROM schema_version")
current = cur.fetchone()[0]
applied = 0
for version in sorted(MIGRATIONS):
if version <= current:
continue
for stmt in MIGRATIONS[version]:
cur.execute(stmt)
cur.execute("INSERT INTO schema_version (version) VALUES (%s)", (version,))
applied += 1
print(f"{NAME}: применена миграция {version}", flush=True)
conn.commit()
if applied == 0:
print(f"{NAME}: нечего применять, схема версии {current}", flush=True)
cur.execute("SELECT pg_advisory_unlock(%s)", (LOCK_KEY,))
return 0
if __name__ == "__main__":
sys.exit(main())
PY
python3 - <<'PY'
import pathlib
p = pathlib.Path("compose.yaml")
p.write_text(p.read_text().replace("./idempotent.py:/m.py:ro", "./locked.py:/m.py:ro"))
PY
docker compose down > /dev/null 2>&1
docker compose up -d db > /dev/null 2>&1
for _ in $(seq 60); do
[ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ] && break
sleep 1
done
echo "═══ три реплики под блокировкой ═══"
docker compose up racy 2>&1 | grep -E 'блокировка|применена|нечего|exited' | sed 's/^/ /'
echo "═══ итоговая схема ═══"
docker compose exec -T db psql -U postgres -d appdb -c '\d items' 2>/dev/null | head -8 | sed 's/^/ /'
docker compose exec -T db psql -U postgres -d appdb -tAc \
'SELECT version, applied_at FROM schema_version ORDER BY version' | sed 's/^/ версия: /'
Ожидаемый вывод:
═══ три реплики под блокировкой ═══
migr-racy-1 | racy-1: блокировка получена
migr-racy-1 | racy-1: применена миграция 1
migr-racy-1 | racy-1: применена миграция 2
migr-racy-2 | racy-2: блокировка получена
migr-racy-2 | racy-2: нечего применять, схема версии 2
migr-racy-3 | racy-3: блокировка получена
migr-racy-3 | racy-3: нечего применять, схема версии 2
migr-racy-1 exited with code 0
migr-racy-2 exited with code 0
migr-racy-3 exited with code 0
═══ итоговая схема ═══
Table "public.items"
Column | Type | Nullable | Default
----------+--------------------------+----------+-------------------
id | integer | not null | nextval(...)
name | text | |
created_at | timestamp with time zone | | now()
версия: 1|2026-07-31 12:04:11.882+00
версия: 2|2026-07-31 12:04:11.891+00
Миграции применились один раз, остальные реплики дождались блокировки и увидели актуальную версию.
Порядок в логах показывает работу блокировки: реплики 2 и 3 получили её только после того, как первая освободила.
Повторный запуск ничего не ломает
cd /tmp/migr
echo "═══ повторный запуск ═══"
docker compose up racy 2>&1 | grep -E 'применена|нечего' | sed 's/^/ /'
echo "═══ версия схемы не изменилась ═══"
docker compose exec -T db psql -U postgres -d appdb -tAc \
'SELECT count(*) FROM schema_version' | xargs printf ' записей в schema_version: %s\n'
Ожидаемый вывод:
═══ повторный запуск ═══
migr-racy-1 | racy-1: нечего применять, схема версии 2
migr-racy-2 | racy-2: нечего применять, схема версии 2
migr-racy-3 | racy-3: нечего применять, схема версии 2
═══ версия схемы не изменилась ═══
записей в schema_version: 2
Таблица версий сделала своё дело: повторный запуск не выполнил ни одного оператора.
Транзакционный DDL: упавшая миграция не оставляет следов
cd /tmp/migr
cat > failing.py <<'PY'
"""Миграция, падающая на середине. Проверяет откат DDL."""
import os
import sys
import psycopg
DSN = os.environ["DSN"]
with psycopg.connect(DSN) as conn, conn.cursor() as cur:
cur.execute("SELECT count(*) FROM information_schema.tables WHERE table_name LIKE 'part_%'")
print(f" таблиц part_* до миграции: {cur.fetchone()[0]}", flush=True)
try:
with psycopg.connect(DSN) as conn, conn.cursor() as cur:
cur.execute("CREATE TABLE part_one (id int)")
print(" создана part_one", flush=True)
cur.execute("CREATE TABLE part_two (id int)")
print(" создана part_two", flush=True)
cur.execute("СИНТАКСИЧЕСКАЯ ОШИБКА") # падение
conn.commit()
except Exception as exc:
print(f" миграция упала: {type(exc).__name__}", flush=True)
with psycopg.connect(DSN) as conn, conn.cursor() as cur:
cur.execute("SELECT count(*) FROM information_schema.tables WHERE table_name LIKE 'part_%'")
n = cur.fetchone()[0]
print(f" таблиц part_* после падения: {n}", flush=True)
sys.exit(0 if n == 0 else 1)
PY
docker compose run --rm -T \
-v "$PWD/failing.py:/f.py:ro" \
-e DSN="postgresql://postgres:secret@db:5432/appdb" \
racy sh -c "pip install -q 'psycopg[binary]==3.3.4' && python -u /f.py" 2>/dev/null
Ожидаемый вывод:
таблиц part_* до миграции: 0
создана part_one
создана part_two
миграция упала: SyntaxError
таблиц part_* после падения: 0
Обе таблицы были созданы и обе исчезли при откате транзакции. Схема осталась в исходном состоянии.
Это свойство PostgreSQL, а не Docker — но именно оно делает миграции в container безопасными: упавший container не оставляет базу наполовину мигрированной.
expand/contract для zero-downtime
cd /tmp/migr
cat > expand.py <<'PY'
"""Шаг 1 (expand): добавить новую колонку, не трогая старую."""
import os
import psycopg
with psycopg.connect(os.environ["DSN"]) as conn, conn.cursor() as cur:
cur.execute("ALTER TABLE items ADD COLUMN IF NOT EXISTS title text")
conn.commit()
print(" expand: колонка title добавлена, name сохранена", flush=True)
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'items' AND column_name IN ('name', 'title')
ORDER BY column_name
""")
print(f" колонки: {[r[0] for r in cur.fetchall()]}", flush=True)
PY
cat > backfill.py <<'PY'
"""Шаг 3 (backfill): заполнить новую колонку из старой, порциями."""
import os
import psycopg
with psycopg.connect(os.environ["DSN"]) as conn, conn.cursor() as cur:
cur.execute("INSERT INTO items (name) SELECT 'элемент-' || g FROM generate_series(1, 500) g")
conn.commit()
total = 0
while True:
# Порциями, чтобы не держать длинную транзакцию на большой таблице
cur.execute("""
UPDATE items SET title = name
WHERE id IN (SELECT id FROM items WHERE title IS NULL LIMIT 100)
""")
conn.commit()
if cur.rowcount == 0:
break
total += cur.rowcount
print(f" backfill: заполнено строк {total}", flush=True)
cur.execute("SELECT count(*) FROM items WHERE title IS NULL")
print(f" осталось незаполненных: {cur.fetchone()[0]}", flush=True)
PY
cat > contract.py <<'PY'
"""Шаг 5 (contract): удалить старую колонку. ВЫПОЛНЯЕТСЯ ВРУЧНУЮ."""
import os
import psycopg
with psycopg.connect(os.environ["DSN"]) as conn, conn.cursor() as cur:
cur.execute("SELECT count(*) FROM items WHERE title IS NULL")
unfilled = cur.fetchone()[0]
if unfilled:
print(f" contract ОТМЕНЁН: {unfilled} строк не заполнено", flush=True)
raise SystemExit(1)
cur.execute("ALTER TABLE items DROP COLUMN IF EXISTS name")
conn.commit()
print(" contract: колонка name удалена", flush=True)
PY
run() {
docker compose run --rm -T \
-v "$PWD/$1:/s.py:ro" \
-e DSN="postgresql://postgres:secret@db:5432/appdb" \
racy sh -c "pip install -q 'psycopg[binary]==3.3.4' && python -u /s.py" 2>/dev/null
}
echo "═══ шаг 1: expand ═══"
run expand.py
echo "═══ шаг 3: backfill ═══"
run backfill.py
echo "═══ шаг 5: contract (только вручную, после проверки) ═══"
run contract.py
Ожидаемый вывод:
═══ шаг 1: expand ═══
expand: колонка title добавлена, name сохранена
колонки: ['name', 'title']
═══ шаг 3: backfill ═══
backfill: заполнено строк 500
осталось незаполненных: 0
═══ шаг 5: contract (только вручную, после проверки) ═══
contract: колонка name удалена
Между шагами 1 и 5 старая и новая версии кода работают одновременно: старая пишет в name, новая — в title, обе видят свои данные.
Обратите внимание на защиту в contract.py: он отказывается удалять колонку, если не все строки заполнены. Разрушительные операции должны проверять предусловия сами.
Обратите внимание и на порции по 100 строк в backfill: длинная транзакция на большой таблице держит блокировки и раздувает журнал.
Тестовая база: скорость
cd /tmp/migr
cat > compose.bench.yaml <<'EOF'
name: migr-bench
services:
db-disk:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
volumes:
- bench-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d testdb"]
interval: 1s
retries: 60
start_period: 20s
db-tmpfs:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
tmpfs:
- /var/lib/postgresql/data:size=512m
command:
- postgres
- -c
- fsync=off
- -c
- full_page_writes=off
- -c
- synchronous_commit=off
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d testdb"]
interval: 1s
retries: 60
start_period: 20s
volumes:
bench-data:
EOF
bench() {
local svc="$1"
docker compose -f compose.bench.yaml up -d "$svc" > /dev/null 2>&1
for _ in $(seq 90); do
[ "$(docker compose -f compose.bench.yaml ps --format '{{.Health}}' "$svc")" = "healthy" ] && break
sleep 1
done
local s e
s="$(date +%s.%N)"
docker compose -f compose.bench.yaml exec -T "$svc" psql -U postgres -d testdb -q <<'SQL' > /dev/null 2>&1
CREATE TABLE bench (id serial PRIMARY KEY, payload text);
INSERT INTO bench (payload) SELECT md5(g::text) FROM generate_series(1, 20000) g;
CREATE INDEX bench_payload_idx ON bench (payload);
SQL
e="$(date +%s.%N)"
printf ' %-9s %.2f c\n' "$svc" "$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"
}
echo "═══ 20 000 вставок плюс индекс ═══"
bench db-disk
bench db-tmpfs
docker compose -f compose.bench.yaml down -v > /dev/null 2>&1
Ожидаемый вывод:
═══ 20 000 вставок плюс индекс ═══
db-disk 1.84 c
db-tmpfs 0.61 c
Втрое быстрее. На наборе из сотен тестов, каждый из которых пересоздаёт данные, разница переходит из секунд в минуты.
Настройки fsync=off и synchronous_commit=off недопустимы для реальных данных: при сбое питания база окажется повреждённой. Для тестовой базы, живущей в памяти, терять уже нечего.
cd /tmp/migr && docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/migr
Практическое упражнение
Задание. Реализуйте систему миграций и подтвердите шесть утверждений.
- Миграции выполняются отдельным сервисом; приложение стартует только после их успешного завершения.
- Три реплики миграций не конфликтуют: операторы выполняются один раз.
- Повторный запуск не применяет ничего и завершается с кодом
0. - Упавшая миграция не оставляет схему частично изменённой.
- Реализованы
upgradeиdowngrade; откат возвращает схему к предыдущей версии. - Тестовая база на
tmpfsпересоздаётся и мигрирует быстрее дисковой.
Подсказки
Подсказка 1
Для пункта 2 нужна pg_advisory_lock с ключом, одинаковым у всех реплик.
Подсказка 2
Пункт 4 проверяется миграцией из двух операторов, второй из которых заведомо падает.
Подсказка 3
Для пункта 5 каждой миграции нужна пара операторов: применение и откат.
Решение
Показать решение
mkdir -p /tmp/migrfull && cd /tmp/migrfull
cat > migrate.py <<'PY'
"""Runner миграций: блокировка, версии, upgrade и downgrade.
Использование:
python migrate.py upgrade [версия] — применить до версии (по умолчанию до последней)
python migrate.py downgrade <версия> — откатить до версии
python migrate.py current — показать текущую версию
"""
from __future__ import annotations
import os
import sys
import time
import psycopg
DSN = os.environ["DSN"]
REPLICA = os.environ.get("REPLICA", os.environ.get("HOSTNAME", "?"))[:12]
LOCK_KEY = 4815162342
FAIL_AT = int(os.environ.get("MIGRATE_FAIL_AT", "0")) # для проверки отката
# Каждая миграция — пара: применение и откат
MIGRATIONS: dict[int, dict[str, list[str]]] = {
1: {
"up": [
"CREATE TABLE IF NOT EXISTS items ("
" id serial PRIMARY KEY, name text NOT NULL)",
"CREATE INDEX IF NOT EXISTS items_name_idx ON items (name)",
],
"down": [
"DROP INDEX IF EXISTS items_name_idx",
"DROP TABLE IF EXISTS items",
],
},
2: {
"up": [
"ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at"
" timestamptz NOT NULL DEFAULT now()",
],
"down": [
"ALTER TABLE items DROP COLUMN IF EXISTS created_at",
],
},
3: {
"up": [
"CREATE TABLE IF NOT EXISTS tags ("
" id serial PRIMARY KEY, item_id int REFERENCES items(id), label text)",
],
"down": [
"DROP TABLE IF EXISTS tags",
],
},
}
HEAD = max(MIGRATIONS)
def connect(attempts: int = 40) -> psycopg.Connection:
"""Повторы с экспоненциальной задержкой: sleep не подходит."""
delay = 0.25
last: Exception | None = None
for _ in range(attempts):
try:
return psycopg.connect(DSN, connect_timeout=3)
except psycopg.OperationalError as exc:
last = exc
time.sleep(delay)
delay = min(delay * 2, 3.0)
raise RuntimeError(f"база недоступна: {last}")
def ensure_version_table(cur) -> None:
cur.execute("""
CREATE TABLE IF NOT EXISTS schema_version (
version integer PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)
""")
def current_version(cur) -> int:
cur.execute("SELECT coalesce(max(version), 0) FROM schema_version")
return cur.fetchone()[0]
def upgrade(cur, target: int) -> int:
applied = 0
for version in sorted(MIGRATIONS):
if version <= current_version(cur) or version > target:
continue
for i, stmt in enumerate(MIGRATIONS[version]["up"], 1):
if FAIL_AT == version and i == 2:
raise RuntimeError(f"имитация сбоя на миграции {version}, оператор {i}")
cur.execute(stmt)
cur.execute("INSERT INTO schema_version (version) VALUES (%s)", (version,))
print(f" [{REPLICA}] применена {version}", flush=True)
applied += 1
return applied
def downgrade(cur, target: int) -> int:
reverted = 0
while current_version(cur) > target:
version = current_version(cur)
for stmt in MIGRATIONS[version]["down"]:
cur.execute(stmt)
cur.execute("DELETE FROM schema_version WHERE version = %s", (version,))
print(f" [{REPLICA}] откачена {version}", flush=True)
reverted += 1
return reverted
def main(argv: list[str]) -> int:
command = argv[0] if argv else "upgrade"
target = int(argv[1]) if len(argv) > 1 else (HEAD if command == "upgrade" else 0)
conn = connect()
try:
with conn.cursor() as cur:
if command == "current":
ensure_version_table(cur)
conn.commit()
print(f" версия схемы: {current_version(cur)}", flush=True)
return 0
# Блокировка снимается автоматически при закрытии соединения
cur.execute("SELECT pg_advisory_lock(%s)", (LOCK_KEY,))
print(f" [{REPLICA}] блокировка получена", flush=True)
ensure_version_table(cur)
conn.commit()
try:
if command == "upgrade":
n = upgrade(cur, target)
verb = "применено"
elif command == "downgrade":
n = downgrade(cur, target)
verb = "откачено"
else:
print(f"неизвестная команда: {command}", file=sys.stderr)
return 2
conn.commit()
except Exception as exc:
conn.rollback()
print(f" [{REPLICA}] ОШИБКА: {exc} — транзакция откачена", flush=True)
return 1
if n == 0:
print(f" [{REPLICA}] нечего делать, версия {current_version(cur)}", flush=True)
else:
print(f" [{REPLICA}] {verb} миграций: {n}, версия {current_version(cur)}", flush=True)
return 0
finally:
conn.close()
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
PY
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir 'psycopg[binary]==3.3.4'
WORKDIR /app
COPY migrate.py .
ENTRYPOINT ["python", "-u", "migrate.py"]
EOF
cat > compose.yaml <<'EOF'
name: migrfull
x-db-common: &db-common
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 1s
timeout: 3s
retries: 30
start_period: 20s
start_interval: 1s
services:
# Дисковая база — для сравнения скорости
db:
<<: *db-common
volumes:
- db-data:/var/lib/postgresql/data
# Тестовая база в памяти
db-test:
<<: *db-common
tmpfs:
- /var/lib/postgresql/data:size=512m
command:
- postgres
- -c
- fsync=off
- -c
- full_page_writes=off
- -c
- synchronous_commit=off
migrate:
build: .
environment:
DSN: "postgresql://postgres:secret@db:5432/appdb"
MIGRATE_FAIL_AT: "${MIGRATE_FAIL_AT:-0}"
depends_on:
db:
condition: service_healthy
restart: "no"
app:
image: python:3.13-slim
command: ["sh", "-c", "echo ПРИЛОЖЕНИЕ ЗАПУЩЕНО; sleep 300"]
depends_on:
migrate:
condition: service_completed_successfully
volumes:
db-data:
EOF
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
mig() { docker compose run --rm -T migrate "$@" 2>/dev/null; }
sql() { docker compose exec -T db psql -U postgres -d appdb -tAc "$1" 2>/dev/null | tr -d ' \r'; }
printf '\n═══ Подготовка ═══\n'
docker compose down -v > /dev/null 2>&1
docker compose build -q > /dev/null 2>&1
docker compose up -d db > /dev/null 2>&1
for _ in $(seq 90); do
[ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ] && break
sleep 1
done
ok "база готова"
printf '\n═══ Пункт 1: приложение ждёт миграций ═══\n'
docker compose up -d app > /tmp/migrfull/up.log 2>&1
sleep 3
mcode="$(docker inspect "$(docker compose ps -aq migrate)" --format '{{.State.ExitCode}}' 2>/dev/null)"
appup="$(docker compose ps --format '{{.Service}}' | grep -c '^app$' || true)"
printf ' код migrate: %s, app запущен: %s\n' "$mcode" "$appup"
docker compose logs migrate --no-log-prefix 2>/dev/null | tail -3 | sed 's/^/ /'
[ "$mcode" = "0" ] && [ "$appup" = "1" ] \
&& ok "миграции прошли, приложение стартовало" || bad "migrate=$mcode app=$appup"
printf ' версия схемы: %s\n' "$(sql 'SELECT max(version) FROM schema_version')"
printf '\n═══ Пункт 3: повторный запуск ═══\n'
out="$(mig upgrade)"
echo "$out" | tail -2 | sed 's/^/ /'
rc=0; echo "$out" | grep -q 'нечего делать' || rc=1
[ "$rc" -eq 0 ] && ok "повторный запуск ничего не применил" || bad "что-то применилось"
printf ' записей в schema_version: %s\n' "$(sql 'SELECT count(*) FROM schema_version')"
[ "$(sql 'SELECT count(*) FROM schema_version')" = "3" ] \
&& ok "версии не продублировались" || bad "лишние записи"
printf '\n═══ Пункт 2: три реплики одновременно ═══\n'
docker compose down > /dev/null 2>&1
docker compose up -d db > /dev/null 2>&1
for _ in $(seq 90); do
[ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ] && break
sleep 1
done
sql 'DROP TABLE IF EXISTS tags, items, schema_version CASCADE' > /dev/null
docker compose up --scale migrate=3 migrate > /tmp/migrfull/race.log 2>&1
grep -E 'блокировка|применена|нечего|ОШИБКА' /tmp/migrfull/race.log | sed 's/^/ /'
applied="$(grep -c 'применена' /tmp/migrfull/race.log || true)"
errors="$(grep -c 'ОШИБКА' /tmp/migrfull/race.log || true)"
printf ' операторов применено: %s (ожидается 3), ошибок: %s\n' "$applied" "$errors"
[ "$applied" -eq 3 ] && [ "$errors" -eq 0 ] \
&& ok "каждая миграция применена ровно один раз" || bad "применено=$applied ошибок=$errors"
printf '\n═══ Пункт 4: упавшая миграция не оставляет следов ═══\n'
sql 'DROP TABLE IF EXISTS tags, items, schema_version CASCADE' > /dev/null
before_tables="$(sql "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'")"
printf ' таблиц до: %s\n' "$before_tables"
MIGRATE_FAIL_AT=1 docker compose run --rm -T migrate upgrade 2>/dev/null | sed 's/^/ /'
rc_fail=$?
after_tables="$(sql "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'")"
items_exists="$(sql "SELECT count(*) FROM information_schema.tables WHERE table_name='items'")"
printf ' таблиц после падения: %s, items существует: %s\n' "$after_tables" "$items_exists"
[ "$items_exists" = "0" ] && ok "схема не изменена — транзакция откачена" \
|| bad "таблица items создана несмотря на сбой"
printf '\n═══ Пункт 5: upgrade и downgrade ═══\n'
mig upgrade | tail -1 | sed 's/^/ /'
v_head="$(sql 'SELECT max(version) FROM schema_version')"
tags="$(sql "SELECT count(*) FROM information_schema.tables WHERE table_name='tags'")"
created="$(sql "SELECT count(*) FROM information_schema.columns WHERE table_name='items' AND column_name='created_at'")"
printf ' версия=%s tags=%s created_at=%s\n' "$v_head" "$tags" "$created"
mig downgrade 1 | sed 's/^/ /'
v_down="$(sql 'SELECT max(version) FROM schema_version')"
tags2="$(sql "SELECT count(*) FROM information_schema.tables WHERE table_name='tags'")"
created2="$(sql "SELECT count(*) FROM information_schema.columns WHERE table_name='items' AND column_name='created_at'")"
items2="$(sql "SELECT count(*) FROM information_schema.tables WHERE table_name='items'")"
printf ' после отката до 1: версия=%s tags=%s created_at=%s items=%s\n' \
"$v_down" "$tags2" "$created2" "$items2"
[ "$v_down" = "1" ] && [ "$tags2" = "0" ] && [ "$created2" = "0" ] && [ "$items2" = "1" ] \
&& ok "откат вернул схему к версии 1" || bad "состояние после отката неверно"
mig upgrade | tail -1 | sed 's/^/ /'
[ "$(sql 'SELECT max(version) FROM schema_version')" = "3" ] \
&& ok "повторный upgrade вернул к head" || bad "не вернулось к версии 3"
printf '\n═══ Пункт 6: скорость тестовой базы ═══\n'
docker compose up -d db-test > /dev/null 2>&1
for _ in $(seq 90); do
[ "$(docker compose ps --format '{{.Health}}' db-test)" = "healthy" ] && break
sleep 1
done
bench() { # bench <сервис> <dsn-host>
local svc="$1" host="$2" s e
docker compose exec -T "$svc" psql -U postgres -d appdb -q \
-c 'DROP TABLE IF EXISTS tags, items, schema_version CASCADE' > /dev/null 2>&1
s="$(date +%s.%N)"
docker compose run --rm -T -e DSN="postgresql://postgres:secret@$host:5432/appdb" \
migrate upgrade > /dev/null 2>&1
docker compose exec -T "$svc" psql -U postgres -d appdb -q \
-c "INSERT INTO items (name) SELECT md5(g::text) FROM generate_series(1, 20000) g" > /dev/null 2>&1
e="$(date +%s.%N)"
awk -v a="$s" -v b="$e" 'BEGIN{printf "%.2f", b-a}'
}
t_disk="$(bench db db)"
t_tmpfs="$(bench db-test db-test)"
printf ' дисковая база: %s c\n' "$t_disk"
printf ' база в памяти: %s c\n' "$t_tmpfs"
awk -v d="$t_disk" -v t="$t_tmpfs" 'BEGIN{exit !(t < d)}' \
&& ok "tmpfs быстрее дисковой" || bad "разницы нет: диск=$t_disk память=$t_tmpfs"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все шесть утверждений подтверждены" || echo " ЕСТЬ ПРОВАЛЫ"
docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/migrfull
exit "$fail"
Ожидаемый вывод:
═══ Подготовка ═══
✓ база готова
═══ Пункт 1: приложение ждёт миграций ═══
код migrate: 0, app запущен: 1
[7f3a2c1e9b04] применена 3
[7f3a2c1e9b04] применено миграций: 3, версия 3
✓ миграции прошли, приложение стартовало
версия схемы: 3
═══ Пункт 3: повторный запуск ═══
[c8d1e4f7a2b5] нечего делать, версия 3
✓ повторный запуск ничего не применил
записей в schema_version: 3
✓ версии не продублировались
═══ Пункт 2: три реплики одновременно ═══
migrate-1 | [migrfull-migr] блокировка получена
migrate-1 | [migrfull-migr] применена 1
migrate-1 | [migrfull-migr] применена 2
migrate-1 | [migrfull-migr] применена 3
migrate-2 | [migrfull-migr] блокировка получена
migrate-2 | [migrfull-migr] нечего делать, версия 3
migrate-3 | [migrfull-migr] блокировка получена
migrate-3 | [migrfull-migr] нечего делать, версия 3
операторов применено: 3 (ожидается 3), ошибок: 0
✓ каждая миграция применена ровно один раз
═══ Пункт 4: упавшая миграция не оставляет следов ═══
таблиц до: 0
[a1b2c3d4e5f6] блокировка получена
[a1b2c3d4e5f6] ОШИБКА: имитация сбоя на миграции 1, оператор 2 — транзакция откачена
таблиц после падения: 1, items существует: 0
✓ схема не изменена — транзакция откачена
═══ Пункт 5: upgrade и downgrade ═══
[b2c3d4e5f6a1] применено миграций: 3, версия 3
версия=3 tags=1 created_at=1
[c3d4e5f6a1b2] откачена 3
[c3d4e5f6a1b2] откачена 2
[c3d4e5f6a1b2] откачено миграций: 2, версия 1
после отката до 1: версия=1 tags=0 created_at=0 items=1
✓ откат вернул схему к версии 1
[d4e5f6a1b2c3] применено миграций: 2, версия 3
✓ повторный upgrade вернул к head
═══ Пункт 6: скорость тестовой базы ═══
дисковая база: 2.41 c
база в памяти: 1.12 c
✓ tmpfs быстрее дисковой
═══ ИТОГ ═══
все шесть утверждений подтверждены
Все шесть утверждений подтверждены.
Обратите внимание на строку «таблиц после падения: 1» в пункте 4: единственная оставшаяся таблица — schema_version, она создаётся и фиксируется до начала миграций отдельной транзакцией. Это осознанное решение: таблица версий должна существовать даже если все миграции провалились.
Три решения, определяющие качество.
Блокировка берётся до создания таблицы версий, а не после. Обратный порядок оставил бы гонку в самом уязвимом месте: три реплики одновременно выполняли бы CREATE TABLE schema_version. Порядок «сначала блокировка, потом всё остальное» устраняет её целиком.
MIGRATE_FAIL_AT — переменная окружения, а не правка кода. Проверка пункта 4 требует падения на середине. Внесение ошибки правкой файла означало бы, что проверяется не тот код, который работает в остальных пунктах. Переменная позволяет сломать миграцию, не трогая её.
Каждая миграция объявлена парой up и down. Хранение только up кажется проще ровно до первого неудачного развёртывания. Пункт 5 показывает цену симметрии: откат до любой версии выполняется без ручного вмешательства.
Чего решение не делает. Откат не восстанавливает данные: DROP COLUMN created_at удаляет их безвозвратно, и повторный upgrade создаст пустую колонку. Для миграций, изменяющих данные, откат обязан сопровождаться восстановлением из резервной копии (урок 7.6). Не покрыт и случай CREATE INDEX CONCURRENTLY: он не может выполняться в транзакции, и такую миграцию пришлось бы выделять отдельно, теряя атомарность.
Проверка результата
mkdir -p /tmp/mg && cd /tmp/mg
cat > compose.yaml <<'EOF'
name: mg
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: x
POSTGRES_DB: appdb
tmpfs:
- /var/lib/postgresql/data:size=256m
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 2s
retries: 20
start_period: 15s
migrate:
image: postgres:17-alpine
command:
- sh
- -c
- 'PGPASSWORD=x psql -h db -U postgres -d appdb -c "CREATE TABLE IF NOT EXISTS t (id int)" && echo МИГРАЦИЯ ОК'
depends_on:
db:
condition: service_healthy
EOF
docker compose up --abort-on-container-exit --exit-code-from migrate
docker compose down -v
cd /tmp && rm -rf /tmp/mg
Ожидается МИГРАЦИЯ ОК и код 0.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Миграции в lifespan приложения | Ничего не настраивать | Гонка при репликах; отдельный сервис |
| Нет блокировки при нескольких репликах | Не думали о параллельности | pg_advisory_lock |
sleep 10 вместо ожидания готовности | Просто | Ненадёжно; повторы с задержкой |
| Неидемпотентные миграции | Не учли повторный up | IF NOT EXISTS, таблица версий |
restart: always у сервиса миграций | Скопировали у приложения | Бесконечный перезапуск |
Нет down у миграций | Кажется лишним | Откат невозможен без ручной работы |
| Удаление колонки в общем деплое | Кажется обычной миграцией | Разрушительно; expand/contract и вручную |
| Backfill одной транзакцией | Проще написать | Длинная блокировка, раздувание журнала |
fsync=off в production | Скопировали из тестовой конфигурации | Повреждение базы при сбое |
| Откат считают безопасным | «Просто вернём как было» | Данные удалённых колонок не восстановятся |
Контрольные вопросы
На понимание:
- Почему миграции при старте приложения ломаются при нескольких репликах?
- Что делает
pg_advisory_lockи когда блокировка освобождается? - Почему
sleep— плохой способ дождаться базы? - Что даёт транзакционный DDL и где он не работает?
- Зачем нужна схема
expand/contract?
На применение:
- Как сделать миграции идемпотентными? Назовите четыре приёма.
- Как ускорить тестовую базу и почему это безопасно?
- Как организовать откат миграции?
На диагностику:
- При
deploy.replicas: 3часть реплик падает при старте. Первая версия? - Повторный
docker compose upломает схему. Где искать?
Краткое резюме
- Три способа: при старте приложения, отдельный сервис, вручную; для Compose правильный — отдельный сервис.
- Миграции при старте при нескольких репликах дают гонку и частично применённую схему.
pg_advisory_lockдаёт единственное выполнение; блокировка снимается при закрытии соединения.pg_try_advisory_lockне ждёт — реплика продолжает работу со старой схемой.- Готовности базы ждут повторами с экспоненциальной задержкой, а не
sleep. depends_on: service_healthyне заменяет повторов: база может отвалиться позже.- Миграции обязаны быть идемпотентными: повторный
upвыполняет их снова. - Таблица версий — основной механизм идемпотентности.
- Транзакционный DDL в PostgreSQL откатывает упавшую миграцию целиком.
CREATE INDEX CONCURRENTLYв транзакции невозможен — выделяют отдельно.expand/contractпозволяет менять схему без остановки при нескольких репликах.- Тестовая база на
tmpfsсfsync=offбыстрее в разы и ничем не рискует.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| PostgreSQL: advisory locks | https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS | Механизм, область действия, освобождение |
| PostgreSQL: transactions and DDL | https://www.postgresql.org/docs/current/sql-begin.html | Транзакционный DDL |
PostgreSQL: CREATE INDEX CONCURRENTLY | https://www.postgresql.org/docs/current/sql-createindex.html | Невозможность в транзакции |
| PostgreSQL: non-durable settings | https://www.postgresql.org/docs/current/non-durability.html | fsync, synchronous_commit для тестов |
| Alembic | https://alembic.sqlalchemy.org/en/latest/ | upgrade, downgrade, таблица версий |
| Compose: depends_on | https://docs.docker.com/reference/compose-file/services/#depends_on | service_completed_successfully |
| Docker Hub: postgres | https://hub.docker.com/_/postgres | Параметры запуска, инициализация |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Автоматизация
Главное оглавление