Главная/Development workflow/Урок

10.4. Database migrations

Цели

После этого материала вы сможете:

  • выбрать между тремя способами запуска миграций и обосновать выбор;
  • объяснить, почему миграции при старте приложения ломаются при нескольких репликах;
  • защитить миграции от одновременного выполнения консультативной блокировкой;
  • дождаться готовности базы правильно, а не через sleep;
  • обеспечить идемпотентность и возможность отката;
  • организовать тестовую базу, пересоздаваемую за секунды.

Предварительные знания

Ключевые термины

ТерминОбъяснение
миграцияИзменение схемы базы, применяемое версионированно
advisory lockКонсультативная блокировка PostgreSQL, не связанная с таблицами
expand/contractСхема совместимых миграций: сначала добавить, потом убрать
headПоследняя миграция в цепочке (терминология Alembic)
идемпотентностьПовторный запуск не меняет результат

Теория

Три способа и их цена

СпособКогда выполняетсяПлюсыМинусы
При старте приложенияВ коде, до приёма запросовНичего не настраиватьГонка при репликах; медленный старт; провал роняет приложение
Отдельный сервис ComposeМежду базой и приложениемОдин раз; провал останавливает запускТребует конфигурации
ВручнуюПо команде оператораПолный контрольЗабудут; расхождение сред

Рекомендация для Compose — отдельный сервис (урок 9.4). Он даёт единственное выполнение и превращает провал миграции в отказ запуска, а не в приложение со старой схемой.

Ручной запуск остаётся нужным для разрушительных операций: удаление колонки, переливка данных, длительное построение индекса. Такие миграции не должны выполняться автоматически при деплое.

Почему «при старте» ломается

python
# Кажется удобным
@asynccontextmanager
async def lifespan(app):
    run_migrations()          # ← опасно
    yield

При deploy.replicas: 3 три процесса начинают миграцию одновременно:

Что происходитРезультат
Три CREATE TABLE подрядДвое получают ошибку «уже существует»
Три ALTER TABLEВзаимоблокировка или частично применённая схема
Одна реплика упала на серединеСхема в неопределённом состоянии

Проблема не гипотетическая: она проявляется именно при масштабировании, то есть в production, а не при разработке с одной репликой.

Если способ всё же выбран, обязательна консультативная блокировка.

Консультативная блокировка

PostgreSQL позволяет взять блокировку по произвольному числовому ключу:

sql
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).

python
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 EXISTSCREATE 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 — приём совместимых миграций:

text
1. expand:   добавить новую колонку, оставив старую
2. deploy:   код пишет в обе, читает из новой
3. backfill: заполнить новую колонку из старой
4. deploy:   код использует только новую
5. contract: удалить старую колонку

Каждый шаг обратим по отдельности, и на любом из них старая и новая версии кода работают одновременно. Это единственный способ обновлять схему без остановки при нескольких репликах.

Не все миграции обратимы: удаление колонки теряет данные. Поэтому шаг 5 выполняют вручную и после резервной копии (урок 7.6).

Тестовая база

Требования к ней другие: скорость важнее сохранности.

yaml
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 и большинство операций откатываются при ошибке.

sql
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 обычно хранит числовую версию — этого достаточно для линейной цепочки миграций.


Команды и примеры

Гонка при миграциях в приложении

bash
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/^/  /'

Ожидаемый вывод:

text
═══ три реплики выполняют миграцию одновременно ═══
  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: идемпотентность

bash
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/^/  /'

Ожидаемый вывод:

text
═══ три реплики, идемпотентная миграция ═══
  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: консультативная блокировка

bash
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/^/  версия: /'

Ожидаемый вывод:

text
═══ три реплики под блокировкой ═══
  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 получили её только после того, как первая освободила.

Повторный запуск ничего не ломает

bash
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'

Ожидаемый вывод:

text
═══ повторный запуск ═══
  migr-racy-1  | racy-1: нечего применять, схема версии 2
  migr-racy-2  | racy-2: нечего применять, схема версии 2
  migr-racy-3  | racy-3: нечего применять, схема версии 2
═══ версия схемы не изменилась ═══
  записей в schema_version: 2

Таблица версий сделала своё дело: повторный запуск не выполнил ни одного оператора.

Транзакционный DDL: упавшая миграция не оставляет следов

bash
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

Ожидаемый вывод:

text
  таблиц part_* до миграции: 0
  создана part_one
  создана part_two
  миграция упала: SyntaxError
  таблиц part_* после падения: 0

Обе таблицы были созданы и обе исчезли при откате транзакции. Схема осталась в исходном состоянии.

Это свойство PostgreSQL, а не Docker — но именно оно делает миграции в container безопасными: упавший container не оставляет базу наполовину мигрированной.

expand/contract для zero-downtime

bash
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

Ожидаемый вывод:

text
═══ шаг 1: expand ═══
  expand: колонка title добавлена, name сохранена
  колонки: ['name', 'title']
═══ шаг 3: backfill ═══
  backfill: заполнено строк 500
  осталось незаполненных: 0
═══ шаг 5: contract (только вручную, после проверки) ═══
  contract: колонка name удалена

Между шагами 1 и 5 старая и новая версии кода работают одновременно: старая пишет в name, новая — в title, обе видят свои данные.

Обратите внимание на защиту в contract.py: он отказывается удалять колонку, если не все строки заполнены. Разрушительные операции должны проверять предусловия сами.

Обратите внимание и на порции по 100 строк в backfill: длинная транзакция на большой таблице держит блокировки и раздувает журнал.

Тестовая база: скорость

bash
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

Ожидаемый вывод:

text
═══ 20 000 вставок плюс индекс ═══
  db-disk   1.84 c
  db-tmpfs  0.61 c

Втрое быстрее. На наборе из сотен тестов, каждый из которых пересоздаёт данные, разница переходит из секунд в минуты.

Настройки fsync=off и synchronous_commit=off недопустимы для реальных данных: при сбое питания база окажется повреждённой. Для тестовой базы, живущей в памяти, терять уже нечего.

bash
cd /tmp/migr && docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/migr

Практическое упражнение

Задание. Реализуйте систему миграций и подтвердите шесть утверждений.

  1. Миграции выполняются отдельным сервисом; приложение стартует только после их успешного завершения.
  2. Три реплики миграций не конфликтуют: операторы выполняются один раз.
  3. Повторный запуск не применяет ничего и завершается с кодом 0.
  4. Упавшая миграция не оставляет схему частично изменённой.
  5. Реализованы upgrade и downgrade; откат возвращает схему к предыдущей версии.
  6. Тестовая база на tmpfs пересоздаётся и мигрирует быстрее дисковой.

Подсказки

Подсказка 1

Для пункта 2 нужна pg_advisory_lock с ключом, одинаковым у всех реплик.

Подсказка 2

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

Подсказка 3

Для пункта 5 каждой миграции нужна пара операторов: применение и откат.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Подготовка ═══
  ✓ база готова

═══ Пункт 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: он не может выполняться в транзакции, и такую миграцию пришлось бы выделять отдельно, теряя атомарность.

Проверка результата

bash
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 вместо ожидания готовностиПростоНенадёжно; повторы с задержкой
Неидемпотентные миграцииНе учли повторный upIF NOT EXISTS, таблица версий
restart: always у сервиса миграцийСкопировали у приложенияБесконечный перезапуск
Нет down у миграцийКажется лишнимОткат невозможен без ручной работы
Удаление колонки в общем деплоеКажется обычной миграциейРазрушительно; expand/contract и вручную
Backfill одной транзакциейПроще написатьДлинная блокировка, раздувание журнала
fsync=off в productionСкопировали из тестовой конфигурацииПовреждение базы при сбое
Откат считают безопасным«Просто вернём как было»Данные удалённых колонок не восстановятся

Контрольные вопросы

На понимание:

  1. Почему миграции при старте приложения ломаются при нескольких репликах?
  2. Что делает pg_advisory_lock и когда блокировка освобождается?
  3. Почему sleep — плохой способ дождаться базы?
  4. Что даёт транзакционный DDL и где он не работает?
  5. Зачем нужна схема expand/contract?

На применение:

  1. Как сделать миграции идемпотентными? Назовите четыре приёма.
  2. Как ускорить тестовую базу и почему это безопасно?
  3. Как организовать откат миграции?

На диагностику:

  1. При deploy.replicas: 3 часть реплик падает при старте. Первая версия?
  2. Повторный docker compose up ломает схему. Где искать?

Краткое резюме

  1. Три способа: при старте приложения, отдельный сервис, вручную; для Compose правильный — отдельный сервис.
  2. Миграции при старте при нескольких репликах дают гонку и частично применённую схему.
  3. pg_advisory_lock даёт единственное выполнение; блокировка снимается при закрытии соединения.
  4. pg_try_advisory_lock не ждёт — реплика продолжает работу со старой схемой.
  5. Готовности базы ждут повторами с экспоненциальной задержкой, а не sleep.
  6. depends_on: service_healthy не заменяет повторов: база может отвалиться позже.
  7. Миграции обязаны быть идемпотентными: повторный up выполняет их снова.
  8. Таблица версий — основной механизм идемпотентности.
  9. Транзакционный DDL в PostgreSQL откатывает упавшую миграцию целиком.
  10. CREATE INDEX CONCURRENTLY в транзакции невозможен — выделяют отдельно.
  11. expand/contract позволяет менять схему без остановки при нескольких репликах.
  12. Тестовая база на tmpfs с fsync=off быстрее в разы и ничем не рискует.

Официальные источники

ИсточникСсылкаЧто подтверждает
PostgreSQL: advisory lockshttps://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKSМеханизм, область действия, освобождение
PostgreSQL: transactions and DDLhttps://www.postgresql.org/docs/current/sql-begin.htmlТранзакционный DDL
PostgreSQL: CREATE INDEX CONCURRENTLYhttps://www.postgresql.org/docs/current/sql-createindex.htmlНевозможность в транзакции
PostgreSQL: non-durable settingshttps://www.postgresql.org/docs/current/non-durability.htmlfsync, synchronous_commit для тестов
Alembichttps://alembic.sqlalchemy.org/en/latest/upgrade, downgrade, таблица версий
Compose: depends_onhttps://docs.docker.com/reference/compose-file/services/#depends_onservice_completed_successfully
Docker Hub: postgreshttps://hub.docker.com/_/postgresПараметры запуска, инициализация

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Автоматизация
Главное оглавление

Markdown на GitHub ↗