Опирается на правила:
R-SQLC-MIG-1,R-SQLC-MIG-2,R-SQLC-MIG-3,R-SQLC-MIG-4,R-SQLC-MIG-X1,R-SQLC-MIG-X2из sqlc Style Guide → раздел 9. Миграции. Безопасность DDL-операций — по правиламPG-M-*из PostgreSQL: миграции.
Важно знать
- Схема версионируется через файлы миграций в
db/migrations/; никакогоCREATE TABLEв коде приложения.sqlc generateперечитывает схему из файлов миграций, не из живой БД —schema: "db/migrations"вsqlc.yaml.- golang-migrate и goose — оба допустимы; выбор фиксируется в начале проекта и не меняется.
- Применение при старте через
migrate.Up()— обязательно. Производственный деплой не требует ручного вмешательства.- Уже применённую миграцию не редактируют. Исправление — новой миграцией поверх.
CREATE INDEX CONCURRENTLYиlock_timeout— по правиламPG-M-*; миграция с долгим lock'ом блокирует трафик.- Паттерн expand-contract (добавить столбец → переключить код → удалить старый) применяется для обратно-несовместимых изменений.
Схема PostgreSQL — артефакт, который живёт отдельно от кода и отдельно от sqlc-сгенерированных запросов. Связь между ними — файлы в db/migrations/. Инструмент миграций читает их и приводит БД к нужной версии; sqlc читает их и генерирует типобезопасный Go-код. Это единый источник правды: одно дерево файлов описывает и схему, и типы.
Инструменты: golang-migrate и goose
R-SQLC-MIG-1 требует инструмент с версионированием файлов. Оба подходят:
golang-migrate — более строгая модель: каждый шаг состоит из пары <N>_<name>.up.sql и <N>_<name>.down.sql. Применение через CLI или через библиотеку в коде:
db/migrations/
├── 001_create_orders.up.sql
├── 001_create_orders.down.sql
├── 002_add_customer_index.up.sql
└── 002_add_customer_index.down.sql
goose — более гибкий: down-миграция необязательна, поддерживает Go-миграции для данных (seed), статусы хранятся в таблице goose_db_version:
db/migrations/
├── 20240101120000_create_orders.sql
├── 20240201090000_add_products.sql
└── 20240301150000_create_customers.sql
Файл goose содержит оба направления в одном:
-- +goose Up
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL,
amount NUMERIC(12,2) NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- +goose Down
DROP TABLE orders;
Для нового сервиса golang-migrate предпочтительнее: строгое разделение up/down, нет зависимости от формата goose-аннотаций, хорошо работает в CI без дополнительной настройки.
sqlc.yaml: схема из файлов миграций
R-SQLC-MIG-2: в sqlc.yaml поле schema указывает на папку с миграциями. sqlc читает все *.sql-файлы в этой папке и восстанавливает финальное состояние схемы:
# sqlc.yaml
version: "2"
sql:
- engine: "postgresql"
queries: "db/queries"
schema: "db/migrations"
gen:
go:
package: "db"
out: "db"
emit_json_tags: true
emit_pointers_for_null_fields: true
overrides:
- db_type: "uuid"
go_type: "github.com/google/uuid.UUID"
- db_type: "pg_catalog.numeric"
go_type: "github.com/shopspring/decimal.Decimal"
- db_type: "timestamptz"
go_type: "time.Time"
После добавления новой миграции:
sqlc generate
Генератор перестроит db/models.go, db/querier.go и db/orders.sql.go, отражая изменения схемы. Если новая колонка nullable — появится поле *SomeType в параметрах запросов благодаря emit_pointers_for_null_fields: true.
Применение при старте: migrate.Up()
R-SQLC-MIG-4: миграция применяется при каждом запуске приложения через migrate.Up(). Это снимает вопрос «применена ли последняя миграция на этом окружении»:
// infrastructure/migrations.go
package infrastructure
import (
"database/sql"
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/jackc/pgx/v5/stdlib"
)
func RunMigrations(dsn, migrationsPath string) error {
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open db for migrations: %w", err)
}
defer db.Close()
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return fmt.Errorf("create migration driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance(
"file://"+migrationsPath,
"postgres",
driver,
)
if err != nil {
return fmt.Errorf("init migrate: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("apply migrations: %w", err)
}
return nil
}
Вызов из точки старта — до инициализации pgxpool.Pool и db.Queries:
// main.go или bootstrap
func main() {
cfg := config.Load()
if err := infrastructure.RunMigrations(cfg.DSN, "db/migrations"); err != nil {
log.Fatalf("migrations: %v", err)
}
pool, err := infrastructure.NewPool(cfg)
if err != nil {
log.Fatalf("pool: %v", err)
}
defer pool.Close()
// ... остальная инициализация
}
migrate.ErrNoChange — не ошибка: миграции уже применены. Всё остальное — fatalf, поскольку без консистентной схемы работать нельзя.
Пример: добавить столбец к orders
Допустим, в сервис заказов Sber нужно добавить payment_method. Добавляем новую миграцию:
-- db/migrations/003_add_payment_method_to_orders.up.sql
ALTER TABLE orders ADD COLUMN payment_method TEXT;
-- db/migrations/003_add_payment_method_to_orders.down.sql
ALTER TABLE orders DROP COLUMN payment_method;
Запускаем sqlc generate — в db/models.go появится:
type Order struct {
ID uuid.UUID
CustomerID uuid.UUID
Amount decimal.Decimal
Status string
PaymentMethod *string // nullable — через emit_pointers_for_null_fields
CreatedAt time.Time
}
Новое поле сразу доступно в запросах. Обновляем маппер в репозитории:
// adapters/out/persistence/order_mapper.go
func toDomain(row db.Order) *order.Order {
o := &order.Order{
ID: row.ID,
CustomerID: row.CustomerID,
Amount: row.Amount,
Status: order.Status(row.Status),
CreatedAt: row.CreatedAt,
}
if row.PaymentMethod != nil {
o.PaymentMethod = order.PaymentMethod(*row.PaymentMethod)
}
return o
}
Expand-contract для обратно-несовместимых изменений
R-SQLC-MIG-3 отсылает к PG-M-*: переименовать или удалить столбец нельзя за один шаг — это прерывает трафик при rolling-деплое.
Паттерн для переименования status → order_status в таблице orders:
Миграция 1 (expand): добавить новый столбец, триггер синхронизации:
-- 004_expand_order_status.up.sql
ALTER TABLE orders ADD COLUMN order_status TEXT;
UPDATE orders SET order_status = status;
CREATE OR REPLACE FUNCTION sync_order_status()
RETURNS TRIGGER AS $$
BEGIN
NEW.order_status := NEW.status;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_order_status
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION sync_order_status();
Шаг кода: переключить запросы sqlc на order_status, задеплоить.
Миграция 2 (contract): убрать старый столбец и триггер:
-- 005_contract_drop_status.up.sql
DROP TRIGGER trg_sync_order_status ON orders;
DROP FUNCTION sync_order_status();
ALTER TABLE orders DROP COLUMN status;
Индексы: CONCURRENTLY и lock_timeout
Миграция с CREATE INDEX без CONCURRENTLY берёт ShareLock на таблицу и блокирует записи на время построения. На таблицах с трафиком это недопустимо.
R-SQLC-MIG-3, PG-M-*:
-- 006_idx_orders_customer_id.up.sql
SET lock_timeout = '2s';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_id
ON orders (customer_id);
SET lock_timeout ограничивает время ожидания блокировки — если за 2 секунды таблицу не удалось захватить (идут тяжёлые транзакции), миграция падает с ошибкой, а не зависает. CONCURRENTLY строит индекс без удержания lock'а на записи.
Ограничение: golang-migrate не поддерживает CREATE INDEX CONCURRENTLY в транзакции. Нужно отключить транзакцию для этого шага:
// Через golang-migrate можно использовать WithInstance с DisableLock
// или вынести CONCURRENTLY-шаги в отдельный механизм (goose --no-tx)
Goose решает это проще — аннотация -- +goose NO TRANSACTION:
-- +goose Up
-- +goose NO TRANSACTION
SET lock_timeout = '2s';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_category_id
ON products (category_id);
-- +goose Down
DROP INDEX CONCURRENTLY IF EXISTS idx_products_category_id;
Изоляция в тестах
R-SQLC-TEST-2: каждый интеграционный тест репозитория запускает миграции на чистой БД. Через testcontainers-go:
// adapters/out/persistence/postgres_order_repository_test.go
var testPool *pgxpool.Pool
func TestMain(m *testing.M) {
ctx := context.Background()
container, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForListeningPort("5432/tcp").WithStartupTimeout(30*time.Second),
),
)
if err != nil {
log.Fatalf("start postgres: %v", err)
}
defer container.Terminate(ctx)
dsn, _ := container.ConnectionString(ctx, "sslmode=disable")
if err := infrastructure.RunMigrations(dsn, "../../../db/migrations"); err != nil {
log.Fatalf("migrations: %v", err)
}
testPool, _ = pgxpool.New(ctx, dsn)
defer testPool.Close()
os.Exit(m.Run())
}
func TestPostgresOrderRepository_Save(t *testing.T) {
ctx := context.Background()
tx, _ := testPool.Begin(ctx)
t.Cleanup(func() { tx.Rollback(ctx) })
repo := NewPostgresOrderRepository(testPool).WithTx(tx)
o := &order.Order{
ID: uuid.New(),
CustomerID: uuid.New(),
Amount: decimal.NewFromFloat(1500.00),
Status: order.StatusPending,
CreatedAt: time.Now(),
}
err := repo.Save(ctx, o)
require.NoError(t, err)
found, err := repo.FindByID(ctx, o.ID)
require.NoError(t, err)
require.Equal(t, o.ID, found.ID)
}
Миграции применяются один раз в TestMain, контейнер переиспользуется всеми тестами. Изоляция данных — через tx.Rollback в t.Cleanup.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
pool.Exec("CREATE TABLE ...") в application-коде | R-SQLC-MIG-1 | Файлы в db/migrations/, инструмент миграций |
schema: "" или указание на живую БД в sqlc.yaml | R-SQLC-MIG-2 | schema: "db/migrations" — sqlc читает файлы |
| Редактирование уже применённой миграции | R-SQLC-MIG-X1 | Новая миграция поверх |
AutoMigrate-паттерн из Go-структур | R-SQLC-MIG-X2 | Версионированные .sql-файлы |
CREATE INDEX без CONCURRENTLY на таблице с трафиком | PG-M-* | CREATE INDEX CONCURRENTLY + lock_timeout |
Применение миграций вручную без CI-шага или migrate.Up() | R-SQLC-MIG-4 | Автоматический запуск при старте или в CI |
Куда дальше
- go/repository-pattern.md — как репозиторий использует сгенерированные из миграций типы
- go/transactions.md —
pgx.Tx,WithTx, границы транзакции на Handler - PostgreSQL: миграции — правила
PG-M-*: expand-contract, CONCURRENTLY, lock_timeout, проверка длинных транзакций