Транзакции и миграции в Go (PostgreSQL)
Зачем знать: Middle 1 backend строит сервисы, где состояние БД — критично. Транзакция, выполненная без понимания isolation level — это deadlock, потерянный апдейт или phantom read в проде. Миграция, выполненная не online — это даунтайм. На собесе спрашивают: что такое ACID, как обработать deadlock, почему serializable дорогой, как сделать миграцию без downtime, чем atlas отличается от golang-migrate. Это вопросы senior, но middle 1 должен понимать суть, чтобы не сломать продакшен своим первым
ALTER TABLE.
Содержание
Заголовок раздела «Содержание»- Базовая концепция
- Под капотом / Best practices
- Gotchas
- Производительность
- Вопросы на собеседовании
- Practice
- Источники
1. Базовая концепция
Заголовок раздела «1. Базовая концепция»Часть 1: Транзакции
Заголовок раздела «Часть 1: Транзакции»Транзакция — набор операций над БД, выполняемых атомарно: либо все, либо ни одна. Свойства ACID:
- A — Atomicity: либо весь набор, либо ничего.
- C — Consistency: транзакция переводит БД из одного валидного состояния в другое.
- I — Isolation: параллельные транзакции не мешают друг другу (в зависимости от уровня).
- D — Durability: после COMMIT — данные на диске.
В Go (database/sql):
tx, err := db.BeginTx(ctx, &sql.TxOptions{ Isolation: sql.LevelReadCommitted, ReadOnly: false,})if err != nil { return err}defer tx.Rollback() // безопасно: после Commit становится no-op
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", 100, fromID)if err != nil { return err // defer Rollback сработает}_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", 100, toID)if err != nil { return err}
return tx.Commit()В pgx:
tx, err := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted})if err != nil { return err }defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "..."); err != nil { return err }return tx.Commit(ctx)Часть 2: Миграции
Заголовок раздела «Часть 2: Миграции»Миграция — версионированное изменение схемы БД (DDL: CREATE/ALTER/DROP). Цели:
- Воспроизводимость: новый разработчик/инстанс получает ту же схему.
- Версионирование: история изменений в git.
- Откат: возможность вернуть схему назад (rare в проде).
- CI/CD: автоматическое применение.
Популярные инструменты:
- golang-migrate — самый распространённый.
- goose — поддерживает Go и SQL migrations.
- atlas — declarative, новое поколение (2024-2026).
2. Под капотом / Best practices
Заголовок раздела «2. Под капотом / Best practices»2.1 BeginTx и TxOptions
Заголовок раздела «2.1 BeginTx и TxOptions»opts := &sql.TxOptions{ Isolation: sql.LevelSerializable, // или LevelRepeatableRead, etc. ReadOnly: true, // hint для БД, что не будет writes}tx, _ := db.BeginTx(ctx, opts)ReadOnly: true важен для read-replicas — на них write упадёт, но если БД знает, что tx read-only, может оптимизировать.
2.2 Commit / Rollback
Заголовок раздела «2.2 Commit / Rollback»tx, _ := db.BeginTx(ctx, nil)defer tx.Rollback() // idempotent после Commit (вернёт sql.ErrTxDone, но безопасно)
// ... работа ...
return tx.Commit()defer tx.Rollback() после Commit() возвращает sql.ErrTxDone (или pgx.ErrTxClosed). Это не ошибка — мы её игнорируем. Защищает от случая, когда мы вернулись по ошибке до Commit.
2.3 Pattern: helper with closure
Заголовок раздела «2.3 Pattern: helper with closure»func WithTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer func() { if p := recover(); p != nil { tx.Rollback() panic(p) } }() if err := fn(tx); err != nil { tx.Rollback() return err } return tx.Commit()}
// Usage:err := WithTx(ctx, db, func(tx *sql.Tx) error { if _, err := tx.ExecContext(ctx, "..."); err != nil { return err } return nil})Этот паттерн исключает дублирование Begin/Commit/Rollback во всех функциях.
2.4 Isolation levels
Заголовок раздела «2.4 Isolation levels»Что такое аномалии
Заголовок раздела «Что такое аномалии»| Аномалия | Описание |
|---|---|
| Dirty read | Читаешь незакоммиченные данные другой транзакции |
| Non-repeatable read | Читаешь одну и ту же строку дважды, получаешь разные результаты |
| Phantom read | Повторяешь SELECT с условием — появляются/исчезают строки |
| Lost update | Две транзакции читают, обе обновляют, одно изменение теряется |
| Write skew | Две tx читают пересекающийся набор, пишут разные части, нарушают инвариант |
Уровни SQL standard
Заголовок раздела «Уровни SQL standard»| Уровень | Dirty read | Non-rep read | Phantom | Lost update |
|---|---|---|---|---|
| Read Uncommitted | Возможен | Возможен | Возможен | Возможен |
| Read Committed | Нет | Возможен | Возможен | Возможен |
| Repeatable Read | Нет | Нет | Возможен | Зависит |
| Serializable | Нет | Нет | Нет | Нет |
PostgreSQL specifics
Заголовок раздела «PostgreSQL specifics»- Read Uncommitted в PG = Read Committed. PG не реализует уровень ниже committed.
- Default = Read Committed.
- Repeatable Read в PG = Snapshot Isolation (не классический RR из SQL std):
- Каждое statement в tx видит snapshot, сделанный в начале первого statement.
- Phantom reads не возникают.
- Но: write skew возможен.
- Serializable в PG = Serializable Snapshot Isolation (SSI):
- Все аномалии исключены, включая write skew.
- Реализован через “detect anomaly at runtime”: если конфликт обнаружен — одна из tx получает
serialization_failure(код40001). - Дороже по CPU, но обычно ≤20% overhead.
tx, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})Когда какой использовать
Заголовок раздела «Когда какой использовать»- Read Committed (default) — 90% случаев. Достаточно для большинства бизнес-операций.
- Repeatable Read — отчёты, которые должны видеть консистентный snapshot.
- Serializable — критичная бизнес-логика, где write skew недопустим (банковские переводы при специфичных инвариантах). Готовьтесь к retry.
2.5 Serialization failure и retry
Заголовок раздела «2.5 Serialization failure и retry»for retries := 0; retries < 3; retries++ { err := WithTx(ctx, db, func(tx *sql.Tx) error { // tx с Serializable return doStuff(tx) }) if err == nil { return nil } var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "40001" { // serialization_failure — retry continue } if errors.As(err, &pgErr) && pgErr.Code == "40P01" { // deadlock_detected — retry continue } return err}return ErrTooManyRetriesС jitter exponential backoff:
time.Sleep(time.Duration(1<<retries) * 10 * time.Millisecond)2.6 Deadlocks
Заголовок раздела «2.6 Deadlocks»T1: UPDATE accounts WHERE id=1; -- блокирует строку 1T2: UPDATE accounts WHERE id=2; -- блокирует строку 2T1: UPDATE accounts WHERE id=2; -- ждёт T2T2: UPDATE accounts WHERE id=1; -- ждёт T1 → DEADLOCKPostgreSQL обнаруживает deadlock и killит одну из tx с кодом 40P01. В Go: retry.
Профилактика:
- Брать локи в одинаковом порядке (
ORDER BY id). - Уменьшать длительность транзакций.
- Использовать
SELECT ... FOR UPDATEявно для критических locks.
2.7 Pessimistic vs Optimistic locking
Заголовок раздела «2.7 Pessimistic vs Optimistic locking»Pessimistic:
SELECT * FROM accounts WHERE id = $1 FOR UPDATE;-- блокирует строку, другие SELECT FOR UPDATE ждутUPDATE accounts SET balance = $1 WHERE id = $2;Optimistic (через version column):
SELECT id, balance, version FROM accounts WHERE id = 1;-- ... вычисления ...UPDATE accounts SET balance = $1, version = version + 1 WHERE id = $2 AND version = $3;-- если rows_affected = 0 — конфликт, retryOptimistic дешевле для редких конфликтов, pessimistic — для частых.
2.8 Savepoints (nested transactions)
Заголовок раздела «2.8 Savepoints (nested transactions)»PostgreSQL не поддерживает nested transactions, но есть savepoints:
_, _ = tx.ExecContext(ctx, "SAVEPOINT sp1")if _, err := tx.ExecContext(ctx, "..."); err != nil { _, _ = tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT sp1")} else { _, _ = tx.ExecContext(ctx, "RELEASE SAVEPOINT sp1")}pgx даёт tx.Begin(ctx) который под капотом делает savepoint — эмуляция nested:
tx, _ := pool.Begin(ctx)innerTx, _ := tx.Begin(ctx) // на самом деле SAVEPOINT2.9 SELECT FOR UPDATE / SKIP LOCKED
Заголовок раздела «2.9 SELECT FOR UPDATE / SKIP LOCKED»FOR UPDATE
Заголовок раздела «FOR UPDATE»SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE;Блокирует строку. Другая транзакция с тем же SELECT FOR UPDATE будет ждать.
SKIP LOCKED
Заголовок раздела «SKIP LOCKED»SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE SKIP LOCKED;Не ждёт, пропускает залоченные. Полезно для очередей задач: каждый worker берёт свой job без блокировки.
2.10 Distributed transactions (2PC)
Заголовок раздела «2.10 Distributed transactions (2PC)»Two-Phase Commit (2PC):
- Prepare: все участники готовятся, отвечают OK/Fail.
- Commit/Abort: координатор приказывает всем commit или abort.
В PostgreSQL: PREPARE TRANSACTION 'xid', потом COMMIT PREPARED 'xid'.
tx, _ := db.BeginTx(ctx, nil)// работа_, _ = tx.ExecContext(ctx, "PREPARE TRANSACTION 'tx_123'")// другой узел тоже PREPARE// если все OK:_, _ = db.ExecContext(ctx, "COMMIT PREPARED 'tx_123'")Минусы 2PC: если координатор упал между phase 1 и 2, ресурсы залочены до восстановления. В микросервисах 2PC обычно избегают.
2.11 Saga pattern
Заголовок раздела «2.11 Saga pattern»Альтернатива 2PC для микросервисов: цепочка локальных транзакций + compensation actions.
1. Order Service: create order (TX1)2. Payment Service: charge card (TX2)3. Inventory: reserve item (TX3)
Если TX3 fails: - cancel payment (compensation TX2') - cancel order (compensation TX1')Реализации: temporal.io, cadence, или вручную через message queue.
(Детально — middle 2.)
2.12 Repository pattern для tx
Заголовок раздела «2.12 Repository pattern для tx»Чтобы repository умел работать и в самостоятельном режиме, и в транзакции:
// DBTX — общий интерфейс для *sql.DB и *sql.Txtype DBTX interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row}
type UserRepo struct{ db DBTX }
func (r *UserRepo) Get(ctx context.Context, id int64) (User, error) { var u User err := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id=$1", id).Scan(...) return u, err}Использование:
// Вне tx:repo := UserRepo{db: db}
// Внутри tx:tx, _ := db.BeginTx(ctx, nil)repo := UserRepo{db: tx}repo.Get(...)В sqlc этот паттерн встроен: db.New(pool) или db.New(tx) — оба удовлетворяют DBTX.
2.13 UnitOfWork pattern
Заголовок раздела «2.13 UnitOfWork pattern»Группировка операций в одной tx через явный объект:
type UnitOfWork struct { tx *sql.Tx users *UserRepo orders *OrderRepo}
func NewUOW(ctx context.Context, db *sql.DB) (*UnitOfWork, error) { tx, err := db.BeginTx(ctx, nil) if err != nil { return nil, err } return &UnitOfWork{ tx: tx, users: &UserRepo{db: tx}, orders: &OrderRepo{db: tx}, }, nil}
func (u *UnitOfWork) Commit() error { return u.tx.Commit() }func (u *UnitOfWork) Rollback() error { return u.tx.Rollback() }Использование:
uow, _ := NewUOW(ctx, db)defer uow.Rollback()uow.users.Create(...)uow.orders.Create(...)uow.Commit()2.14 Long-running transactions — антипаттерн
Заголовок раздела «2.14 Long-running transactions — антипаттерн»tx, _ := db.BeginTx(ctx, nil)defer tx.Rollback()
for _, row := range rows { tx.ExecContext(ctx, "...") time.Sleep(1 * time.Second) // ОПАСНО: tx живёт долго}tx.Commit()Проблемы:
- Vacuum не может удалить старые версии строк (MVCC bloat).
- Locks висят, блокируя других.
- Conn pool исчерпан.
Лечение: батчи коротких tx.
2.15 Transaction и context
Заголовок раздела «2.15 Transaction и context»Cancel context во время tx → tx будет rolled back:
ctx, cancel := context.WithTimeout(ctx, time.Second)defer cancel()tx, _ := db.BeginTx(ctx, nil)// если timeout, tx автоматически rollbackВ pgx — то же поведение.
2.16 pgx-специфика транзакций
Заголовок раздела «2.16 pgx-специфика транзакций»tx, err := pool.BeginTx(ctx, pgx.TxOptions{ IsoLevel: pgx.Serializable, AccessMode: pgx.ReadWrite, DeferrableMode: pgx.NotDeferrable,})
// pgx также имеет:err = pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error { // работа return nil}) // авто commit/rollback по errpgx.BeginFunc — встроенный helper аналогичный нашему WithTx.
2.17 Migrations: golang-migrate
Заголовок раздела «2.17 Migrations: golang-migrate»Установка
Заголовок раздела «Установка»brew install golang-migrate# илиgo install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latestmigrations/ 000001_create_users.up.sql 000001_create_users.down.sql 000002_add_email.up.sql 000002_add_email.down.sqlup.sql:
CREATE TABLE users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL);down.sql:
DROP TABLE users;migrate -path migrations -database "postgres://user:pass@localhost:5432/app?sslmode=disable" upmigrate -path migrations -database "..." down 1migrate -path migrations -database "..." versionmigrate -path migrations -database "..." force 3 # сбросить версию (после ручного fix)Embeddable
Заголовок раздела «Embeddable»import ( "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file")
m, err := migrate.New( "file://migrations", "postgres://user:pass@localhost/app?sslmode=disable",)if err != nil { log.Fatal(err) }if err := m.Up(); err != nil && err != migrate.ErrNoChange { log.Fatal(err)}Embedded migrations (embed.FS)
Заголовок раздела «Embedded migrations (embed.FS)»import ( "embed" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/source/iofs")
//go:embed migrations/*.sqlvar migrationsFS embed.FS
func runMigrations(dbURL string) error { d, err := iofs.New(migrationsFS, "migrations") if err != nil { return err } m, err := migrate.NewWithSourceInstance("iofs", d, dbURL) if err != nil { return err } return m.Up()}Удобно: миграции компилируются в бинарь, не нужно копировать файлы.
Schema_migrations table
Заголовок раздела «Schema_migrations table»golang-migrate создаёт таблицу schema_migrations(version BIGINT, dirty BOOLEAN). Хранит текущую версию.
dirty=true — миграция упала посередине. Лечится migrate force <version> после ручного fix.
2.18 Migrations: goose (pressly/goose)
Заголовок раздела «2.18 Migrations: goose (pressly/goose)»Альтернатива. Поддерживает SQL и Go миграции.
go install github.com/pressly/goose/v3/cmd/goose@latestgoose create add_users sqlФайл 20240101120000_add_users.sql:
-- +goose UpCREATE TABLE users (id INT);
-- +goose DownDROP TABLE users;goose -dir migrations postgres "user=... dbname=..." upGo migrations
Заголовок раздела «Go migrations»package migrations
import ( "context" "database/sql" "github.com/pressly/goose/v3")
func init() { goose.AddMigrationContext(upXxx, downXxx) }func upXxx(ctx context.Context, tx *sql.Tx) error { _, err := tx.ExecContext(ctx, "...") return err}func downXxx(ctx context.Context, tx *sql.Tx) error { return nil }Удобно: можно делать миграции с логикой (например, преобразование данных в Go-коде).
2.19 Migrations: atlas (ariga/atlas)
Заголовок раздела «2.19 Migrations: atlas (ariga/atlas)»Новое поколение (2023-2026). Declarative подход: описываете желаемое состояние схемы, atlas сам рассчитывает diff.
go install ariga.io/atlas/cmd/atlas@latestHCL schema
Заголовок раздела «HCL schema»schema "public" {}
table "users" { schema = schema.public column "id" { type = int null = false identity {} } column "name" { type = text } primary_key { columns = [column.id] }}Применение
Заголовок раздела «Применение»atlas schema apply --url "postgres://..." --to file://schema.hclAtlas посчитает diff (ALTER TABLE ...), покажет, применит.
Versioned mode
Заголовок раздела «Versioned mode»Атлас также поддерживает обычные numbered миграции, но генерирует их автоматически:
atlas migrate diff add_users --to file://schema.hcl --dev-url "docker://postgres/16/dev"sqlc + atlas
Заголовок раздела «sqlc + atlas»Популярная пара: atlas для schema, sqlc для queries. Atlas пишет schema файлы, sqlc парсит их (через schema: в sqlc.yaml).
2.20 Migration best practices
Заголовок раздела «2.20 Migration best practices»Online migrations (без downtime)
Заголовок раздела «Online migrations (без downtime)»Принцип: каждая миграция backward-compatible с предыдущей версией кода.
Плохо:
-- migration: rename columnALTER TABLE users RENAME COLUMN name TO full_name;Если деплоить миграцию до кода — старый код упадёт. Если код до миграции — новый код упадёт.
Хорошо (multi-step):
Шаг 1. Добавить новую колонку:
ALTER TABLE users ADD COLUMN full_name TEXT;Деплой кода, который пишет в обе колонки.
Шаг 2. Backfill:
UPDATE users SET full_name = name WHERE full_name IS NULL;Шаг 3. Деплой кода, который читает только full_name.
Шаг 4. Удалить старую:
ALTER TABLE users DROP COLUMN name;Non-blocking ADD COLUMN
Заголовок раздела «Non-blocking ADD COLUMN»-- PostgreSQL 11+: ADD COLUMN ... DEFAULT ... — fast (constant time)ALTER TABLE users ADD COLUMN active BOOLEAN DEFAULT TRUE;
-- БЛОКИРУЕТ ПИШУЩИЕ ОПЕРАЦИИ если использовали volatile expression в default (старые PG)В Postgres 11+ ADD COLUMN с immutable default — мгновенно, не переписывает таблицу.
Non-blocking NOT NULL
Заголовок раздела «Non-blocking NOT NULL»-- ПЛОХО: блокирует table scanALTER TABLE users ALTER COLUMN email SET NOT NULL;
-- ХОРОШО (multi-step):-- 1. Добавить CHECK CONSTRAINT NOT VALID:ALTER TABLE users ADD CONSTRAINT users_email_not_null CHECK (email IS NOT NULL) NOT VALID;-- 2. Validate отдельно (быстрая операция на indexed scan):ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;-- 3. Только потом SET NOT NULL — мгновенно (используется constraint):ALTER TABLE users ALTER COLUMN email SET NOT NULL;ALTER TABLE users DROP CONSTRAINT users_email_not_null;CREATE INDEX CONCURRENTLY
Заголовок раздела «CREATE INDEX CONCURRENTLY»-- БЛОКИРУЕТ WRITES:CREATE INDEX idx_users_email ON users(email);
-- НЕ блокирует:CREATE INDEX CONCURRENTLY idx_users_email ON users(email);CONCURRENTLY — медленнее (2x), но не блокирует. Обязателен в production.
Внимание: CONCURRENTLY не работает внутри транзакции. golang-migrate по умолчанию обернёт миграцию в tx. Решение:
-- В golang-migrate: использовать суффикс _no_tx или директиву.Или goose с +goose NO TRANSACTION:
-- +goose Up-- +goose NO TRANSACTIONCREATE INDEX CONCURRENTLY idx_users_email ON users(email);Migrate перед deploy
Заголовок раздела «Migrate перед deploy»В типичном CI/CD:
1. Apply migrations (backward compatible).2. Deploy new code (роллинг апдейт).3. (Опционально) cleanup migrations (drop старых столбцов).Миграции и код деплоятся разными pipeline’ами. Миграция перед кодом, потому что новый код должен видеть новую схему.
Идемпотентность миграций
Заголовок раздела «Идемпотентность миграций»CREATE TABLE IF NOT EXISTS users (...);DROP INDEX IF EXISTS idx_users_email;В golang-migrate миграции не идемпотентны (по умолчанию). При повторном up упадёт. Это by design — миграция запускается ровно раз.
2.21 Откат миграций
Заголовок раздела «2.21 Откат миграций»Down migrations пишутся, но в проде редко используются. Откат базы — операция, требующая людей и плана, не автоматизация. Обычно:
- Forward-only миграции в проде.
- Down — для локальной разработки и тестов.
Atlas вообще поощряет declarative, без явных down.
2.22 CI/CD миграций
Заголовок раздела «2.22 CI/CD миграций»Варианты:
- Отдельный CI job перед деплоем кода. Просто, надёжно.
- Init container в k8s. Под не стартует, пока миграция не завершена. Удобно, но проблема: 10 подов — 10 параллельных миграций. Лечится:
LOCK schema_migrationsили leader election. - Operator (atlas controller для k8s).
- Application boot: код сам применяет миграцию при старте. Удобно для small projects, опасно в больших.
В большой компании — отдельный pipeline + change-management процесс.
3. Gotchas
Заголовок раздела «3. Gotchas»3.1 defer Rollback после Commit
Заголовок раздела «3.1 defer Rollback после Commit»defer tx.Rollback()// ...return tx.Commit()После Commit Rollback вернёт sql.ErrTxDone / pgx.ErrTxClosed — это не ошибка, можно игнорировать.
3.2 Использование db после Begin
Заголовок раздела «3.2 Использование db после Begin»tx, _ := db.BeginTx(ctx, nil)// Делаем запрос НЕ через tx:db.QueryContext(ctx, "...") // НЕ в транзакции! Берёт другой conn.Внутри tx все запросы через tx.*. Если случайно через db.* — это другой conn, transactions не объединены.
3.3 Long-running tx и pool exhaustion
Заголовок раздела «3.3 Long-running tx и pool exhaustion»Tx держит conn до Commit/Rollback. Если в tx — длинные операции (вызов внешнего сервиса), conn зависает.
Антипаттерн:
tx, _ := db.BeginTx(ctx, nil)defer tx.Rollback()tx.Exec("UPDATE ...")callExternalAPI() // 5 секунд! conn висит.tx.Commit()Лечение: вынести внешние вызовы за tx.
3.4 Read-only tx и writes
Заголовок раздела «3.4 Read-only tx и writes»tx, _ := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})tx.Exec("INSERT ...") // упадёт: "cannot execute INSERT in a read-only transaction"3.5 Serializable retry без идемпотентности
Заголовок раздела «3.5 Serializable retry без идемпотентности»Если retry’им бизнес-логику без проверки — два раза создадим заказ. Идемпотентность: использовать idempotency key, UPSERT, ON CONFLICT.
3.6 Repeatable Read и read-after-write
Заголовок раздела «3.6 Repeatable Read и read-after-write»В Repeatable Read tx видит snapshot с начала. Свои writes видит. Other tx writes — не видит:
tx, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})tx.Exec("INSERT INTO users (name) VALUES ('alice')")// в другой goroutine: INSERT INTO users (name) VALUES ('bob'); COMMIT;rows, _ := tx.Query("SELECT * FROM users")// увидит только alice, не bob3.7 deadlock_detected handling
Заголовок раздела «3.7 deadlock_detected handling»Все блочные операции (UPDATE, SELECT FOR UPDATE) могут спровоцировать deadlock. Всегда имейте retry logic для 40P01.
3.8 Auto-commit mode
Заголовок раздела «3.8 Auto-commit mode»В Go нет явного “auto-commit”: каждый db.Exec — это автоматическая tx за кулисами (для PG). Tx только через BeginTx.
3.9 Locking в DDL
Заголовок раздела «3.9 Locking в DDL»ALTER TABLE берёт AccessExclusiveLock — блокирует всё на время операции. Для больших таблиц минута блокировки = downtime.
Используйте online-патерны (см. 2.20).
3.10 Migration tool conflict
Заголовок раздела «3.10 Migration tool conflict»Не используйте одновременно несколько (golang-migrate + GORM AutoMigrate). Они не знают друг о друге, схема рассинхронизируется.
3.11 Forgot to commit
Заголовок раздела «3.11 Forgot to commit»tx, _ := db.BeginTx(ctx, nil)defer tx.Rollback()tx.Exec("INSERT ...")return nil // Commit не вызвали! Rollback откатит.Случайно вернули nil до Commit — ничего не сохранится. В тестах поймайте.
3.12 sql.LevelDefault
Заголовок раздела «3.12 sql.LevelDefault»db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelDefault})В Postgres это значит read_committed (default). Не путать с sql.LevelReadUncommitted (которого в PG нет).
3.13 Tx и goroutines
Заголовок раздела «3.13 Tx и goroutines»*sql.Tx не thread-safe. Не используйте одну Tx из нескольких goroutines.
tx, _ := db.BeginTx(ctx, nil)go tx.Exec(...) // RACEgo tx.Exec(...)3.14 Migration ordering (timestamp vs number)
Заголовок раздела «3.14 Migration ordering (timestamp vs number)»golang-migrate: имена000001_*,000002_*— ordered по числам.goose: timestamps20240101120000_*— меньше конфликтов при rebase.
Если несколько разработчиков делают миграции одновременно, timestamp подход избегает конфликтов номеров.
3.15 Embedded migrations и go:embed
Заголовок раздела «3.15 Embedded migrations и go:embed»//go:embed migrationsvar fs embed.FSЕсли migrations папка пустая на момент компиляции — ошибка. Положите хотя бы dummy файл или используйте //go:embed migrations/*.
3.16 Down migration в проде
Заголовок раздела «3.16 Down migration в проде»Если down включает удаление колонки/таблицы — это не откат, это потеря данных. В проде down используют редко, обычно forward-only fix.
3.17 Огромные миграции
Заголовок раздела «3.17 Огромные миграции»Backfill миллионов строк одним UPDATE — блокирует надолго. Лечение: батчи:
UPDATE users SET full_name = name WHERE id BETWEEN 1 AND 10000 AND full_name IS NULL;-- повторить для следующих 10000Или фоновой job, не миграцией.
3.18 sqlc и tx
Заголовок раздела «3.18 sqlc и tx»sqlc генерирует методы на Queries. Чтобы вызвать в tx:
tx, _ := pool.Begin(ctx)qtx := q.WithTx(tx) // sqlc методqtx.GetUser(ctx, 42)tx.Commit(ctx)q.WithTx(tx) возвращает новый Queries, который использует tx.
3.19 atlas dev URL
Заголовок раздела «3.19 atlas dev URL»atlas migrate diff требует --dev-url: пустую БД для расчёта diff. Обычно docker://postgres/16/dev — поднимает временный контейнер.
3.20 PgBouncer transaction mode и Begin
Заголовок раздела «3.20 PgBouncer transaction mode и Begin»В transaction mode PgBouncer возвращает conn в пул после каждого COMMIT. Это значит:
- LISTEN/NOTIFY ломаются.
- Prepared statements ломаются (по умолчанию).
BeginTx + SET LOCAL ...работает.
Если используете PgBouncer transaction mode — отключите prepared cache в pgx (или используйте PgBouncer 1.21+ с поддержкой prepared).
4. Производительность
Заголовок раздела «4. Производительность»4.1 Транзакции overhead
Заголовок раздела «4.1 Транзакции overhead»BEGIN + COMMIT сами по себе — ~0.5ms overhead. Для одного INSERT часто не нужны (auto-commit). Для нескольких связанных — обязательно.
4.2 Isolation level стоимость
Заголовок раздела «4.2 Isolation level стоимость»- Read Committed: дефолт, никакого overhead.
- Repeatable Read: snapshot per tx, минимальный overhead.
- Serializable: SSI tracking, ~10-20% overhead, плюс возможные retries.
4.3 Batch операций в tx
Заголовок раздела «4.3 Batch операций в tx»Лучше: одна tx с 100 INSERT’ов. Хуже: 100 отдельных tx.
tx, _ := db.BeginTx(ctx, nil)for _, u := range users { tx.Exec("INSERT ...")}tx.Commit()Экономия — 100 × 0.5ms commit overhead.
4.4 SELECT FOR UPDATE затраты
Заголовок раздела «4.4 SELECT FOR UPDATE затраты»Локи на уровне строки. Если многие tx ждут — длинная очередь. Идиомы:
FOR UPDATE NOWAIT— упадёт сразу, если занято.FOR UPDATE SKIP LOCKED— пропустит залоченные.
4.5 Deadlock retries
Заголовок раздела «4.5 Deadlock retries»Каждый retry — повторное выполнение работы. Если 10% tx падают на deadlock и retry’ятся 2 раза — реальная нагрузка ~10% выше.
Снижайте deadlock: порядок locks, короткие tx, меньший isolation.
4.6 Migration durations
Заголовок раздела «4.6 Migration durations»Online миграции (ADD COLUMN DEFAULT, CREATE INDEX CONCURRENTLY) — секунды-минуты на больших таблицах, без даунтайма.
ALTER TABLE со сменой типа — может занять часы и заблокировать. На terabyte tables используют pg_repack, pt-online-schema-change (нет аналога для PG) или pgrepack.
4.7 Vacuum и MVCC
Заголовок раздела «4.7 Vacuum и MVCC»После UPDATE/DELETE старые версии строк остаются (MVCC). Vacuum их чистит. Длинные tx блокируют vacuum — bloat растёт.
Идиома: tx ≤ нескольких секунд.
4.8 Throughput vs latency
Заголовок раздела «4.8 Throughput vs latency»Tx с serializable: throughput может упасть в 2-3x из-за retries и tracking. Если throughput критичен — read_committed + optimistic locking.
4.9 Connection per tx
Заголовок раздела «4.9 Connection per tx»Каждая tx — один conn. Если max_conn=25, не больше 25 параллельных tx. Long-running tx → нет conn для других → throughput тормозит.
4.10 Index на schema_migrations
Заголовок раздела «4.10 Index на schema_migrations»golang-migrate создаёт таблицу schema_migrations без индексов (одна строка). Это норма.
5. Вопросы на собеседовании
Заголовок раздела «5. Вопросы на собеседовании»1. Что такое ACID? Atomicity, Consistency, Isolation, Durability. Свойства транзакций.
2. Какие уровни изоляции в SQL standard? Read Uncommitted, Read Committed, Repeatable Read, Serializable.
3. Что такое dirty read? Чтение незакоммиченных данных другой tx. В PG невозможно (RU = RC).
4. Что такое phantom read? Повторный SELECT с условием возвращает разное число строк (другая tx INSERT’нула).
5. Что такое write skew? Две tx читают пересекающийся набор, обновляют разные части, нарушают инвариант. Возможен в Repeatable Read, исключён в Serializable.
6. Какой default isolation в PostgreSQL? Read Committed.
7. Чем PG Repeatable Read отличается от стандарта? PG RR = Snapshot Isolation. Не имеет phantom reads (в отличие от SQL standard RR), но имеет write skew.
8. Что такое SSI?
Serializable Snapshot Isolation — реализация Serializable в PG. Detect anomalies в runtime, retry through 40001.
9. Как обработать deadlock в Go?
Поймать pgErr.Code == "40P01", retry с backoff.
10. Чем FOR UPDATE отличается от FOR UPDATE SKIP LOCKED?
FOR UPDATE ждёт освобождения. SKIP LOCKED — пропускает залоченные. Для очередей задач — SKIP LOCKED.
11. Что такое savepoint?
Точка внутри tx, к которой можно откатиться. В Go: SAVEPOINT sp1; ... ROLLBACK TO sp1. Эмулирует nested tx.
12. Что такое 2PC? Two-phase commit для распределённых tx. Phase 1 — PREPARE, phase 2 — COMMIT/ABORT. Координатор риск.
13. Что такое Saga? Альтернатива 2PC для микросервисов: цепочка локальных tx + compensation actions.
14. Чем optimistic locking отличается от pessimistic?
Optimistic — version column, retry при конфликте. Pessimistic — FOR UPDATE, блокирует.
15. Что произойдёт, если cancel context во время tx? Запрос прервётся, tx будет rolled back.
16. Что такое sql.ErrTxDone?
Sentinel error, который возвращает Commit/Rollback на завершённой tx. Безопасно игнорировать.
17. Можно ли использовать одну *sql.Tx из разных goroutines?
Нет, не thread-safe.
18. Зачем миграции? Версионирование схемы, воспроизводимость, синхронизация между средами.
19. Что такое schema_migrations?
Таблица в БД с текущей версией миграции (golang-migrate, goose).
20. Чем golang-migrate отличается от goose? golang-migrate — только SQL миграции. goose — SQL + Go (с логикой).
21. Что такое atlas? Declarative migration tool: описываете желаемое состояние, atlas рассчитывает diff и генерирует migration.
22. Что значит “online migration”? Миграция без блокировки приложения. Backward-compatible с предыдущей версией кода.
23. Зачем CREATE INDEX CONCURRENTLY?
Создать индекс без блокировки writes. Медленнее, но без downtime.
24. Как добавить NOT NULL без даунтайма?
- ADD CHECK CONSTRAINT NOT VALID. 2) VALIDATE CONSTRAINT. 3) SET NOT NULL (мгновенно).
25. Как сделать rename column без даунтайма? Multi-step: 1) ADD COLUMN new. 2) Code пишет в обе. 3) Backfill. 4) Code читает new. 5) DROP old.
26. Когда делать down migration? В локальной разработке, тестах. В проде — крайне редко, обычно forward-only fix.
27. Что такое idempotency в миграциях?
Возможность безопасно повторить миграцию. golang-migrate не поддерживает по умолчанию, потому что версия отслеживается в schema_migrations.
28. Зачем embed.FS для миграций?
Включить миграции в Go-бинарь, чтобы не таскать отдельные файлы.
29. Что такое dirty migration?
Миграция упала посередине. schema_migrations.dirty=true. Лечение: fix вручную, migrate force <version>.
30. Можно ли запускать миграции из приложения при старте? Можно в small проектах. В large — отдельный pipeline, чтобы не было race между подами и контролировать процесс.
6. Practice
Заголовок раздела «6. Practice»Задача 1: WithTx helper
Заголовок раздела «Задача 1: WithTx helper»Реализуйте WithTx(ctx, db, func(tx) error) error с panic recovery и автоматическим Rollback при ошибке.
Задача 2: Bank transfer
Заголовок раздела «Задача 2: Bank transfer»Реализуйте Transfer(from, to, amount) через tx. Учтите: проверка баланса, deadlock retry, idempotency через transfer_id.
Задача 3: Optimistic locking
Заголовок раздела «Задача 3: Optimistic locking»Реализуйте UpdateUser с version column. При rows_affected=0 — retry с обновлёнными данными.
Задача 4: SKIP LOCKED очередь
Заголовок раздела «Задача 4: SKIP LOCKED очередь»Реализуйте таблицу jobs(id, payload, status) и worker, который берёт jobs через SELECT ... FOR UPDATE SKIP LOCKED.
Задача 5: golang-migrate setup
Заголовок раздела «Задача 5: golang-migrate setup»Создайте проект с миграциями (3 штуки), запустите up/down, проверьте schema_migrations. Embed через embed.FS.
Задача 6: Online ADD COLUMN
Заголовок раздела «Задача 6: Online ADD COLUMN»Сценарий: добавить email к users(10M строк). Шаги: ADD COLUMN, backfill батчами по 10к, ADD NOT NULL через NOT VALID + VALIDATE.
Задача 7: Serializable + retry
Заголовок раздела «Задача 7: Serializable + retry»Реализуйте увеличение счётчика без race condition через Serializable isolation и retry-on-40001.
Задача 8: atlas vs golang-migrate
Заголовок раздела «Задача 8: atlas vs golang-migrate»Создайте тот же набор изменений схемы (CREATE TABLE, ADD COLUMN, ADD INDEX) через atlas (declarative) и через golang-migrate (versioned). Сравните DX.
7. Источники
Заголовок раздела «7. Источники»- PostgreSQL docs: Transaction Isolation: https://www.postgresql.org/docs/current/transaction-iso.html.
- PostgreSQL docs: Explicit Locking: https://www.postgresql.org/docs/current/explicit-locking.html.
- database/sql Tx: https://pkg.go.dev/database/sql#Tx.
- pgx Tx: https://pkg.go.dev/github.com/jackc/pgx/v5#Tx.
- golang-migrate: https://github.com/golang-migrate/migrate, docs.
- goose: https://github.com/pressly/goose.
- atlas: https://atlasgo.io/, docs.
- Article “Online Migrations at Scale” (Stripe blog).
- Article “Postgres Zero Downtime Migrations” (various).
- Книга “Designing Data-Intensive Applications” Martin Kleppmann — главы про tx и isolation.
- Talk “Transactions in Distributed Systems” (Eric Brewer, various).
- Postgres wiki: Lock Conflicts: https://wiki.postgresql.org/wiki/Lock_Monitoring.