Перейти к содержимому

Транзакции и миграции в Go (PostgreSQL)

Зачем знать: Middle 1 backend строит сервисы, где состояние БД — критично. Транзакция, выполненная без понимания isolation level — это deadlock, потерянный апдейт или phantom read в проде. Миграция, выполненная не online — это даунтайм. На собесе спрашивают: что такое ACID, как обработать deadlock, почему serializable дорогой, как сделать миграцию без downtime, чем atlas отличается от golang-migrate. Это вопросы senior, но middle 1 должен понимать суть, чтобы не сломать продакшен своим первым ALTER TABLE.

  1. Базовая концепция
  2. Под капотом / Best practices
  3. Gotchas
  4. Производительность
  5. Вопросы на собеседовании
  6. Practice
  7. Источники

Транзакция — набор операций над БД, выполняемых атомарно: либо все, либо ни одна. Свойства 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)

Миграция — версионированное изменение схемы БД (DDL: CREATE/ALTER/DROP). Цели:

  • Воспроизводимость: новый разработчик/инстанс получает ту же схему.
  • Версионирование: история изменений в git.
  • Откат: возможность вернуть схему назад (rare в проде).
  • CI/CD: автоматическое применение.

Популярные инструменты:

  • golang-migrate — самый распространённый.
  • goose — поддерживает Go и SQL migrations.
  • atlas — declarative, новое поколение (2024-2026).

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, может оптимизировать.

tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback() // idempotent после Commit (вернёт sql.ErrTxDone, но безопасно)
// ... работа ...
return tx.Commit()

defer tx.Rollback() после Commit() возвращает sql.ErrTxDone (или pgx.ErrTxClosed). Это не ошибка — мы её игнорируем. Защищает от случая, когда мы вернулись по ошибке до Commit.

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 во всех функциях.

АномалияОписание
Dirty readЧитаешь незакоммиченные данные другой транзакции
Non-repeatable readЧитаешь одну и ту же строку дважды, получаешь разные результаты
Phantom readПовторяешь SELECT с условием — появляются/исчезают строки
Lost updateДве транзакции читают, обе обновляют, одно изменение теряется
Write skewДве tx читают пересекающийся набор, пишут разные части, нарушают инвариант
УровеньDirty readNon-rep readPhantomLost update
Read UncommittedВозможенВозможенВозможенВозможен
Read CommittedНетВозможенВозможенВозможен
Repeatable ReadНетНетВозможенЗависит
SerializableНетНетНетНет
  • 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.
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)
T1: UPDATE accounts WHERE id=1; -- блокирует строку 1
T2: UPDATE accounts WHERE id=2; -- блокирует строку 2
T1: UPDATE accounts WHERE id=2; -- ждёт T2
T2: UPDATE accounts WHERE id=1; -- ждёт T1 → DEADLOCK

PostgreSQL обнаруживает deadlock и killит одну из tx с кодом 40P01. В Go: retry.

Профилактика:

  • Брать локи в одинаковом порядке (ORDER BY id).
  • Уменьшать длительность транзакций.
  • Использовать SELECT ... FOR UPDATE явно для критических locks.

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 — конфликт, retry

Optimistic дешевле для редких конфликтов, pessimistic — для частых.

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) // на самом деле SAVEPOINT
SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE;

Блокирует строку. Другая транзакция с тем же SELECT FOR UPDATE будет ждать.

SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE SKIP LOCKED;

Не ждёт, пропускает залоченные. Полезно для очередей задач: каждый worker берёт свой job без блокировки.

Two-Phase Commit (2PC):

  1. Prepare: все участники готовятся, отвечают OK/Fail.
  2. 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 обычно избегают.

Альтернатива 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.)

Чтобы repository умел работать и в самостоятельном режиме, и в транзакции:

// DBTX — общий интерфейс для *sql.DB и *sql.Tx
type 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.

Группировка операций в одной 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()
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.

Cancel context во время tx → tx будет rolled back:

ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
tx, _ := db.BeginTx(ctx, nil)
// если timeout, tx автоматически rollback

В 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 по err

pgx.BeginFunc — встроенный helper аналогичный нашему WithTx.

Окно терминала
brew install golang-migrate
# или
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
migrations/
000001_create_users.up.sql
000001_create_users.down.sql
000002_add_email.up.sql
000002_add_email.down.sql

up.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" up
migrate -path migrations -database "..." down 1
migrate -path migrations -database "..." version
migrate -path migrations -database "..." force 3 # сбросить версию (после ручного fix)
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)
}
import (
"embed"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed migrations/*.sql
var 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()
}

Удобно: миграции компилируются в бинарь, не нужно копировать файлы.

golang-migrate создаёт таблицу schema_migrations(version BIGINT, dirty BOOLEAN). Хранит текущую версию.

dirty=true — миграция упала посередине. Лечится migrate force <version> после ручного fix.

Альтернатива. Поддерживает SQL и Go миграции.

Окно терминала
go install github.com/pressly/goose/v3/cmd/goose@latest
goose create add_users sql

Файл 20240101120000_add_users.sql:

-- +goose Up
CREATE TABLE users (id INT);
-- +goose Down
DROP TABLE users;
Окно терминала
goose -dir migrations postgres "user=... dbname=..." up
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-коде).

Новое поколение (2023-2026). Declarative подход: описываете желаемое состояние схемы, atlas сам рассчитывает diff.

Окно терминала
go install ariga.io/atlas/cmd/atlas@latest
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.hcl

Atlas посчитает diff (ALTER TABLE ...), покажет, применит.

Атлас также поддерживает обычные numbered миграции, но генерирует их автоматически:

Окно терминала
atlas migrate diff add_users --to file://schema.hcl --dev-url "docker://postgres/16/dev"

Популярная пара: atlas для schema, sqlc для queries. Atlas пишет schema файлы, sqlc парсит их (через schema: в sqlc.yaml).

Принцип: каждая миграция backward-compatible с предыдущей версией кода.

Плохо:

-- migration: rename column
ALTER 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;
-- 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 — мгновенно, не переписывает таблицу.

-- ПЛОХО: блокирует table scan
ALTER 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;
-- БЛОКИРУЕТ 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 TRANSACTION
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

В типичном 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 — миграция запускается ровно раз.

Down migrations пишутся, но в проде редко используются. Откат базы — операция, требующая людей и плана, не автоматизация. Обычно:

  • Forward-only миграции в проде.
  • Down — для локальной разработки и тестов.

Atlas вообще поощряет declarative, без явных down.

Варианты:

  1. Отдельный CI job перед деплоем кода. Просто, надёжно.
  2. Init container в k8s. Под не стартует, пока миграция не завершена. Удобно, но проблема: 10 подов — 10 параллельных миграций. Лечится: LOCK schema_migrations или leader election.
  3. Operator (atlas controller для k8s).
  4. Application boot: код сам применяет миграцию при старте. Удобно для small projects, опасно в больших.

В большой компании — отдельный pipeline + change-management процесс.


defer tx.Rollback()
// ...
return tx.Commit()

После Commit Rollback вернёт sql.ErrTxDone / pgx.ErrTxClosed — это не ошибка, можно игнорировать.

tx, _ := db.BeginTx(ctx, nil)
// Делаем запрос НЕ через tx:
db.QueryContext(ctx, "...") // НЕ в транзакции! Берёт другой conn.

Внутри tx все запросы через tx.*. Если случайно через db.* — это другой conn, transactions не объединены.

Tx держит conn до Commit/Rollback. Если в tx — длинные операции (вызов внешнего сервиса), conn зависает.

Антипаттерн:

tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
tx.Exec("UPDATE ...")
callExternalAPI() // 5 секунд! conn висит.
tx.Commit()

Лечение: вынести внешние вызовы за tx.

tx, _ := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
tx.Exec("INSERT ...") // упадёт: "cannot execute INSERT in a read-only transaction"

Если retry’им бизнес-логику без проверки — два раза создадим заказ. Идемпотентность: использовать idempotency key, UPSERT, ON CONFLICT.

В 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, не bob

Все блочные операции (UPDATE, SELECT FOR UPDATE) могут спровоцировать deadlock. Всегда имейте retry logic для 40P01.

В Go нет явного “auto-commit”: каждый db.Exec — это автоматическая tx за кулисами (для PG). Tx только через BeginTx.

ALTER TABLE берёт AccessExclusiveLock — блокирует всё на время операции. Для больших таблиц минута блокировки = downtime.

Используйте online-патерны (см. 2.20).

Не используйте одновременно несколько (golang-migrate + GORM AutoMigrate). Они не знают друг о друге, схема рассинхронизируется.

tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
tx.Exec("INSERT ...")
return nil // Commit не вызвали! Rollback откатит.

Случайно вернули nil до Commit — ничего не сохранится. В тестах поймайте.

db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelDefault})

В Postgres это значит read_committed (default). Не путать с sql.LevelReadUncommitted (которого в PG нет).

*sql.Tx не thread-safe. Не используйте одну Tx из нескольких goroutines.

tx, _ := db.BeginTx(ctx, nil)
go tx.Exec(...) // RACE
go tx.Exec(...)
  • golang-migrate: имена 000001_*, 000002_* — ordered по числам.
  • goose: timestamps 20240101120000_* — меньше конфликтов при rebase.

Если несколько разработчиков делают миграции одновременно, timestamp подход избегает конфликтов номеров.

//go:embed migrations
var fs embed.FS

Если migrations папка пустая на момент компиляции — ошибка. Положите хотя бы dummy файл или используйте //go:embed migrations/*.

Если down включает удаление колонки/таблицы — это не откат, это потеря данных. В проде down используют редко, обычно forward-only fix.

Backfill миллионов строк одним UPDATE — блокирует надолго. Лечение: батчи:

UPDATE users SET full_name = name WHERE id BETWEEN 1 AND 10000 AND full_name IS NULL;
-- повторить для следующих 10000

Или фоновой job, не миграцией.

sqlc генерирует методы на Queries. Чтобы вызвать в tx:

tx, _ := pool.Begin(ctx)
qtx := q.WithTx(tx) // sqlc метод
qtx.GetUser(ctx, 42)
tx.Commit(ctx)

q.WithTx(tx) возвращает новый Queries, который использует tx.

atlas migrate diff требует --dev-url: пустую БД для расчёта diff. Обычно docker://postgres/16/dev — поднимает временный контейнер.

В transaction mode PgBouncer возвращает conn в пул после каждого COMMIT. Это значит:

  • LISTEN/NOTIFY ломаются.
  • Prepared statements ломаются (по умолчанию).
  • BeginTx + SET LOCAL ... работает.

Если используете PgBouncer transaction mode — отключите prepared cache в pgx (или используйте PgBouncer 1.21+ с поддержкой prepared).


BEGIN + COMMIT сами по себе — ~0.5ms overhead. Для одного INSERT часто не нужны (auto-commit). Для нескольких связанных — обязательно.

  • Read Committed: дефолт, никакого overhead.
  • Repeatable Read: snapshot per tx, минимальный overhead.
  • Serializable: SSI tracking, ~10-20% overhead, плюс возможные retries.

Лучше: одна tx с 100 INSERT’ов. Хуже: 100 отдельных tx.

tx, _ := db.BeginTx(ctx, nil)
for _, u := range users {
tx.Exec("INSERT ...")
}
tx.Commit()

Экономия — 100 × 0.5ms commit overhead.

Локи на уровне строки. Если многие tx ждут — длинная очередь. Идиомы:

  • FOR UPDATE NOWAIT — упадёт сразу, если занято.
  • FOR UPDATE SKIP LOCKED — пропустит залоченные.

Каждый retry — повторное выполнение работы. Если 10% tx падают на deadlock и retry’ятся 2 раза — реальная нагрузка ~10% выше.

Снижайте deadlock: порядок locks, короткие tx, меньший isolation.

Online миграции (ADD COLUMN DEFAULT, CREATE INDEX CONCURRENTLY) — секунды-минуты на больших таблицах, без даунтайма.

ALTER TABLE со сменой типа — может занять часы и заблокировать. На terabyte tables используют pg_repack, pt-online-schema-change (нет аналога для PG) или pgrepack.

После UPDATE/DELETE старые версии строк остаются (MVCC). Vacuum их чистит. Длинные tx блокируют vacuum — bloat растёт.

Идиома: tx ≤ нескольких секунд.

Tx с serializable: throughput может упасть в 2-3x из-за retries и tracking. Если throughput критичен — read_committed + optimistic locking.

Каждая tx — один conn. Если max_conn=25, не больше 25 параллельных tx. Long-running tx → нет conn для других → throughput тормозит.

golang-migrate создаёт таблицу schema_migrations без индексов (одна строка). Это норма.


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 без даунтайма?

  1. 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 между подами и контролировать процесс.


Реализуйте WithTx(ctx, db, func(tx) error) error с panic recovery и автоматическим Rollback при ошибке.

Реализуйте Transfer(from, to, amount) через tx. Учтите: проверка баланса, deadlock retry, idempotency через transfer_id.

Реализуйте UpdateUser с version column. При rows_affected=0 — retry с обновлёнными данными.

Реализуйте таблицу jobs(id, payload, status) и worker, который берёт jobs через SELECT ... FOR UPDATE SKIP LOCKED.

Создайте проект с миграциями (3 штуки), запустите up/down, проверьте schema_migrations. Embed через embed.FS.

Сценарий: добавить email к users(10M строк). Шаги: ADD COLUMN, backfill батчами по 10к, ADD NOT NULL через NOT VALID + VALIDATE.

Реализуйте увеличение счётчика без race condition через Serializable isolation и retry-on-40001.

Создайте тот же набор изменений схемы (CREATE TABLE, ADD COLUMN, ADD INDEX) через atlas (declarative) и через golang-migrate (versioned). Сравните DX.


  1. PostgreSQL docs: Transaction Isolation: https://www.postgresql.org/docs/current/transaction-iso.html.
  2. PostgreSQL docs: Explicit Locking: https://www.postgresql.org/docs/current/explicit-locking.html.
  3. database/sql Tx: https://pkg.go.dev/database/sql#Tx.
  4. pgx Tx: https://pkg.go.dev/github.com/jackc/pgx/v5#Tx.
  5. golang-migrate: https://github.com/golang-migrate/migrate, docs.
  6. goose: https://github.com/pressly/goose.
  7. atlas: https://atlasgo.io/, docs.
  8. Article “Online Migrations at Scale” (Stripe blog).
  9. Article “Postgres Zero Downtime Migrations” (various).
  10. Книга “Designing Data-Intensive Applications” Martin Kleppmann — главы про tx и isolation.
  11. Talk “Transactions in Distributed Systems” (Eric Brewer, various).
  12. Postgres wiki: Lock Conflicts: https://wiki.postgresql.org/wiki/Lock_Monitoring.