Цели модуля
После модуля студент сможет:
- Понимать зачем нужны миграции базы данных
- Использовать goose и golang-migrate для версионирования схемы
- Писать Up и Down миграции
- Применять и откатывать миграции
- Работать с транзакциями в PostgreSQL
- Понимать уровни изоляции транзакций
- Реализовывать паттерн outbox для атомарности
- Тестировать миграции с testcontainers
11.1 Зачем нужны миграции
Проблема без миграций
Без миграций:
- Невозможно воспроизвести схему БД
- Изменения применяются вручную (ошибки, расхождения)
- Невозможно откатить изменения
- Команда не синхронизирована
- Production и staging имеют разную схему
Что дают миграции
- Версионирование схемы БД (как git для кода)
- Воспроизводимость: любой может поднять БД с нуля
- Откат: можно откатить неудачное изменение
- Синхронизация команды: все применяют одни миграции
- Аудит: видно историю изменений схемы
Принцип миграций
Миграция - файл с SQL который:
- Применяет изменение (Up)
- Откатывает изменение (Down)
Миграции применяются по порядку версий. Каждая миграция применяется один раз.
11.2 goose vs golang-migrate
goose
Популярный инструмент миграций для Go.
Установка:
bashgo install github.com/pressly/goose/v3/cmd/goose@latest
Формат файлов:
textmigrations/ ├── 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 на отдельные файлы.
Установка:
bashgo install github.com/golang-migrate/migrate/v4/cmd/migrate@latest
Формат файлов:
textmigrations/ ├── 001_init.up.sql ├── 001_init.down.sql ├── 002_repos.up.sql └── 002_repos.down.sql
Сравнение
| Критерий | goose | golang-migrate |
|---|---|---|
| Файлы | Один файл с Up/Down | Отдельные Up и Down |
| Простота | Проще | Больше файлов |
| CLI | goose | migrate |
| Поддержка 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;
Правила написания миграций
- Одна миграция - одно логическое изменение
- Всегда пишите Down миграцию
- Миграции должны быть идемпотентными (по возможности)
- Не изменяйте применённые миграции - создавайте новые
- Используйте IF NOT EXISTS / IF EXISTS для безопасности
- Тестируйте миграции на чистой БД
Примеры миграций
Создание таблицы:
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
Для применения миграций при старте приложения:
gopackage 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, ".") }
Использование
gofunc 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
gofunc (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).
Вспомогательные функции для транзакций
gofunc (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
gofunc 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 Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | Возможно | Возможно | Возможно |
| READ COMMITTED | Нет | Возможно | Возможно |
| REPEATABLE READ | Нет | Нет | Возможно |
| SERIALIZABLE | Нет | Нет | Нет |
PostgreSQL по умолчанию: READ COMMITTED.
READ UNCOMMITTED в PostgreSQL работает как READ COMMITTED.
Аномалии
Dirty Read: транзакция видит незафиксированные изменения другой транзакции.
Non-repeatable Read: повторное чтение строки возвращает другие данные (другая транзакция изменила и зафиксировала).
Phantom Read: повторное чтение диапазона возвращает новые строки (другая транзакция добавила и зафиксировала).
Установка уровня изоляции
gotx, err := pool.BeginTx(ctx, pgx.TxOptions{ IsoLevel: pgx.RepeatableRead, })
Когда использовать каждый уровень
| Уровень | Когда |
|---|---|
| READ COMMITTED | Большинство операций (default) |
| REPEATABLE READ | Отчёты, аналитика, консистентные чтения |
| SERIALIZABLE | Финансовые операции, критичная консистентность |
Высокие уровни изоляции снижают параллельность. Используйте минимально достаточный уровень.
11.7 Паттерн outbox
Проблема
Нужно атомарно:
- Записать данные в БД
- Отправить событие в очередь
Если отправить событие до записи - возможна отправка без записи. Если после - возможна запись без события (сбой между операциями).
Решение: 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
gofunc (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
gofunc 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) }
Тесты транзакций
gofunc 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) }
Привязка к шаблону проекта
Где миграции в шаблоне
textmigrations/ ├── 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
Что изучаем на примере шаблона
- Миграции нумеруются по порядку
- Каждая миграция содержит Up и Down
- Используются IF NOT EXISTS / IF EXISTS
- Индексы создаются вместе с таблицами
- Foreign keys связывают таблицы
Задание: изучите migrations/
- Какие таблицы создаются?
- Есть ли Down для каждой миграции?
- Какие индексы создаются?
- Есть ли Foreign keys?
💱 Компромиссы
- goose vs golang-migrate: у goose
UpиDownживут в одном файле — меньше файлов и проще ревью; у golang-migrate пары.up.sqlи.down.sqlдают явное разделение, но файлов вдвое больше. CREATE INDEX CONCURRENTLYvs индекс в транзакции: 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 и создайте первую миграцию:
bashgo 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: Применение миграций
Примените миграции к локальной БД:
textgoose -dir migrations postgres "postgres://app:secret@localhost:5432/mydb?sslmode=disable" up
Проверьте статус:
textgoose -dir migrations postgres "postgres://..." status
Откатите последнюю миграцию:
textgoose -dir migrations postgres "postgres://..." down
Задание 5: Транзакция с outbox
Реализуйте CreateWithEvent:
gofunc (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
Напишите тест который:
- Запускает PostgreSQL в Docker
- Применяет миграции
- Создаёт репо с событием
- Проверяет что оба записаны
- Проверяет rollback при ошибке
Quiz "Проверь себя"
✅ Итоги модуля
Студент должен:
- Понимать зачем нужны миграции
- Писать Up и Down миграции в goose
- Применять и откатывать миграции через CLI
- Работать с транзакциями в pgx
- Понимать уровни изоляции
- Реализовывать паттерн outbox
- Тестировать миграции с testcontainers
- Ответить на все вопросы Quiz
Итог Курса 1
Поздравляем! Вы завершили Курс 1: Go Foundation.
Что вы освоили:
- Введение в Go и экосистему
- Базовые понятия языка
- Указатели, интерфейсы и композицию
- Обработку ошибок
- Тестирование
- Конкурентность: горутины и каналы
- Context и отмену операций
- Создание Web API сервиса
- Middleware и валидацию
- Работу с PostgreSQL: основы
- Миграции и транзакции
Вы можете:
- Писать идиоматичный 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 и контейнеризация