Цели модуля

После модуля студент сможет:

  • Понимать зачем нужны миграции базы данных
  • Использовать goose и golang-migrate для версионирования схемы
  • Писать Up и Down миграции
  • Применять и откатывать миграции
  • Работать с транзакциями в PostgreSQL
  • Понимать уровни изоляции транзакций
  • Реализовывать паттерн outbox для атомарности
  • Тестировать миграции с testcontainers

11.1 Зачем нужны миграции

Проблема без миграций

Без миграций:

  • Невозможно воспроизвести схему БД
  • Изменения применяются вручную (ошибки, расхождения)
  • Невозможно откатить изменения
  • Команда не синхронизирована
  • Production и staging имеют разную схему

Что дают миграции

  • Версионирование схемы БД (как git для кода)
  • Воспроизводимость: любой может поднять БД с нуля
  • Откат: можно откатить неудачное изменение
  • Синхронизация команды: все применяют одни миграции
  • Аудит: видно историю изменений схемы

Принцип миграций

Миграция - файл с SQL который:

  • Применяет изменение (Up)
  • Откатывает изменение (Down)

Миграции применяются по порядку версий. Каждая миграция применяется один раз.


11.2 goose vs golang-migrate

goose

Популярный инструмент миграций для Go.

Установка:

bash
go install github.com/pressly/goose/v3/cmd/goose@latest

Формат файлов:

text
migrations/
├── 001_init.sql
├── 002_repos.sql
├── 003_users.sql
└── 004_outbox.sql

Внутри файла:

sql
-- +goose Up
CREATE TABLE repos (...);

-- +goose Down
DROP TABLE repos;

golang-migrate

Альтернативный инструмент с разделением Up/Down на отдельные файлы.

Установка:

bash
go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest

Формат файлов:

text
migrations/
├── 001_init.up.sql
├── 001_init.down.sql
├── 002_repos.up.sql
└── 002_repos.down.sql

Сравнение

Критерийgoosegolang-migrate
ФайлыОдин файл с Up/DownОтдельные Up и Down
ПростотаПрощеБольше файлов
CLIgoosemigrate
Поддержка GoВстроеннаяВстроенная
ПопулярностьВысокаяВысокая

Для проектов рекомендуется goose (один файл проще поддерживать).


11.3 Написание миграций

Структура файла миграции (goose)

sql
-- +goose Up
-- Комментарий: что делает миграция
CREATE TABLE repos (
    id UUID PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    owner_id UUID NOT NULL REFERENCES users(id),
    is_private BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_repos_owner_id ON repos(owner_id);

-- +goose Down
DROP TABLE repos;

Правила написания миграций

  1. Одна миграция - одно логическое изменение
  2. Всегда пишите Down миграцию
  3. Миграции должны быть идемпотентными (по возможности)
  4. Не изменяйте применённые миграции - создавайте новые
  5. Используйте IF NOT EXISTS / IF EXISTS для безопасности
  6. Тестируйте миграции на чистой БД

Примеры миграций

Создание таблицы:

sql
-- +goose Up
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- +goose Down
DROP TABLE IF EXISTS users;

Добавление колонки:

sql
-- +goose Up
ALTER TABLE repos ADD COLUMN IF NOT EXISTS description TEXT;

-- +goose Down
ALTER TABLE repos DROP COLUMN IF EXISTS description;

Создание индекса:

sql
-- +goose Up
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_repos_name ON repos(name);

-- +goose Down
DROP INDEX IF EXISTS idx_repos_name;

ВАЖНО: CREATE INDEX CONCURRENTLY не блокирует таблицу, но не может выполняться внутри транзакции. Для goose:

sql
-- +goose Up
-- +goose NO TRANSACTION
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_repos_name ON repos(name);

-- +goose Down
DROP INDEX IF EXISTS idx_repos_name;

Изменение данных (data migration)

sql
-- +goose Up
UPDATE repos SET is_private = FALSE WHERE is_private IS NULL;

-- +goose Down
-- Откат не всегда возможен для data migration

11.4 Применение миграций

goose CLI

bash
# Применить все миграции
goose -dir migrations postgres "postgres://user:pass@localhost/db?sslmode=disable" up

# Откатить одну миграцию
goose -dir migrations postgres "postgres://..." down

# Откатить все миграции
goose -dir migrations postgres "postgres://..." reset

# Статус миграций
goose -dir migrations postgres "postgres://..." status

# Создать новую миграцию
goose -dir migrations create add_description sql

Миграции в коде Go

Для применения миграций при старте приложения:

go
package migrations

import (
    "embed"
    "github.com/pressly/goose/v3"
)

//go:embed *.sql
var migrationFiles embed.FS

func Run(db *sql.DB) error {
    goose.SetBaseFS(migrationFiles)

    if err := goose.SetDialect("postgres"); err != nil {
        return err
    }

    return goose.Up(db, ".")
}

Использование

go
func main() {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    if err := migrations.Run(db); err != nil {
        log.Fatalf("run migrations: %v", err)
    }

    // Запуск приложения
}

Миграции в CI/CD

Миграции применяются как отдельный шаг перед деплоем:

makefile
# В Makefile
migrate:
    goose -dir migrations postgres "$(DATABASE_URL)" up

# В GitHub Actions
- name: Run migrations
  run: make migrate

11.5 Транзакции в PostgreSQL

Что такое транзакция

Транзакция - атомарная единица работы с БД. Все операции внутри транзакции либо выполняются целиком, либо не выполняются вообще.

Свойства ACID:

  • Atomicity: всё или ничего
  • Consistency: БД переходит из одного валидного состояния в другое
  • Isolation: параллельные транзакции не влияют друг на друга
  • Durability: зафиксированные изменения сохраняются

Базовый синтаксис в pgx

go
func (r *RepoRepository) CreateWithEvent(ctx context.Context, repo *models.Repo, event *models.OutboxEvent) error {
    // Начало транзакции
    tx, err := r.pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    // Гарантируем откат при ошибке
    defer tx.Rollback(ctx)

    // Операция 1: создание репо
    if err := r.createRepoTx(ctx, tx, repo); err != nil {
        return fmt.Errorf("create repo: %w", err)
    }

    // Операция 2: запись события в outbox
    if err := r.insertEventTx(ctx, tx, event); err != nil {
        return fmt.Errorf("insert event: %w", err)
    }

    // Фиксация транзакции
    if err := tx.Commit(ctx); err != nil {
        return fmt.Errorf("commit tx: %w", err)
    }

    return nil
}

defer tx.Rollback()

Паттерн defer tx.Rollback() гарантирует откат если:

  • Произошла ошибка до Commit
  • Функция завершилась через return
  • Произошёл panic

После успешного Commit() вызов Rollback() безопасен (no-op).

Вспомогательные функции для транзакций

go
func (r *RepoRepository) createRepoTx(ctx context.Context, tx pgx.Tx, repo *models.Repo) error {
    query := "INSERT INTO repos (id, name, owner_id, is_private, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)"

    _, err := tx.Exec(ctx, query,
        repo.ID, repo.Name, repo.OwnerID,
        repo.IsPrivate, repo.CreatedAt, repo.UpdatedAt,
    )
    return err
}

func (r *RepoRepository) insertEventTx(ctx context.Context, tx pgx.Tx, event *models.OutboxEvent) error {
    query := "INSERT INTO outbox_events (id, type, payload, created_at) VALUES ($1, $2, $3, $4)"

    _, err := tx.Exec(ctx, query,
        event.ID, event.Type, event.Payload, event.CreatedAt,
    )
    return err
}

Паттерн: WithTransaction helper

go
func WithTransaction(ctx context.Context, pool *pgxpool.Pool, fn func(pgx.Tx) error) error {
    tx, err := pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    if err := fn(tx); err != nil {
        return err
    }

    return tx.Commit(ctx)
}

// Использование
err := WithTransaction(ctx, pool, func(tx pgx.Tx) error {
    if err := createRepo(ctx, tx, repo); err != nil {
        return err
    }
    if err := insertEvent(ctx, tx, event); err != nil {
        return err
    }
    return nil
})

11.6 Уровни изоляции

Что такое уровень изоляции

Уровень изоляции определяет как транзакция видит изменения параллельных транзакций.

Уровни в PostgreSQL

УровеньDirty ReadNon-repeatable ReadPhantom Read
READ UNCOMMITTEDВозможноВозможноВозможно
READ COMMITTEDНетВозможноВозможно
REPEATABLE READНетНетВозможно
SERIALIZABLEНетНетНет

PostgreSQL по умолчанию: READ COMMITTED.

READ UNCOMMITTED в PostgreSQL работает как READ COMMITTED.

Аномалии

Dirty Read: транзакция видит незафиксированные изменения другой транзакции.

Non-repeatable Read: повторное чтение строки возвращает другие данные (другая транзакция изменила и зафиксировала).

Phantom Read: повторное чтение диапазона возвращает новые строки (другая транзакция добавила и зафиксировала).

Установка уровня изоляции

go
tx, err := pool.BeginTx(ctx, pgx.TxOptions{
    IsoLevel: pgx.RepeatableRead,
})

Когда использовать каждый уровень

УровеньКогда
READ COMMITTEDБольшинство операций (default)
REPEATABLE READОтчёты, аналитика, консистентные чтения
SERIALIZABLEФинансовые операции, критичная консистентность

Высокие уровни изоляции снижают параллельность. Используйте минимально достаточный уровень.


11.7 Паттерн outbox

Проблема

Нужно атомарно:

  1. Записать данные в БД
  2. Отправить событие в очередь

Если отправить событие до записи - возможна отправка без записи. Если после - возможна запись без события (сбой между операциями).

Решение: Outbox Pattern

Событие записывается в ту же транзакцию что и данные. Отдельный воркер читает outbox и отправляет события в очередь.

go
// Транзакция: данные + событие
tx.Begin()
INSERT INTO repos (...) VALUES (...)
INSERT INTO outbox_events (type, payload) VALUES ('repo.created', '...')
tx.Commit()

// Отдельный воркер (outbox relay)
for {
    events := SELECT * FROM outbox_events WHERE status = 'pending' LIMIT 100
    for _, event := range events {
        publishToQueue(event)
        UPDATE outbox_events SET status = 'sent' WHERE id = event.id
    }
}

Схема outbox таблицы

sql
-- +goose Up
CREATE TABLE outbox_events (
    id UUID PRIMARY KEY,
    type VARCHAR(100) NOT NULL,
    payload JSONB NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    sent_at TIMESTAMPTZ
);

CREATE INDEX idx_outbox_status ON outbox_events(status) WHERE status = 'pending';

-- +goose Down
DROP TABLE outbox_events;

Пример использования в usecase

go
func (uc *CreateRepoUseCase) Execute(ctx context.Context, req CreateRepoRequest) (*models.Repo, error) {
    // Валидация
    if err := req.Validate(); err != nil {
        return nil, fmt.Errorf("validate: %w", err)
    }

    repo := req.ToDomain()

    // Создаём событие
    event := models.NewOutboxEvent("repo.created", repo)

    // Атомарная операция: репо + событие
    if err := uc.repoService.CreateWithEvent(ctx, repo, event); err != nil {
        return nil, fmt.Errorf("create repo with event: %w", err)
    }

    return repo, nil
}

Преимущества outbox

  • Атомарность: данные и событие в одной транзакции
  • Надёжность: событие не потеряется при сбое
  • Идемпотентность: воркер может повторно обрабатывать события
  • Декомпозиция: сервис не знает о конкретной очереди

Недостатки outbox

  • Задержка: событие отправляется асинхронно
  • Сложность: нужен отдельный воркер
  • Хранение: события накапливаются в таблице

11.8 Тестирование миграций

Интеграционные тесты с testcontainers

go
func TestMigrations(t *testing.T) {
    ctx := context.Background()

    // Запуск PostgreSQL
    pgContainer, err := postgres.Run(ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    require.NoError(t, err)
    defer pgContainer.Terminate(ctx)

    dsn, err := pgContainer.ConnectionString(ctx)
    require.NoError(t, err)

    // Подключение
    db, err := sql.Open("postgres", dsn)
    require.NoError(t, err)
    defer db.Close()

    // Применение миграций
    err = migrations.Run(db)
    assert.NoError(t, err)

    // Проверка что таблицы созданы
    var count int
    err = db.QueryRow("SELECT COUNT(*) FROM repos").Scan(&count)
    assert.NoError(t, err)
}

Тесты транзакций

go
func TestCreateWithEvent(t *testing.T) {
    // Setup: чистая БД через testcontainers


    repo := models.NewRepo("test", "user-1", false)
    event := models.NewOutboxEvent("repo.created", repo)

    // Успешный случай
    err := repoRepo.CreateWithEvent(ctx, repo, event)
    assert.NoError(t, err)

    // Проверяем что и репо и событие созданы
    gotRepo, err := repoRepo.GetByID(ctx, repo.ID)
    assert.NoError(t, err)
    assert.NotNil(t, gotRepo)

    gotEvent, err := outboxRepo.GetByRepoID(ctx, repo.ID)
    assert.NoError(t, err)
    assert.NotNil(t, gotEvent)
}

func TestCreateWithEvent_Rollback(t *testing.T) {
    // Setup: mock который падает на insert event

    repo := models.NewRepo("test", "user-1", false)
    event := models.NewOutboxEvent("repo.created", repo)

    // Симулируем ошибку на втором шаге
    err := repoRepo.CreateWithEventWithError(ctx, repo, event)
    assert.Error(t, err)

    // Проверяем что репо НЕ создан (rollback)
    gotRepo, err := repoRepo.GetByID(ctx, repo.ID)
    assert.NoError(t, err)
    assert.Nil(t, gotRepo)
}

Привязка к шаблону проекта

Где миграции в шаблоне

text
migrations/
├── 001_init.sql              # Базовая схема
├── 002_repos.sql             # Таблица repos
├── 003_pull_requests.sql     # Таблица pull_requests
├── 004_outbox.sql            # Таблица outbox_events
├── 005_sagas.sql             # Таблица saga_instances
└── 006_idempotency_keys.sql  # Таблица idempotency_keys

Что изучаем на примере шаблона

  1. Миграции нумеруются по порядку
  2. Каждая миграция содержит Up и Down
  3. Используются IF NOT EXISTS / IF EXISTS
  4. Индексы создаются вместе с таблицами
  5. Foreign keys связывают таблицы

Задание: изучите migrations/

  • Какие таблицы создаются?
  • Есть ли Down для каждой миграции?
  • Какие индексы создаются?
  • Есть ли Foreign keys?

💱 Компромиссы

  • goose vs golang-migrate: у goose Up и Down живут в одном файле — меньше файлов и проще ревью; у golang-migrate пары .up.sql и .down.sql дают явное разделение, но файлов вдвое больше.
  • CREATE INDEX CONCURRENTLY vs индекс в транзакции: concurrently не блокирует запись на большой таблице, но не работает внутри транзакции и требует -- +goose NO TRANSACTION; обычный CREATE INDEX атомарен, но держит блокировку на всё время построения.
  • Миграции при старте пода vs шаг в CI/CD: запуск goose.Up в main держит схему и код синхронно, но несколько инстансов соревнуются за право мигрировать и сбой роняет старт; отдельный шаг в пайплайне выполняется один раз, но его можно забыть — схема и код разъедутся.
  • READ COMMITTED vs SERIALIZABLE: дефолтный уровень пропускает больше параллельных транзакций, но допускает non-repeatable read и фантомы; SERIALIZABLE убирает аномалии, но требует повторов при ошибках сериализации и режет пропускную способность.
  • Outbox vs прямая отправка в очередь: запись события в ту же транзакцию даёт атомарность «данные плюс событие», но добавляет задержку, отдельного воркера и рост таблицы outbox_events; публикация сразу проще и быстрее, но событие можно потерять или отправить без записи в БД.

Практическое задание

Задание 1: Установка goose

Установите goose и создайте первую миграцию:

bash
go install github.com/pressly/goose/v3/cmd/goose@latest

mkdir migrations
goose -dir migrations create init sql

Задание 2: Миграция для users

Напишите миграцию для таблицы users:

sql
-- +goose Up
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- +goose Down
DROP TABLE IF EXISTS users;

Задание 3: Миграция для repos с foreign key

sql
-- +goose Up
CREATE TABLE IF NOT EXISTS repos (
    id UUID PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    owner_id UUID NOT NULL REFERENCES users(id),
    is_private BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_repos_owner_id ON repos(owner_id);

-- +goose Down
DROP TABLE IF EXISTS repos;

Задание 4: Применение миграций

Примените миграции к локальной БД:

text
goose -dir migrations postgres "postgres://app:secret@localhost:5432/mydb?sslmode=disable" up

Проверьте статус:

text
goose -dir migrations postgres "postgres://..." status

Откатите последнюю миграцию:

text
goose -dir migrations postgres "postgres://..." down

Задание 5: Транзакция с outbox

Реализуйте CreateWithEvent:

go
func (r *RepoRepository) CreateWithEvent(ctx context.Context, repo *models.Repo, event *models.OutboxEvent) error {
    tx, err := r.pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    if err := r.createRepoTx(ctx, tx, repo); err != nil {
        return fmt.Errorf("create repo: %w", err)
    }

    if err := r.insertEventTx(ctx, tx, event); err != nil {
        return fmt.Errorf("insert event: %w", err)
    }

    return tx.Commit(ctx)
}

Задание 6: Интеграционный тест с testcontainers

Напишите тест который:

  1. Запускает PostgreSQL в Docker
  2. Применяет миграции
  3. Создаёт репо с событием
  4. Проверяет что оба записаны
  5. Проверяет rollback при ошибке

Quiz "Проверь себя"

1. Как работает outbox pattern?
2. Какой уровень изоляции по умолчанию в PostgreSQL?
3. Что произойдёт, если не вызвать tx.Commit()?
4. Какие уровни изоляции есть в PostgreSQL?
5. Чем goose Up отличается от goose Down?
6. Что дают миграции команде и проекту? — выберите все подходящие
7. Зачем нужен defer tx.Rollback()?
8. Почему нельзя изменять применённые миграции?

✅ Итоги модуля

Студент должен:

  • Понимать зачем нужны миграции
  • Писать Up и Down миграции в goose
  • Применять и откатывать миграции через CLI
  • Работать с транзакциями в pgx
  • Понимать уровни изоляции
  • Реализовывать паттерн outbox
  • Тестировать миграции с testcontainers
  • Ответить на все вопросы Quiz

Итог Курса 1

Поздравляем! Вы завершили Курс 1: Go Foundation.

Что вы освоили:

  1. Введение в Go и экосистему
  2. Базовые понятия языка
  3. Указатели, интерфейсы и композицию
  4. Обработку ошибок
  5. Тестирование
  6. Конкурентность: горутины и каналы
  7. Context и отмену операций
  8. Создание Web API сервиса
  9. Middleware и валидацию
  10. Работу с PostgreSQL: основы
  11. Миграции и транзакции

Вы можете:

  • Писать идиоматичный Go-код
  • Создавать REST API сервисы
  • Работать с PostgreSQL
  • Писать тесты
  • Использовать конкурентность
  • Реализовывать middleware
  • Управлять миграциями БД

🔗 Мостик к Модулю 12

Следующий шаг: Bridge Course 1-2: Go in Practice (2 недели)

Фокус:

  • Pet-project с полной реализацией
  • Code review (давать и получать фидбек)
  • Настройка CI (GitHub Actions)
  • Опыт полного цикла разработки

После Bridge: Курс 2: Go Backend Developer (Middle)

  • Чистая архитектура
  • Виды организации проектов
  • Clean Architecture Deep Dive
  • Configuration Management
  • Database Access: PostgreSQL (углублённо)
  • Business Logic Layer
  • Observability
  • Background Processing
  • API Security
  • Docker и контейнеризация