Содержание урока · 10 разделов
Каркас из тринадцатого урока — Begin, проверка ошибки, defer Rollback, работа, Commit — честный, но повторяющийся: на сервис с сорока операциями это сорок одинаковых четырёхстрочных прелюдий, и каждая четвёртая на ревью хромает: то defer забыт, то Commit не проверен, то Rollback зовётся только в одной ветке. Паттерн «с транзакцией» устраняет повторяемость конструкцией языка: операция описывается функцией, которую обёртка запускает внутри транзакции — открытие, страховка и финал живут в одном месте и не забываются никогда. В pgx обёртка готовая: pgx.BeginFunc принимает пул, соединение или другую транзакцию и колбэк вида func(tx pgx.Tx) error. Разберём сначала коробочную версию, затем её внутренности — там всего десять строк и одно важное правило про контекст, — потом свой WithTx, который нужен, когда к каркасу добавляется политика: метрики, лимит времени, повторные попытки. Дальше — то, вокруг чего паттерн и придуман: как правильно передавать транзакцию в репозитории, чтобы она не разваливалась на полпути, и что pgx умеет с вложенными транзакциями. Ключевое требование к уроку одно: после него вы не забудете Rollback — не потому что стали дисциплинированнее, а потому что забыть стало невозможно.
pgx.BeginFunc: обёртка из коробки#
Сигнатура: pgx.BeginFunc(ctx, db, fn) — где db это всё, у чего есть метод Begin(ctx) (pgx.Tx, error), а fn — func(tx pgx.Tx) error. На место db встаёт и pgxpool.Pool из четвёртого урока, и pgx.Conn, и сама транзакция — про последний случай ниже, он живее, чем кажется. Контракт BeginFunc короткий: открыть транзакцию, вызвать fn; вернула nil — Commit, вернула ошибку — Rollback, и именно её ошибку обёртка вернёт наверх. Обратите внимание, что ошибка запроса внутри fn обычно никуда специально не «отправляется» — вы сами возвращаете её из fn, и это делает поток ошибок прямым и читаемым: одна точка выхода, никаких флагов и состояний. Всё, что fn делает через tx.Exec и tx.Query, едет на коннект транзакции; пул внутрь не лезет — на этом построено живое место ниже. Отдельная деталь, которая сбивает с толку новичков: контекст в BeginFunc используется для управляющих команд BEGIN, COMMIT и ROLLBACK, но не ограничивает выполнение fn — если нужен дедлайн на всю операцию, его приносит сам ctx через WithTimeout, и о расстановке бюджетов говорит пятнадцатый урок. Пока — просто запомните: BeginFunc не добавляет таймаутов от себя.
Перевод денег через BeginFunc
GOfunc transfer(ctx context.Context, pool *pgxpool.Pool,
fromID, toID int, amount int64) error {
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount, fromID)
if err != nil {
return err
}
_, err = tx.Exec(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount, toID)
if err != nil {
return err
}
return nil
})
}Функция перевода свелась к возвращению pgx.BeginFunc: обёртка открывает транзакцию на пуле, запускает колбэк и сама выбирает Commit либо Rollback по ошибке колбэка. ErrTxClosed от безвредного отката тоже поглощается внутри. Ни одной строки про Begin и Rollback — и забывать их нечем.
Что внутри BeginFunc: контракт ошибок#
Обёртка умещается в десять строк, и их стоит понимать, потому что контракт проще показать, чем описать. После Begin ставится defer tx.Rollback() — тот же приём из тринадцатого урока; далее fn вызывается. Если fn вернула ошибку, обёртка делает Rollback ещё раз явно и возвращает вашу ошибку без изменений — то, что fn хотела сказать наверх, не обёрнуто и не съедено. Если fn вернула nil, выполняется Commit, а отложенный Rollback после него вернёт ErrTxClosed, который обёртка распознаёт через errors.Is и молча игнорирует — тот самый безопасный defer. Практическое следствие: собственная ошибка Commit доходит до вызывающего как есть — обёртка не глотает её; фильтр errors.Is игнорирует только ErrTxClosed, а после Commit или явного отката транзакция уже закрыта, так что отложенный Rollback в живом коде возвращает именно его. Вторая коробочная версия — pgx.BeginTxFunc(ctx, db, options, fn): то же самое, но с pgx.TxOptions, где задаются уровень изоляции pgx.Serializable или pgx.RepeatableRead, режим доступа pgx.ReadOnly и откладываемость; она пригодится для отчётов, которым нужен согласованный срез данных, — базовый уровень read committed из тринадцатого урока остаётся выбором по умолчанию.
Свой WithTx: когда нужна своя обёртка
GOtype Store struct {
pool *pgxpool.Pool
}
func (s *Store) WithTx(ctx context.Context,
fn func(pgx.Tx) error) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() {
if rErr := tx.Rollback(ctx); rErr != nil &&
!errors.Is(rErr, pgx.ErrTxClosed) {
log.Printf("rollback: %v", rErr)
}
}()
if err := fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
}Свой WithTx повторяет контракт BeginFunc и добавляет политику: дедлайн на всю транзакцию через context.WithTimeout и логирование редкого сбоя отката. Заметьте порядок: таймер ставится до Begin, поэтому дедлайн накрывает и ожидание коннекта из пула, и все запросы колбэка. Каркас из тринадцатого урока никуда не делся — он переехал в одно место проекта, где его видит ревью, а не размазан по сорока операциям.
Свой WithTx: когда нужна своя обёртка#
Коробочная BeginFunc покрывает восемь случаев из десяти, и начинать стоит с неё. Своя обёртка оправдывается, когда у каркаса появляется политика. Первое — время: дедлайн на транзакцию целиком, как в примере выше, — у BeginFunc для этого нет крючка, он честно берёт ваш контекст как есть. Второе — наблюдаемость: метрика «сколько транзакций откатилось», лог медленных операций, трассировка — всё это пишется один раз в WithTx и работает для всех операций автоматически. Третье — типовые отступления от атомарности: компенсирующие действия при откате, события для шины после коммита. Отдельный соблазн — ретраи: обёртка вроде место для повторной попытки, но повторять можно только операцию без внешних побочных эффектов, готовую к тому, что первая попытка откатилась целиком; колбэк с письмом внутри ретраить нельзя. Четвёртое — тесты: WithTx на интерфейсе позволяет подставить фейковый store без базы — тема двадцать третьего урока. Где проходит граница разумного: обёртка не должна знать про бизнес-логику — как только внутри появились условия «если такая-то таблица, то другой уровень изоляции», это уже не каркас, а новое место для багов. Правило то же, что у middleware: обёртка делает механику, функция — смысл. И да, хранить WithTx стоит рядом с пулом: методы структуры Store получают его «бесплатно», без передачи лишних аргументов.
Самая частая ошибка паттерна: внутри func(tx pgx.Tx) запрос сделан не через tx, а через захваченный по замыканию pool — p.Exec уходит на любой свободный коннект мимо транзакции и в откат не попадает. Всё выглядит рабочим: коммит зелёный, данные на месте, — но половина операции вне атомарности, и в тесте с падением на середине это выстрелит. Что сломалось бы: откат операции с уже записанным побочным эффектом. Лекарство дисциплинарное: колбэк принимает tx, и внутри него слово pool встречаться не должно; ревью ловит это одним поиском.
Транзакция и репозитории: интерфейс Querier#
Паттерн живёт или умирает на границе со слоем репозиториев. Первая наивная версия: методы репозитория принимают pgx.Tx — CreateOrder(ctx, tx, order). Работает, но связывает методы с транзакцией жёстко: тот же метод из кода без транзакции уже не вызвать, а в тестах придётся мокать весь pgx.Tx. Вторая версия — узкий интерфейс: type Querier interface { Exec(ctx, sql, args...) (...); Query(...); QueryRow(...) }. В pgx его реализуют и *pgxpool.Pool, и pgx.Tx — наборы методов у них совпадают намеренно, это проверено по исходникам. Репозиторий объявляет методы под Querier и не знает, приехал ли он с транзакцией: сервис при обычной операции передаёт пул, при составной — tx из BeginFunc. Бонус этой схемы — вложенность: у pgx.Tx есть и Begin, поэтому репозиторий, получивший tx, при нужде сам откроет savepoint, не ломая контракт. Нужен интерфейс из трёх методов или меньше? Сужайте под каждый репозиторий — вместо Querier с Exec может быть OrderWriter с одним InsertOrder; принцип тот же: функция просит ровно то, чем пользуется, и это остаётся честным при передаче транзакции. Спорная деталь, о которой спорят на ревью: передавать ли tx явно параметром или прятать в контекст. Явный параметр виден в сигнатуре и в трассировке; контекст короче, но превращает связь в невидимую — я за явный, пока команда не договорилась об обратном осознанно.
Querier: один метод, два источника
GOtype Querier interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
func (r *OrderRepo) Create(ctx context.Context, q Querier,
o Order) error {
_, err := q.Exec(ctx,
"INSERT INTO orders (user_id, total) VALUES ($1, $2)",
o.UserID, o.Total)
return err
}Метод Create принимает Querier — интерфейс из трёх глаголов, который реализуют и пул, и pgx.Tx: сигнатуры совпадают до буквы, от CommandTag до Row. Сервис зовёт его двояко: repo.Create(ctx, pool, order) в обычном коде и repo.Create(ctx, tx, order) внутри BeginFunc — репозиторий не меняется, а транзакция доехала до места назначения; тестовая подмена реализации ложится на тот же узкий интерфейс.
Проверка: контракт BeginFunc
Колбэк внутри pgx.BeginFunc вернул ошибку. Что сделает обёртка?
Вложенные транзакции: savepoints#
Внутри fn транзакция — тоже подходящее db для BeginFunc: pgx.BeginFunc(ctx, tx, fn2) сработает, но создаст не настоящую вторую транзакцию, а псевдо — через savepoint. Драйвер зовёт tx.Begin, который исполняет SAVEPOINT sp_N с растущим номером; Commit псевдотранзакции — это RELEASE SAVEPOINT, Rollback — ROLLBACK TO SAVEPOINT. Поведение для кода то же: ошибка внутреннего колбэка откатывает его часть и возвращается наверх, внешняя транзакция жива и может продолжиться. Это верно и для настоящей SQL-ошибки: после неё транзакция сидит в аварийном состоянии из тринадцатого урока, и снимает его именно ROLLBACK TO SAVEPOINT — в Postgres это единственный способ продолжить транзакцию после реального сбоя, не стирая уже годную работу; полный Rollback честнее, но стирает всё подряд. Классическое применение — батч независимых шагов: третий из пяти упал, откатили его, четыре остальных доехали; деталей больше, чем пользы, но как дешёвый частичный откат в групповых операциях savepoints честно работают. Есть и цена: каждый savepoint — лишняя поездка команды на сервер, и злоупотреблять вложенностью на горячем пути не стоит. Одна оговорка к истории с аварийным состоянием: продолжить можно только работу после отката к savepoint, а сама ошибка всё равно обязана доехать до вызывающего — молча проглотить её и делать вид, что ничего не случилось, — путь к данным, о которых никто не спрашивал.
Что останется в базе?
Прочитайте код и выберите, что он выведет:
err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
if err := repo.Create(ctx, tx, order); err != nil {
return err
}
return pgx.BeginFunc(ctx, tx, func(sp pgx.Tx) error {
if err := repo.Create(ctx, sp, auditRow); err != nil {
return err
}
return errors.New("audit failed validation")
})
})
fmt.Println(err != nil)Три способа провести транзакцию#
| Способ | Кто держит каркас | Когда выбирать |
|---|---|---|
| Руками: Begin, defer Rollback, Commit | Каждая операция | Разовый случай, изучение механики |
| pgx.BeginFunc / BeginTxFunc | Библиотека | Дефолт: каркас без политики |
| Свой WithTx | Ваш проект | Дедлайны, метрики, ретраи, тесты |
Правила обёртки одним списком#
- Начинайте с pgx.BeginFunc: контракт уже продуман и проверен
- fn возвращает nil только когда всё действительно готово к Commit
- Внутри колбэка — только tx; пул в замыкании — баг, а не стиль
- Своя WithTx — ради политики: дедлайн, метрики, ретраи
- Репозитории — под узкий интерфейс Querier, а не под pgx.Tx
- tx передаётся явно параметром, контекст для этого — скрытая связь
- Вложенный BeginFunc на tx — savepoint: частичный откат, не новая транзакция
- BeginTxFunc — когда нужен уровень изоляции или pgx.ReadOnly
Проверка: Querier
Почему *pgxpool.Pool можно передать в метод, ожидающий интерфейс Querier из урока?
Соберите вызов обёртки
Восстановите сигнатуру BeginFunc и безопасный откат в своей обёртке.
Соберите WithTx
Расставьте строки собственной обёртки в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: паттерн «с транзакцией»
С тем же каркасом вы столкнётесь в sqlc — его сгенерированный Queries принимает Querier-подобный DBTX, в ent и GORM свой tx-хелпер устроен одинаково: транзакция — колбэк. Понимая BeginFunc, вы читаете все эти обёртки без документации.
Живые проверки — локально: песочница урока покажет тематическую программу на чистом Go, а WithTx гоняйте на Postgres из первого урока — таблица accounts из тринадцатого урока подойдёт. Дальше по курсу: в пятнадцатом — контекст и таймауты, включая то, что BeginFunc не добавляет дедлайнов сам; в восемнадцатом — Batch внутри транзакции, когда шагов становится десятки; в двадцать третьем — тесты обёртки с настоящей базой в Docker. Домашнее задание в конце урока сводит всё: переписать перевод денег на BeginFunc, завернуть в свой WithTx с метрикой и уронить середину — база должна остаться нетронутой.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- pgx.BeginFunc(ctx, db, fn) ведёт каркас сам: Begin, Rollback при ошибке fn, Commit при nil.
- db — всё с методом Begin(ctx) (pgx.Tx, error): пул, соединение, другая транзакция.
- Контекст обёртки служит BEGIN, COMMIT и ROLLBACK, но не ставит дедлайн на fn.
- Свой WithTx оправдан политикой: дедлайн, метрики, ретраи, тестируемость.
- Репозитории принимают узкий Querier — его реализуют и пул, и pgx.Tx.
- tx передаётся явно; пул внутри колбэка уводит запросы мимо транзакции.
- Вложенный BeginFunc на tx — savepoint: частичный откат живой внешней транзакции.
- Ошибка самого Commit доходит наверх как есть — обёртка глотает только ErrTxClosed отложенного отката.
Домашнее задание
Перепишите перевод денег из тринадцатого урока на pgx.BeginFunc, затем на собственный WithTx с дедлайном и счётчиком откатов. Добавьте OrderRepo с методом Create под Querier и вызовите его дважды: с пулом и внутри транзакции. Уроните операцию посередине — ошибкой второго запроса — и убедитесь, что ни одна строка не записана. В конце заверните внутренний шаг в BeginFunc на tx с намеренной ошибкой и проверьте, что внешняя операция может обработать отказ шага и продолжиться.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему не прятать tx в context.Context?
Так делают, и это работает, но связь становится невидимой: сигнатура репозитория не говорит, что метод ведёт себя иначе внутри транзакции, а забытый set/get ключа ловится только тестами. Явный параметр дороже в написании на одну строку и дешевле в отладке; правило простое — транзакция влияет на поведение, значит, она в сигнатуре.
BeginFunc делает COMMIT в отдельной горутине или ждёт?
Ждёт: и Rollback, и Commit выполняются синхронно в той же горутине, что и вызов обёртки, — это видно в исходниках: beginFuncExec завершается возвратом tx.Commit(ctx). Асинхронность здесь была бы вредна: вызывающий обязан знать результат коммита до продолжения работы.
Можно вернуть из fn свою ошибку-статус для HTTP?
Да, и это нормальный путь: обёртка откатит транзакцию и вернёт вашу ошибку наверх без изменений — навесьте на неё тип или код, как удобно слою транспорта. Единственное предостережение: sentinel-ошибки вроде «нет средств» не должны маскироваться под ошибки соединения — их разворачивают через errors.Is, как в шестнадцатом уроке. Ролей у обёртки ровно две — механика и прозрачность ошибок; как только появляется третья, это повод вынести её из колбэка.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup