Содержание урока · 14 разделов
- sqlc транзакции: почему одной Queries мало
- Что генерирует sqlc: db.go и метод WithTx
- Паттерн withTx: Begin, defer Rollback, Commit
- withTx-хелпер: одна точка правды
- Querier: интерфейс над транзакциями
- PrepareTx: подготовленные выражения в транзакции
- Шпаргалка: транзакции в двух мирах
- Вложенные вызовы и передача транзакции вниз
- Когда транзакции нужны, а когда лишние
- Шесть правил транзакций
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Десятый урок закрыл колонки-коллекции и на прощание пообещал транзакции: 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 и живой PostgreSQL в браузер не поместить, поэтому в песочнице уроки курса не запускаются — runner-ов здесь нет вовсе. Понимание проверяют quiz, predict, fill, order и флешкарты: они гоняют то же самое, что проверил бы компилятор и sqlc generate на вашей машине. Сгенерированные фрагменты сверены с шаблонами генератора версии 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/sql | pgx / 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.ErrTxDone | errors.Is(err, pgx.ErrTxClosed), текст «tx is closed» |
| Commit прерванной транзакции | ошибка от сервера | ошибка с errors.Is(err, pgx.ErrTxCommitRollback) |
| Каркас транзакции | хелпер withTx пишут сами | pgx.BeginFunc(ctx, pool, fn) из коробки |
| Prepared statements | emit_prepared_queries: true — Prepare и Close | не нужен: неявный кэш pgx |
| Интерфейс DBTX | ExecContext, PrepareContext, QueryContext, QueryRowContext | Exec, Query, QueryRow (+ CopyFrom, SendBatch при использовании) |
| Кто подходит под DBTX | sql.DB, sql.Conn, *sql.Tx | pgx.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 не умеет открывать транзакции — и это правильно.
Первая: забытый defer Rollback. Ранний return без отложенного отката оставляет соединение с открытой транзакцией — у database/sql оно числится занятым до финализатора или таймаута, у pgx — до закрытия соединения; пул худеет на глазах. Дисциплина: defer tx.Rollback(ctx) — первая строка после Begin. Вторая: смешанные вызовы. Половина операторов через qtx, половина через q — вторая половина едет мимо транзакции: читает не то и пишет в обход атомарности; ошибки не будет, будут неправильные данные. Третья: транзакция-контейнер. Держать рамку открытой вокруг сетевых вызовов и внешних API — значит растить блокировки, истощать пул и приглашать дедлоки; всё, что не база, выносится за пределы withTx.
Восстановите скелет транзакции
Четыре пропуска: старт транзакции, сторож, мостик Queries, фиксация.
Соберите хелпер withTx по строкам
Тело хелпера без объявления функции: Begin, guard, отложенный сторож, мостик, замыкание, фиксация.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: транзакции
Что дальше#
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 внутри одной транзакции и опишите, что увидит второй вызов — это лучшая прививка от ловушки номер два.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем 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 откажется генерировать такую конфигурацию.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
ent: графовая ORM с кодогенерацией
Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.
- Проектировать схемы и связи в ent
- Генерировать код и жить с кодогенерацией
- Писать запросы builder-стилем с типами
- Строить хуки, интерсепторы и privacy-правила