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

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 11 из 25 ~20 мин +50 XP

Транзакции: QueriesWithTx

sqlc транзакции — тема, которую курс вынашивал с шестого урока: там появилась привычка «несколько записей — одна транзакция», а полный разбор был обещан позже. Позже — сегодня. У трекера назрёл первый сценарий, где два изменения обязаны случиться вместе: закрытие задачи и запись её суммы в журнал списаний. По отдельности каждый UPDATE и каждый INSERT атомарны, но между двумя вызовами queries может влезть что угодно — сбой сети, отмена контекста, паника в вашем же коде, — и половина работы повиснет в воздухе. Разбираем обещанное по буквам: почему сгенерированные Queries привязаны к пулу, а транзакция требует выделенного соединения; как метод WithTx — единственный транзакционный во всём db.go — строит копию Queries поверх транзакции и почему возвращает ровно одно значение; канонический паттерн withTx — Begin, defer Rollback, вызовы через tx-копию, Commit — и его упаковка в хелпер; откат по ошибкам и панике; интерфейс Querier; история PrepareTx и emit_prepared_queries; различия pgx и database/sql — от TxOptions до BeginFunc; вложенные вызовы и границы: когда транзакция нужна, а когда лишняя. Карта типов при pgx/v5 — в [десятом уроке](/courses/sqlc/lessons/10-massivy-json/); план остальных тем — на странице [курса sqlc](/courses/sqlc/).

Содержание урока · 14 разделов

Десятый урок закрыл колонки-коллекции и на прощание пообещал транзакции: PrepareTx, передачу *sql.Tx и паттерн withTx. Обещание выполняем. У трекера назрел первый операционный сценарий, где два изменения обязаны случиться вместе: закрытие задачи и запись её суммы в журнал списаний. По отдельности каждый UPDATE и каждый INSERT атомарны — но между двумя вызовами queries может влезть что угодно: сбой сети, отмена контекста, паника в вашем же коде. Половина работы повиснет в воздухе, и ни один тест этого не заметит — пока не придёт бухгалтерия.

Одиннадцатый урок отвечает на пять вопросов. Первый: почему сгенерированные Queries, привязанные к пулу, не годятся для транзакций и что вообще значит «транзакция требует выделенного соединения». Второй: что именно пишет генератор в db.go — метод WithTx, единственный транзакционный во всём файле, — и почему он возвращает ровно одно значение, а не пару со строкой ошибки. Третий: канонический паттерн withTx — Begin, defer Rollback, вызовы через tx-копию, Commit — и его упаковка в хелпер, где boilerplate живёт в одном месте и потерять откат невозможно. Четвёртый: что происходит с подготовленными выражениями — история PrepareTx и опции emit_prepared_queries, которую второй урок держал выключенной. Пятый: различия pgx и database/sql — от TxOptions и BeginFunc до ErrTxDone — плюс вложенные вызовы, при которых транзакцию нельзя открывать заново, и границы темы: когда транзакция нужна, а когда она лишняя работа. Всё — на знакомой схеме todos, одной новой таблице spendings и сгенерированном коде, как его печатает генератор версии 1.27.

sqlc транзакции: почему одной Queries мало#

Запрос «sqlc транзакции» почти всегда означает одно и то же недоумение: методы Queries есть, а ключевого слова BEGIN среди них нет. Разложим сцену по слоям. Сгенерированный q := db.New(pool) держит в поле db интерфейс DBTX, под который подходит и пул, и одиночное соединение. Каждый вызов q.CompleteTodo или q.CreateSpending — самостоятельный оператор: он выполняется в режиме автокоммита, то есть база оборачивает его в собственное неявное BEGIN ... COMMIT и фиксирует сразу. Два соседних вызова — две независимые сделки, и между ними есть окно, куда влезает что угодно: чужой запрос, сбой сети, рестарт приложения, отмена контекста. Требование «или оба изменения, или ни одного» на таком слое не выражается — это и есть момент, когда нужны транзакции.

Вторая причина тоньше и про соединения. Пул держит несколько соединений и раздаёт их по запросу; два вызова одного и того же q вовсе не обязаны поехать на одном — и BEGIN, открытый на одном соединении, не накроет оператор на другом. Транзакция в Go — это выделенное соединение с состоянием: database/sql отдаёт его в виде *sql.Tx после Begin или BeginTx, pgx — в виде pgx.Tx. У этого выделенного соединения своя судьба: всё, что через него проходит, коммитится или откатывается одной кнопкой, а чужие клиенты его не трогают.

Запрос «withtx sqlc» ищет именно мостик между двумя мирами — пулом и выделенным соединением. Сгенерированные Queries умеют работать с любым DBTX, а *sql.Tx и pgx.Tx реализуют DBTX целиком: у них есть все те же Exec- и Query-методы. Значит, копия Queries с tx в поле db вызывает те же методы с теми же сигнатурами — но внутри одной транзакции на одном соединении. Ровно это и делает WithTx. Шестой урок дал привычку «несколько записей — одна транзакция»; сегодня — механика по буквам, включая строку, которая не скомпилируется.

Что генерирует sqlc: db.go и метод WithTx#

Точную генерацию сверим с шаблонами генератора — папка internal/codegen/golang/templates в репозитории sqlc, версия 1.27. Для pgx/v5 db.go содержит четыре сущности: интерфейс DBTX из трёх методов (Exec, Query, QueryRow — плюс CopyFrom и SendBatch, если ваши запросы их используют), конструктор New(db DBTX), структуру Queries с единственным полем db и метод WithTx. Никакого второго хранилища, никаких флагов транзакции: WithTx строит новую структуру Queries, у которой то же самое поле db теперь ссылается на транзакцию.

Три факта об этой генерации стоит запомнить наизусть. Первый: WithTx не отправляет в базу ни одного оператора — BEGIN к моменту вызова уже выполнен Begin-ом, и методу нечего делать в сети; поэтому у него нет и ошибки. Он возвращает ровно одно значение — Queries. Если где-то в вашем коде осталась строка вида qtx, err := queries.WithTx(tx), она не скомпилируется: справа одно значение, слева две переменные, и компилятор ответит assignment mismatch. Ошибку отдаёт Begin — к моменту WithTx транзакция уже открыта. Второй факт: копия независима. Оригинальный q продолжает работать с пулом — после WithTx у вас в руках два объекта, pool-версия и tx-версия, и это разные пути данных. Третий факт: WithTx — метод Queries, а не функция пакета, вызывается как queries.WithTx(tx).

В database/sql-флейворе генерация идентична с точностью до типа аргумента: WithTx(tx sql.Tx) Queries, а DBTX описан четырьмя методами с суффиксом Context. Занятная деталь: New(tx) компилируется так же — конструктор принимает любой DBTX, и транзакция в него влезает. WithTx всё равно полезнее: при подготовленных выражениях он переносит stmt-поля, о которых New знать не знает, — этому посвящён отдельный раздел ниже.

db.go при pgx/v5: точная генерация

GO
// db/sqlc/db.go — сгенерировано (pgx/v5, шаблон dbCodeTemplatePgx, v1.27)
type DBTX interface {
        Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
        Query(context.Context, string, ...interface{}) (pgx.Rows, error)
        QueryRow(context.Context, string, ...interface{}) pgx.Row
}

func New(db DBTX) *Queries {
        return &Queries{db: db}
}

type Queries struct {
        db DBTX
}

// единственный транзакционный метод во всём db.go:
// не ходит в сеть и возвращает ровно одно значение
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
        return &Queries{
                db: tx,
        }
}

// database/sql-флейвор отличается только типом аргумента:
// func (q *Queries) WithTx(tx *sql.Tx) *Queries

Фрагмент взят из шаблона dbCodeTemplatePgx дословно, сокращены только повторы. DBTX при pgx — три метода без суффикса Context: контекст всегда первый аргумент, пятый урок уже показывал это на QueryRow. WithTx — единственный метод, который sqlc генерирует для транзакций: ни Begin, ни Commit в сгенерированном коде нет, они приходят от драйвера. Тип аргумента — pgx.Tx, интерфейс драйвера: его реализуют и транзакция одиночного соединения, и транзакция, взятая из пула. Копия содержит то же единственное поле db — других отличий от оригинала нет. В database/sql-варианте интерфейс четырёхметодный, а WithTx принимает *sql.Tx; тело метода идентично построчно.

Проверка: две версии Queries

После строки qtx := queries.WithTx(tx) какой из вызовов выполнится ВНЕ транзакции?

Предскажите судьбу этой строки

Прочитайте код и выберите, что он выведет:

// pgx/v5: скелет транзакции
tx, err := pool.Begin(ctx)
if err != nil {
        return err
}
defer tx.Rollback(ctx)

qtx, err := queries.WithTx(tx)

Паттерн withTx: Begin, defer Rollback, Commit#

Канонический порядок паттерна withTx жёсткий, и каждое место в нём обосновано. Шаг первый — Begin: у pgx pool.Begin(ctx), у database/sql db.BeginTx(ctx, nil) или короткое db.Begin(ctx); оператор вынимает соединение из пула и открывает транзакцию. Это единственная точка входа с ошибкой — проверяем её первой строкой. Шаг второй — defer tx.Rollback(ctx), поставленный сразу после Begin: отложенный откат сторожит все выходы — каждый return с ошибкой, ранний выход, панику. Отложенные функции выполняются при раскрутке стека даже в случае паники, поэтому изменения откатятся, а паника продолжит всплытие к recover или до верха программы. Шаг третий — qtx := queries.WithTx(tx): дальше всё только через tx-копию. Шаг четвёртый — вызовы qtx, где каждая ошибка означает return err. Шаг пятый — return tx.Commit(ctx): единственная и явная точка фиксации, ошибку которой проверяют — здесь база говорит «зафиксировать не вышло».

Две детали делают паттерн честным. Первая: Rollback после Commit вернёт ошибку — database/sql ответит sql.ErrTxDone с дословным текстом «sql: transaction has already been committed or rolled back», pgx — ошибкой, для которой errors.Is(err, pgx.ErrTxClosed) истинно. В defer эту ошибку игнорируют: это ожидаемая дорога, а не авария. Вторая: Commit в defer писать нельзя — анти-паттерн по обеим осям. Ошибка коммита потеряется, ведь defer ничего не возвращает, а при ошибке внутри транзакции Commit сделает бессмысленную попытку зафиксировать прерванную работу: в Postgres транзакция после сбоя оператора считается прерванной, и pgx честно ответит ErrTxCommitRollback — «коммит на самом деле сделал откат». Плюс мелочь, которая путает: у *sql.Tx методы Rollback и Commit живут без контекста — контекст израсходован BeginTx; у pgx контекст есть у всех.

Скелет: закрыть задачу и списать деньги

GO
-- db/migrations/007_spendings.sql: журнал списаний
CREATE TABLE spendings (
        id         BIGSERIAL PRIMARY KEY,
        todo_id    BIGINT NOT NULL REFERENCES todos(id),
        amount     BIGINT NOT NULL, -- деньги целыми числами
        created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- db/queries/todos.sql: уже знакомый оператор
-- name: CompleteTodo :execrows
UPDATE todos
SET done = true
WHERE id = $1 AND done = false AND deleted_at IS NULL;

-- db/queries/spendings.sql: новый оператор
-- name: CreateSpending :one
INSERT INTO spendings (todo_id, amount)
VALUES ($1, $2)
RETURNING id, created_at;

// main.go — канонический паттерн (pgx/v5)
func closeAndCharge(ctx context.Context, pool *pgxpool.Pool, queries *db.Queries, id, amount int64) error {
        tx, err := pool.Begin(ctx) // ошибка живёт здесь, у Begin
        if err != nil {
                return err
        }
        defer tx.Rollback(ctx) // безвреден после Commit

        qtx := queries.WithTx(tx) // одно значение, без err
        if _, err := qtx.CompleteTodo(ctx, id); err != nil {
                return err // defer откатит BEGIN
        }
        params := db.CreateSpendingParams{TodoID: id, Amount: amount}
        if _, err := qtx.CreateSpending(ctx, params); err != nil {
                return err
        }
        return tx.Commit(ctx) // единственная точка фиксации
}

amount — bigint NOT NULL: деньгам не нужен nullable-мир девятого урока, ошибки тут обязаны быть исключением. CreateSpendingParams — правило восьмого урока: два параметра сворачиваются в структуру. CreateSpending оформлен :one с RETURNING — форма из третьего и пятого уроков, забираем id и created_at вставленной строки. Порядок паттерна жёсткий: Begin до WithTx, defer сразу после Begin — тогда откат сработает на любом выходе, включая панику; Commit — финальный return, единственный вызов без defer. После Commit отложенный Rollback вернёт ErrTxClosed — в defer её игнорируют. Ошибка внутри любого qtx-вызова завершает функцию: оба оператора откатятся, база останется в исходном состоянии. И заметьте: оба вызова идут через qtx — вызов queries.CompleteTodo рядом с транзакцией уехал бы по другому соединению мимо неё.

Проверка: паника в середине

Внутри замыкания после двух qtx-вызовов случилась паника. Что произойдёт с транзакцией и программой?

Предскажите вывод повторного Commit

Прочитайте код и выберите, что он выведет:

// database/sql
tx, err := db.BeginTx(ctx, nil)
if err != nil {
        return err
}
defer tx.Rollback()
if err := tx.Commit(); err != nil {
        return err
}
fmt.Println(tx.Commit())

withTx-хелпер: одна точка правды#

Скелет из пяти шагов повторяется в проекте столько раз, сколько есть атомарных сценариев, и рано или поздно кто-то напишет его без defer. Решение — хелпер withTx: рамка, внутри которой boilerplate живёт ровно один раз. Сигнатура проста: функция принимает контекст, источник соединений и замыкание fn func(q *db.Queries) error; сама открывает транзакцию, ставит отложенный откат, строит tx-копию Queries, передаёт её в замыкание и — если замыкание вернуло nil — коммитит. Ошибка внутри замыкания означает откат, nil — фиксацию; других режимов нет.

Из такой формы следуют три дисциплины. Первая: забыть откат или потерять коммит невозможно — они прописаны в одном месте, и новые сценарии не копируют boilerplate, а вызывают хелпер. Вторая: замыкание видит только tx-версию Queries и не решает, коммитить ли по ходу дела — решение «атомарно или не атомарно» принимает вызывающий код, выбирая между q и withTx. Третья: внутри замыкания не открывают вторую транзакцию — почему, следующий раздел покажет на дедлоке.

В pgx-мире есть и встроенный аналог — pgx.BeginFunc: функция принимает контекст, что-то умеющее Begin (пул или соединение) и замыкание от pgx.Tx; сама делает Begin, коммит при nil и откат при ошибке, а отложенный отладочный Rollback фильтрует по ErrTxClosed — ровно та дисциплина, которую мы разбирали. database/sql встроенного хелпера не имеет: пишут свой withTx, благо это десять строк. Обе версии хелпера — в блоке кода ниже; они не знают ни одного конкретного запроса и потому переживают любые изменения слоя данных.

withTx для pgxpool и для *sql.DB

GO
// pgx/v5: boilerplate живёт в одном месте
func withTx(ctx context.Context, pool *pgxpool.Pool, fn func(q *db.Queries) error) error {
        tx, err := pool.Begin(ctx)
        if err != nil {
                return err
        }
        defer tx.Rollback(ctx) // безвреден после Commit
        if err := fn(queries.WithTx(tx)); err != nil {
                return err
        }
        return tx.Commit(ctx)
}

// database/sql: BeginTx с TxOptions, у *sql.Tx методы без ctx
func withTxSQL(ctx context.Context, db *sql.DB, fn func(q *db.Queries) error) error {
        tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
        if err != nil {
                return err
        }
        defer tx.Rollback()
        if err := fn(dbq.WithTx(tx)); err != nil {
                return err
        }
        return tx.Commit()
}

// вызов: внутри замыкания — только tx-копия
err := withTx(ctx, pool, func(q *db.Queries) error {
        return closeTodoAndCharge(ctx, q, 42, 1500)
})

// pgx-аналог хелпера — есть из коробки:
err = pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
        qtx := queries.WithTx(tx)
        _, err := qtx.CompleteTodo(ctx, 42)
        return err
})

Хелпер принимает замыкание, которому отдаётся только tx-версия Queries, — потерять Rollback или спрятать ошибку Commit внутри такого каркаса невозможно. database/sql-версия показывает TxOptions: уровень изоляции и флаг ReadOnly передаются BeginTx; у *sql.Tx отложенные методы живут без контекста — контекст уже израсходован BeginTx. pgx.BeginFunc(ctx, pool, fn) — готовый каркас: fn получает pgx.Tx, ошибку — откат, nil — коммит; внутри pgx сам игнорирует ErrTxClosed от отложенного Rollback, повторяя наш defer дословно. Имя dbq во второй версии — просто вторая переменная Queries, чтобы пример читался без лишнего контекста.

Querier: интерфейс над транзакциями#

Флаг emit_interface из второго урока просит у генератора ещё один файл — querier.go — и в транзакционной теме у него вторая, более тонкая роль. Сгенерированный интерфейс собирается из методов запросов: по форме на каждый оператор — :one даёт метод с возвратом (Row, error), :many — ([]Row, error), :exec — error, :execrows — (int64, error). В конце файла стоит строка var _ Querier = (*Queries)(nil) — компиляционная проверка, что структура Queries реализует интерфейс целиком: забудете один метод в тестовом фейке — компилятор укажет раньше, чем CI.

Главное для сегодняшней темы — чего в интерфейсе НЕТ. Querier — это только методы запросов: New, DBTX и WithTx в него не входят, потому что это не запросы, а рамка. Из этого следует практическое правило маршрутизации. Функции вниз по стеку — business-логика, обработчики — объявляйте как принимающие ctx и Querier: им всё равно, приехал аргумент с пула или из транзакции, и тесты подставляют фейк (вторая роль той же опции из второго урока). А функция, которая ПЛАНИРУЕТ транзакцию, обязана принимать конкретную *Queries или пул: у интерфейса нет WithTx, и это осознанный дизайн — сервисный метод, способный втихую открыть транзакцию внутри себя, — плохой контракт. Хелпер withTx строит рамку и отдаёт tx-копию; функции ниже принимают Querier и даже не знают, что работают внутри транзакции.

Отсюда же простой тест на подозрительную сигнатуру: если функция объявлена func(ctx, q Querier) и внутри пытается открыть транзакцию — она не скомпилируется, и это хорошо: открытие транзакции — решение уровня хелпера, а не каждой функции наугад.

Проверка: что входит в Querier

emit_interface: true включён. Что из перечисленного окажется в сгенерированном интерфейсе Querier?

PrepareTx: подготовленные выражения в транзакции#

Имя PrepareTx из плана курса не приедет из генератора: sqlc не создаёт метода PrepareTx — и это лучший повод рассказать, как подготовленные выражения уживаются с транзакциями по-настоящему. Начало истории — в database/sql. По умолчанию каждый ExecContext и QueryContext отправляет текст запроса на сервер заново: парсинг и планирование повторяются при каждом вызове, кэша prepared statements у database/sql нет — подготовленное *sql.Stmt привязано к соединению, а пул это соединение меняет при каждом запросе.

Опция emit_prepared_queries: true — та самая, которую второй урок держал выключенной, — меняет генерацию: sqlc пишет функцию Prepare(ctx, db DBTX) (Queries, error), метод Close() и поля sql.Stmt по одному на каждый запрос; вызовы идут через внутренние queryRow, query и exec, которые используют подготовленные выражения. Где здесь транзакции? Внутри WithTx: копия получает не только tx, но и stmt-поля, а каждый вызов проходит через ветку, где подготовленное выражение перепривязывается к транзакции методом q.tx.StmtContext(ctx, stmt). Вот почему отдельного PrepareTx не существует — пара Prepare и WithTx покрывает сценарий целиком: подготовили один раз на пуле, перепривязали в каждой транзакции. StmtContext здесь несущая конструкция: *sql.Stmt подготовлен на соединении из пула, транзакция живёт на своём соединении, и без перепривязки выражение из пула нельзя выполнить внутри tx.

У pgx всё иначе: драйвер имеет неявный кэш подготовленных выражений из коробки — документация sqlc прямо говорит, что с pgx/v5 дополнительная конфигурация не нужна, и опция emit_prepared_queries к pgx-флейвору не применяется вовсе. Есть и техническое ограничение: опция несовместима с emit_methods_with_db_argument — sqlc откажется генерировать такую конфигурацию. Проверить, что ваши запросы вообще поддаются подготовке, можно без включения опции — sqlc vet с правилом sqlc/db-prepare прогоняет каждый запрос через Prepare на живой базе и роняет проверку на первом неудобном запросе.

Что генерирует emit_prepared_queries

GO
# sqlc.yaml: emit_prepared_queries: true — только для database/sql
// db/sqlc/db.go — сгенерировано (database/sql, сокращено)
func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
        q := Queries{db: db}
        var err error
        if q.createTodoStmt, err = db.PrepareContext(ctx, createTodo); err != nil {
                return nil, fmt.Errorf("error preparing query CreateTodo: %w", err)
        }
        // ... по одному PrepareContext на каждый запрос
        return &q, nil
}

type Queries struct {
        db             DBTX
        tx             *sql.Tx    // заполняет WithTx
        createTodoStmt *sql.Stmt  // подготовленное выражение на запрос
}

func (q *Queries) WithTx(tx *sql.Tx) *Queries {
        return &Queries{
                db:             tx,
                tx:             tx,
                createTodoStmt: q.createTodoStmt, // stmt-поля переезжают в копию
        }
}

func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row {
        switch {
        case stmt != nil && q.tx != nil:
                // перепривязка подготовленного выражения к транзакции
                return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...)
        case stmt != nil:
                return stmt.QueryRowContext(ctx, args...)
        default:
                return q.db.QueryRowContext(ctx, query, args...)
        }
}

Три фрагмента объясняют всю механику. Prepare принимает DBTX и подготавливает каждый запрос, возвращая готовую Queries и ошибку — её проверяют один раз при старте. WithTx копирует stmt-поля и заполняет tx — поэтому он и не синоним New: конструктор знает только поле db. queryRow показывает три ветки: в транзакции выражение перепривязывается через StmtContext, вне транзакции выполняется как подготовленное, без опции — по старинке текстом. Close() закрывает все stmt; вызывать его обязан тот, кто вызывал Prepare. У pgx-флейвора ничего этого нет: неявный кэш драйвера покрывает и пул, и транзакции.

Шпаргалка: транзакции в двух мирах#

Действиеdatabase/sqlpgx / pgxpool (pgx/v5)
Начать транзакциюdb.BeginTx(ctx, nil) или db.Begin(ctx)pool.Begin(ctx) или conn.Begin(ctx)
Уровень изоляции, ReadOnly&sql.TxOptions{Isolation: sql.LevelSerializable}pgx.TxOptions{IsoLevel: pgx.Serializable}
Откатtx.Rollback() — без контекстаtx.Rollback(ctx)
Коммитtx.Commit()tx.Commit(ctx)
Commit/Rollback после закрытияsql.ErrTxDoneerrors.Is(err, pgx.ErrTxClosed), текст «tx is closed»
Commit прерванной транзакцииошибка от сервераошибка с errors.Is(err, pgx.ErrTxCommitRollback)
Каркас транзакциихелпер withTx пишут самиpgx.BeginFunc(ctx, pool, fn) из коробки
Prepared statementsemit_prepared_queries: true — Prepare и Closeне нужен: неявный кэш pgx
Интерфейс DBTXExecContext, PrepareContext, QueryContext, QueryRowContextExec, Query, QueryRow (+ CopyFrom, SendBatch при использовании)
Кто подходит под DBTXsql.DB, sql.Conn, *sql.Txpgx.Conn, pgx.Tx, pgxpool.Pool
Вложенная транзакциятолько SAVEPOINT рукамиtx.Begin(ctx) — псевдовложенность через SAVEPOINT

Вложенные вызовы и передача транзакции вниз#

Правило звучит коротко: одна транзакция на весь путь вызовов. Анти-паттерн выглядит так: функция A открывает withTx и внутри вызывает функцию B, которая тоже открывает withTx. Вторая Begin просит у пула ещё одно соединение — а первое-то занято транзакцией A. При пуле из одного-двух соединений программа взаимоблокирует сама себя: A ждёт завершения B, B ждёт свободное соединение. Даже при щедром пуле атомарность разваливается: два коммита — два независимых события, и сбой между ними оставляет половину работы. Внутри транзакции вторую не открывают — её либо не открывают, либо передают вниз tx-версию.

Правильная форма — раздача аргументом. Функция вниз по стеку принимает q — tx-версию Queries: chargeTodo(ctx, q, id). Она не знает и не хочет знать, в транзакции ли едет: снаружи её вызывают и с pool-версией (каждый оператор сам по себе), и внутри withTx (всё атомарно). Так функции передают не соединение, а Queries — и сигнатура честно показывает, что функция ходит в базу.

Смешивание q и qtx внутри одной транзакции — тихий убийца корректности. Вызов queries.GetTodo посреди withTx едет через пул: возможно, на другом соединении, в автокоммите, и незакоммиченных изменений вашей транзакции он не видит — read-your-writes ломается без единой ошибки. А вызов queries.UpdateSomething в середине транзакции — независимая сделка в стороне от вашего атомарного сценария. Дисциплина одна: внутри рамки — только qtx.

Две придаточные темы. pgx умеет псевдовложенность: tx.Begin(ctx) на живой транзакции отдаёт вложенную транзакцию через SAVEPOINT — pgx сам ведёт счёт точек сохранения; database/sql предлагает только ручные SAVEPOINT и ROLLBACK TO текстом. И помните про длину: транзакция живёт, пока вы её держите, — сетевые вызовы, отправка писем и ожидание ввода внутри рамки растягивают блокировки и истощают пул; работа вне базы выносится за withTx.

Когда транзакции нужны, а когда лишние#

Критерии стоят того, чтобы держать их перед глазами на ревью. Нужна транзакция, когда выполняется хотя бы одно из трёх. Первое — две и более записи атомарно: деньги и журнал, перевод между счетами, каскадное закрытие задачи вместе с её подзадачами. Второе — согласованное прочитать-изменить-записать: прочитать счётчик и записать инкремент — это два оператора, и без рамки между ними влезет чужой инкремент; один оператор с возвратом id решал похожую гонку внутри самого себя, но два оператора без транзакции не сцепить. Третье — частичный результат хуже ошибки: биллинг, аудит, миграции данных, где половина применённого изменения хуже отложенной работы.

Не нужны транзакции, когда оператор один: INSERT либо вставит строку, либо нет — атомарность оператора база гарантирует и без рамки, и оборачивание одиночного вызова в withTx добавляет поездки BEGIN и COMMIT без выгоды. Не нужны они и для чтений, которые показывают пользователю список: пусть даже данные чуть устареют — ничего не сломается. Отдельная категория — диалоговые транзакции, открытые в ожидании пользовательского ввода: соединение занято, блокировки растут, соседние запросы queue-ятся; пользователь ушёл на обед — пул исчерпан.

И последнее уточнение: транзакция — инструмент корректности, а не скорости. Она не ускоряет ничего, а длинная транзакция замедляет всех вокруг. Если внутри рамки нужен захват строки — SELECT ... FOR UPDATE в аннотированном запросе сработает как обычно, sqlc не мешает писать это в SQL; но выбор уровня изоляции — уже следующий виток глубины, и для трекера дефолтного READ COMMITTED хватает почти всегда: меняйте TxOptions осознанно, когда объяснили себе, какую именно аномалию вы лечите.

Шесть правил транзакций#

  • Queries привязаны к пулу, транзакция — к выделенному соединению: мостик — метод WithTx, возвращающий копию Queries с db = tx; оригинал продолжает работать с пулом.
  • WithTx возвращает ровно одно значение — *Queries; ошибку отдаёт Begin/BeginTx, и её проверяют первой строкой. Форма qtx, err := queries.WithTx(tx) не компилируется.
  • defer tx.Rollback ставится сразу после Begin и сторожит все выходы — ошибки и паники; после Commit он безвреден: sql.ErrTxDone или errors.Is(err, pgx.ErrTxClosed) в defer игнорируют.
  • Commit — один, явный, с проверкой ошибки; defer Commit — анти-паттерн: ошибка потеряется, а прерванная транзакция ответит ErrTxCommitRollback.
  • Внутри рамки — только tx-копия qtx: смешанные вызовы через q едут мимо транзакции по другим соединениям и ломают read-your-writes без единой ошибки.
  • Транзакцию внутри транзакции не открывают: функции вниз получают qtx аргументом, boilerplate живёт в withTx-хелпере (у pgx — pgx.BeginFunc), а Querier из emit_interface не умеет открывать транзакции — и это правильно.

Восстановите скелет транзакции

Четыре пропуска: старт транзакции, сторож, мостик Queries, фиксация.

// main.go: скелет транзакции (pgx/v5) func closeAndCharge(ctx context.Context, pool *pgxpool.Pool, queries *db.Queries, id, amount int64) error { tx, err := pool.(ctx) if err != nil { return err } defer tx.(ctx) // сторожит и ошибки, и паники qtx := queries.(tx) if _, err := qtx.CompleteTodo(ctx, id); err != nil { return err } params := db.CreateSpendingParams{TodoID: id, Amount: amount} if _, err := qtx.CreateSpending(ctx, params); err != nil { return err } return tx.(ctx) }

Соберите хелпер withTx по строкам

Тело хелпера без объявления функции: Begin, guard, отложенный сторож, мостик, замыкание, фиксация.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: транзакции

Термин

WithTx

нажмите, чтобы перевернуть
Определение

Метод *Queries: возвращает копию с db = tx — ровно одно значение, без ошибки, без похода в сеть. Оригинал продолжает работать с пулом. При emit_prepared_queries копия уносит и stmt-поля с tx. New(tx) компилируется тоже, но WithTx честнее выражает намерение.

1 / 6

Что дальше#

QueriesWithTx разобран по буквам: копия Queries с tx в поле db, одно возвращаемое значение вместо двух, defer-сторож сразу после Begin, единственный проверяемый Commit, хелпер с замыканием и pgx.BeginFunc, Querier без WithTx и PrepareTx как пара Prepare и WithTx со StmtContext внутри. Трекер научился закрывать задачу и списывать деньги одной судьбой — и знает границы: одна транзакция на путь вызовов, никаких рамок вокруг сети.

Следующая остановка — двенадцатый урок «Джойны и вложенные структуры»: много-колоночные выборки и сканирование в структуры, JOIN в аннотированных запросах и что делает генератор с Row-типами на несколько таблиц. Спойлер из практики: часть сценариев, ради которых вчера открывали транзакцию, завтра читается одним запросом с соединением таблиц. Если понадобится освежить карту коллекций — она в десятом уроке, nullable-мир обёрток и указателей — в девятом; план остальных тем — на странице курса sqlc.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Сгенерированные Queries привязаны к пулу, транзакция живёт на выделенном соединении: мостик — метод WithTx, возвращающий копию Queries с db = tx; оригинал продолжает работать с пулом.
  • WithTx возвращает ровно одно значение — *Queries; ошибку отдаёт Begin/BeginTx. Строка qtx, err := queries.WithTx(tx) не скомпилируется.
  • Паттерн withTx: Begin → defer Rollback сразу после Begin → qtx := queries.WithTx(tx) → вызовы через qtx → Commit; отложенный Rollback сторожит и ошибки, и паники, а после Commit безвреден.
  • Повторные Commit/Rollback отвечают sql.ErrTxDone и pgx.ErrTxClosed: в defer их игнорируют, в явных вызовах проверяют; Commit в defer — анти-паттерн, как и ErrTxCommitRollback на прерванной транзакции.
  • Хелпер withTx(ctx, pool, fn func(q *db.Queries) error) держит boilerplate в одном месте; у pgx есть готовый pgx.BeginFunc; Querier из emit_interface не содержит WithTx — сервисы принимают интерфейс, рамки строят над конкретной *Queries.
  • PrepareTx в генерации нет: emit_prepared_queries даёт Prepare(ctx, db) и Close, а WithTx копирует stmt-поля; вызовы в транзакции перепривязывают выражение через q.tx.StmtContext; pgx-флейвору опция не нужна — неявный кэш prepared statements.
  • Транзакции нужны на две и более записи атомарно и на согласованное прочитать-изменить-записать; один оператор атомарен сам; внутри транзакции не открывают вторую и не смешивают q и qtx.

Домашнее задание

Проведите трекер через транзакции. Первое: миграцией 007 создайте spendings (todo_id, amount, created_at), добавьте CreateSpending в db/queries и сверьте после sqlc generate, что параметры свернулись в CreateSpendingParams. Второе: напишите closeAndCharge по каноническому скелету — Begin, defer Rollback, WithTx, два вызова, Commit; добейтесь, чтобы компилятор поймал любую попытку написать qtx, err := queries.WithTx(tx). Третье: сломайте сценарий намеренно — поставьте на таблицу CHECK (amount > 0) и попробуйте списать отрицательную сумму; убедитесь, что done у задачи не изменился: это работа Rollback. Четвёртое: упакуйте скелет в хелпер withTx(ctx, pool, fn) и перепишите closeAndCharge на замыкание; рядом соберите pgx.BeginFunc-версию и решите, какая читается лучше. Пятое: включите emit_prepared_queries в database/sql-копии конфига и найдите в сгенерированном db.go Prepare, Close и ветку с StmtContext; объясните себе, почему в WithTx-копии stmt-поля копируются, а tx заполняется. Шестое: на живой базе попробуйте смешать вызовы queries и qtx внутри одной транзакции и опишите, что увидит второй вызов — это лучшая прививка от ловушки номер два.

Личные заметкиПусто
Хранятся только в вашем браузере (sqlc · Транзакции: QueriesWithTx)

Вопросы и ответы по уроку

Чем WithTx отличается от New — оба же принимают соединение?

New строит Queries поверх любого DBTX — пула, одиночного соединения и даже транзакции: New(tx) компилируется. WithTx — метод существующего инстанса: копирует его состояние (при emit_prepared_queries — вместе со stmt-полями и tx), подменяет db на транзакцию и главное — выражает намерение: эти запросы идут внутри транзакции. Компилятору безразлично, читателю — нет.

Почему WithTx не возвращает ошибку, если транзакция может не открыться?

Потому что к моменту WithTx транзакция уже открыта: BEGIN выполнил Begin или BeginTx, и их ошибку вы проверили первой строкой. WithTx ничего не отправляет в базу — он лишь строит копию структуры с tx в поле db, поэтому возвращает ровно одно значение *Queries. Форма qtx, err := queries.WithTx(tx) не скомпилируется: справа одно значение, слева две переменные.

Нужен ли emit_prepared_queries при pgx/v5?

Нет: pgx/v5 имеет неявный кэш подготовленных выражений и сам перепривязывает их в транзакциях — никакой конфигурации не требуется. Опция имеет смысл для database/sql, MySQL и SQLite: она генерирует Prepare(ctx, db) (*Queries, error), Close() и поля *sql.Stmt, а WithTx-копия перепривязывает выражения через tx.StmtContext. С опцией emit_methods_with_db_argument она несовместима — sqlc откажется генерировать такую конфигурацию.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

ent25 уроков~13 чпрактика25 уроков уже доступны

ent: графовая ORM с кодогенерацией

Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.

  • Проектировать схемы и связи в ent
  • Генерировать код и жить с кодогенерацией
  • Писать запросы builder-стилем с типами
  • Строить хуки, интерсепторы и privacy-правила