Содержание урока · 10 разделов
- Begin: транзакция занимает коннект
- Commit и Rollback: два выхода из транзакции
- Откат при ошибке: defer Rollback и ErrTxClosed
- Аварийное состояние: COMMIT как ROLLBACK
- Событие в транзакции → результат
- Что делает транзакцию транзакцией
- Каркас транзакции в pgx одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Перевод денег — история, с которой обычно и начинается работа с транзакциями: списать сто рублей с одного счёта, начислить их другому. Между двумя UPDATE место есть всему — падению сети, панике, гонке другого сервиса, — и без транзакции половина шага остаётся выполненной: деньги списаны, но не пришли. Транзакция — обещание базы: либо обе операции пройдут вместе, либо ни одна. В pgx это три вызова поверх знакомых глаголов из пятого урока: Begin открывает транзакцию и возвращает объект pgx.Tx, у которого те же Exec, Query и QueryRow, что у пула, плюс Commit и Rollback. Разница между p.Exec и tx.Exec не в синтаксисе, а в том, куда попадает команда: пул берёт любой свободный коннект, транзакция прибита к одному конкретному — и это следствие устройства самой базы, а не драйвера: внутри одной транзакции все команды обязаны видеть одни и те же изменения. Забегая вперёд, скажу, что этот же объект tx умеет открывать вложенные уровни через savepoints — об этом честно расскажет четырнадцатый урок, а сегодня достаточно обычного перевода. Урок идёт по маршруту: Begin и занятие коннекта, два выхода — Commit и Rollback, безопасный defer, аварийное состояние после ошибки посреди транзакции и границы применимости. Всё будет показано на одном примере — переводе между счетами, — чтобы к концу урока каркас узнался бы с одного взгляда, как знакомое лицо.
Begin: транзакция занимает коннект#
pool.Begin(ctx) делает две вещи: берёт соединение из пула из четвёртого урока и отправляет на него BEGIN. Возвращаемый pgx.Tx — интерфейс с привычным набором Exec, Query, QueryRow, SendBatch, CopyFrom и парой Commit/Rollback; любые запросы через tx идут ровно на тот коннект, что был занят. Пока транзакция открыта, этот коннект не обслуживает никого больше: параллельная горутина, вызвавшая p.Exec, попадёт на другой коннект и не увидит незакоммиченные изменения — так и задумано. Отсюда два практических вывода. Первый: транзакция — дорогой ресурс, держать её открытой на время долгих внешних вызовов, HTTP-запроса к платёжному шлюзу или медленного отчёта — способ исчерпать пул и уронить весь сервис; база ещё и держит блокировки дольше, чем нужно. Второй вывод — про контекст: в отличие от database/sql, отмена контекста в pgx не откатывает транзакцию автоматически, ctx в Begin влияет только на саму команду BEGIN — это подводный камень пятнадцатого урока, здесь просто держите в голове. Если Begin не удался, коннект возвращается пулу, и tx вы не получите — проверка err стоит сразу после вызова. Кстати, у пула есть и BeginTx с pgx.TxOptions — уровнем изоляции и режимом read only; для повседневных операций пустые опции Begin достаточны, а изоляция — осознанное исключение, к которому вернёмся, когда встретим отчёты с согласованным срезом данных.
Перевод денег без щелей
GOpackage main
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgxpool"
)
func transfer(ctx context.Context, pool *pgxpool.Pool,
fromID, toID int, amount int64) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
_, 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 tx.Commit(ctx)
}
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx,
"postgres://app:secret@localhost:5432/app")
if err != nil {
log.Fatal(err)
}
defer pool.Close()
if err := transfer(ctx, pool, 1, 2, 100); err != nil {
log.Fatal(err)
}
fmt.Println("перевод прошёл")
}Функция открывает транзакцию, делает оба UPDATE через tx и закрывает её Commit. Любой ранний return по ошибке выходит без Commit — и это ещё не весь контракт: без отката при выходе незакрытая транзакция держит коннект занятым. В продакшене такой код дополняют defer tx.Rollback(), о котором ниже; здесь он опущен, чтобы было видно, что именно надо дописать.
Commit и Rollback: два выхода из транзакции#
У транзакции ровно два финала. Commit(ctx) подтверждает: сервер записывает изменения на диск, остальные соединения начинают их видеть, коннект возвращается пулу. Rollback(ctx) стирает: база возвращается в состояние до BEGIN, коннект тоже освобождается. Оба вызова — обычные SQL-команды COMMIT и ROLLBACK под капотом, обе принимают контекст, и обе возвращают ошибку, которую нельзя выбрасывать: сбой Commit — это возможно потерянные изменения, о которых код думает, что они записаны. Практика, которую держат все зрелые кодовые базы: после каждой транзакции проверять и ошибку Commit — если перевод не закоммитился, пользователь должен увидеть ошибку, а не зелёную галочку. Второй практический момент — pgxpool.Tx, обёртка над pgx.Tx, которую возвращает пул: и Commit, и Rollback в ней освобождают коннект обратно в пул, поэтому закрыть транзакцию надо в любом сценарии, даже если все запросы упали. В исходниках pgxpool у этого правила точное обоснование: Release вызывается до возврата ошибки — коннект освобождается и при неудачном Commit, и при неудачном Rollback; двойное закрытие безопасно, а утечка случается ровно в одном случае — когда ни один из двух вызовов не состоялся. Забытая открытая транзакция не закроется сама — и это хуже, чем кажется: коннект возвращается в пул только из Commit или Rollback, без них он застревает занятым навсегда, и Stat() покажет AcquiredConns, который не падает до перезапуска. Сервер откатит висящую транзакцию лишь при разрыве соединения — например, при закрытии пула, — но строить на этом логику нельзя. И про видимость на границе: пока Commit не вернулся, другие соединения продолжают видеть старые данные — читатели не блокируются, но и новых изменений не видят; это спокойствие, за которое транзакции и любят.
Откат при ошибке: defer Rollback и ErrTxClosed#
Канонический каркас транзакции умещается в четыре строки паттерна: tx, err := pool.Begin(ctx); если ошибка — выходим; defer tx.Rollback(); дальше работа. Зачем defer, если при успехе мы и так зовём Commit? Потому что Rollback после Commit — легальная операция: она вернёт ErrTxClosed и ничего не сделает, документ pgx прямо разрешает такой defer даже при успешном Commit. Смысл — страховка от каждого раннего выхода: любое return err между Begin и Commit вызывает отложенный Rollback, транзакция закрывается, коннект освобождается, база не копит висящих блокировок. Без этого defer код с шестью ветками ошибок требует шесть чисток — и однажды седьмая забудется. Проверка ошибки самого defer тоже соглашение: if err := tx.Rollback(); err != nil && !errors.Is(err, ErrTxClosed) — обработать; ErrTxClosed здесь означает лишь, что транзакция уже закрыта Commit-ом, и это норма. Обратный случай — ошибка на живой транзакции — редкий, но громкий: соединение умерло между запросом и откатом; такая ошибка имеет право испортить статистику или попасть в лог, но не должна изменять поведение бизнес-логики — транзакция всё равно закрыта. Готовые обёртки, которые прячут этот каркас целиком, — тема следующего урока: в pgx есть pgx.BeginFunc, и после этого урока вы будете понимать, что именно она делает за вас.
Проверка: defer Rollback
tx.Commit() прошёл успешно, затем отрабатывает defer tx.Rollback(). Что вернёт Rollback?
Аварийное состояние и честный откат
GOtx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount, fromID); err != nil {
return fmt.Errorf("списать: %w", err)
}
if _, err = tx.Exec(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount, toID); err != nil {
return fmt.Errorf("начислить: %w", err)
}
return tx.Commit(ctx)Тот же перевод в боевом каркасе: defer Rollback страхует оба ранних выхода, а обёрнутые ошибки говорят, какой именно шаг упал. Если дубликат уронит второй UPDATE, defer откатит первый — баланс останется исходным, и вызывающий код получит одну ошибку с контекстом вместо половины выполненной операции.
Аварийное состояние: COMMIT как ROLLBACK#
Ошибка посреди транзакции — не любой сбой, а особый режим базы. Если один из UPDATE внутри транзакции упал — нарушение уникальности, деление, дедлок, — Postgres объявляет транзакцию прерванной: все следующие команды до конца блока отклоняются с ошибкой current transaction is aborted, commands ignored until end of transaction block, сколько бы раз их ни звали (код SQLSTATE — 25P02, пригодится, когда в шестнадцатом уроке ошибки разберут по полям). Это защитная логика: база не даст вам закрыть глаза на полусделанную работу. Выход один — Rollback, который вернёт базу в состояние до BEGIN и снимет аварийный режим. Забавный сговорчивый случай: если позвать Commit на прерванной транзакции, Postgres его примет, но выполнит как ROLLBACK — данные не запишутся. pgx превращает это в явную ошибку ErrTxCommitRollback с текстом commit unexpectedly resulted in rollback: она означает, что где-то выше по коду ошибка была проглочена, а вы собрались коммитить поверх неё — проверяйте err каждого tx-вызова, аварийное состояние редко приходит неожиданно. Отсюда правило: внутри транзакции не бывает «незначительных» ошибок — каждая либо обрабатывается с откатом, либо всплывает наверх.
Что напечатает фрагмент?
Прочитайте код и выберите, что он выведет:
tx, err := pool.Begin(ctx)
if err != nil {
log.Fatal(err)
}
defer tx.Rollback()
_, err = tx.Exec(ctx,
"UPDATE accounts SET balance = balance - 100 WHERE id = 1")
if err != nil {
log.Fatal(err)
}
_, err = tx.Exec(ctx, "INSERT INTO accounts (id) VALUES (1)")
fmt.Println(err)
err = tx.Commit(ctx)
fmt.Println(err)Событие в транзакции → результат#
| Событие | Результат | Что делать коду |
|---|---|---|
| tx.Exec упал | Транзакция в аварийном состоянии | Rollback и обработать ошибку |
| Exec после ошибки | current transaction is aborted | Не повторять — сначала Rollback |
| Commit после ошибки | ErrTxCommitRollback: коммит как откат | Искать проглоченную ошибку выше |
| Rollback после Commit | ErrTxClosed, безвреден | Так и задумано для defer |
| tx.Exec после Commit | ErrTxClosed | Ошибка каркаса: закрывать позже |
| Пул Begin, всё ок | Коннект занят до Commit/Rollback | Не держать транзакцию на долгие вызовы |
Что делает транзакцию транзакцией#
За тремя глаголами стоит терминология ACID, и на уровне pgx достаточно трёх её букв. Атомарность — уже знакомое «всё или ничего»: два UPDATE либо оба записаны, либо оба забыты. Изоляция — параллельные транзакции не видят чужих полусделанных изменений: по умолчанию Postgres работает в режиме read committed, где каждая команда видит только закоммиченное, — для большинства сервисов этого хватает, а уровни вроде serializable меняют поведение под нагрузкой и заслуживают отдельного чтения. Долговечность — после Commit данные переживают падение сервера: журнал упреждающей записи гарантирует это до вашего возврата из Commit. Атомарность и есть то, зачем транзакции ставят в код: два UPDATE — классика, но не единственный случай. Полезно вешать транзакцию на любую группу, где логика ломается наполовину: вставка заказа вместе с пунктами, списание лимита вместе с записью об использовании, переезд данных между таблицами при миграции. Обратное тоже важно: одиночный Exec атомарен сам по себе — оборачивать одну команду в транзакцию значит только занять коннект дольше и ничего не выиграть. Практический бонус транзакций, о котором часто вспоминают позже: в тестах они работают как undo — открыли, нагенерировали данных, откатили — и таблицы чисты без TRUNCATE между кейсами; это, впрочем, тема двадцать третьего урока.
Типичная ошибка: открыть Begin, внутри сделать запрос к платёжному шлюзу, по ответу — UPDATE и Commit. Транзакция живёт секунды, коннект занят, блокировки на строке счёта держатся всё время ожидания — десяток таких параллельных вызовов выедают пул, а шлюз с таймаутом в тридцать секунд кладёт сервис целиком. Что сломалось бы: любой всплеск трафика к оплате. Правильный каркас: внутри транзакции — только база; внешние вызовы — до или после, а согласованность через статусы и повторные попытки. Исключение есть, и оно узкое: резервные транзакции с коротким timeout-зовом, где вы осознанно платите занятым коннектом.
Каркас транзакции в pgx одним списком#
- tx, err := pool.Begin(ctx) — сразу проверьте err
- defer tx.Rollback() — страховка от каждого раннего выхода
- Все запросы группы — через tx.Exec и tx.Query, не через пул
- Ошибку любого tx-вызова не глотать: аварийное состояние ждёт Rollback
- Успех — tx.Commit(ctx) с проверкой возвращённой ошибки
- ErrTxClosed в defer — норма; ErrTxCommitRollback — потерянная ошибка выше
- Внутри транзакции — только база: HTTP и тяжёлые вычисления снаружи
- Одиночная команда в транзакции не нуждается
Соберите каркас транзакции
Восстановите четыре опорные строки: открытие, страховка, работа, подтверждение.
Соберите перевод денег
Расставьте строки функции перевода в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Проверка: занятие коннекта
Транзакция открыта и живёт. Что увидит параллельная горутина, вызвавшая pool.Exec с UPDATE другой таблицы?
Карточки: транзакции
Четыре строки каркаса повторяются в каждом сервисе сотни раз, и pgx предлагает готовую функцию pgx.BeginFunc(ctx, pool, fn): она откроет транзакцию, откатит при ошибке, закоммитит при успехе. Каркас этого урока нужен, чтобы понимать, что происходит внутри и когда стандартная обёртка не подходит, — разберём её построчно в четырнадцатом уроке.
Формат курса прежний: песочница урока покажет тематическую программу на чистом Go, а транзакции гоняйте локально — Go и Postgres из первого урока; таблицу accounts создайте двумя INSERT. Дальше по курсу: в четырнадцатом — паттерн «с транзакцией», где defer Rollback спрятан в обёртку и не забывается никогда; в пятнадцатом — что контекст делает и чего не делает с открытой транзакцией; в семнадцатом — как рулить конфликтом уникальности, самой частой ошибкой внутри транзакций. Проверьте себя перед движением дальше: напишите перевод с намеренной ошибкой во втором UPDATE и убедитесь, что после отката оба баланса целы — это и есть обещание атомарности в руках. И держите в голове меру: транзакция — не про безопасность вообще, а про согласованность конкретной группы операций; там, где группа не нужна, честнее один Exec из пятого урока, чем транзакция ради галочки.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- pool.Begin(ctx) занимает коннект и возвращает pgx.Tx с теми же Exec, Query, QueryRow.
- Commit подтверждает, Rollback стирает; коннект освобождается только одним из двух.
- defer tx.Rollback() после Begin — канон: после Commit безвреден, ранние выходы закрывает.
- Rollback после Commit возвращает ErrTxClosed — это норма, а не ошибка каркаса.
- Ошибка внутри транзакции переводит её в аварийное состояние до Rollback.
- Commit на прерванной транзакции даёт ErrTxCommitRollback — ищите проглоченную ошибку.
- Внутри транзакции — только база; HTTP-вызовы и тяжёлые вычисления — снаружи.
Домашнее задание
Создайте таблицу accounts с двумя строками и напишите функцию перевода из урока: Begin, defer Rollback, два UPDATE, Commit. Проведите два разных сбоя и сравните их. Первый — настоящий SQL-облом во втором UPDATE, например дубликат уникального ключа: убедитесь, что Commit вернул ErrTxCommitRollback, а после отката оба баланса исходные. Второй — сбой уровня приложения: проверьте RowsAffected после UPDATE несуществующего счёта и верните свою ошибку; заметьте, транзакция при этом не аварийная, и если позвать Commit, первый перевод запишется. Разницу запишите в комментарий к функции — это граница между «ошибкой базы» и «ошибкой кода». Добавьте логирование времени жизни транзакции и оцените, что случится с пулом из четвёртого урока при паре сотен параллельных переводов.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли BeginFunc, или каркас из урока — уже идиома?
Каркас с defer Rollback — основа, его пишут руками во многих командах и это нормально. BeginFunc из четырнадцатого урока убирает повторяемость и человеческий фактор: забыть defer, спутать порядок. Выбирать стоит по размеру проекта, но понимать каркас обязан каждый — обёртка из него и состоит.
Что будет с транзакцией при отмене контекста?
В pgx отмена контекста не откатывает транзакцию автоматически: ctx в Begin влияет только на саму команду BEGIN, а дальше отмена рвёт выполнение конкретного запроса. Открывать Commit и Rollback всё равно нужно — нюансы и безопасный каркас разберём в пятнадцатом уроке, посвящённом контекстам и таймаутам.
Всегда ли держать соединение до Commit?
Да, это свойство протокола: одна транзакция живёт на одном коннекте, и другого не бывает. Отсюда и дисциплина: транзакция короткая, без внешних вызовов, с быстрым финалом. Если работа по смыслу длинная — делите на несколько транзакций или переносите тяжелую часть наружу.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Паттерн «с транзакцией»: функция-обёртка
- Контекст и таймауты запросов
- Ошибки pgx: pgconn.PgError
- Ошибка 23505: конфликты уникальности
- Массивы Postgres: []string и pgtype.Array
- JSONB: гибкие данные без миграций
- Типы Postgres: UUID, TIMESTAMPTZ, NUMERIC
- Интерфейс database/sql: pgx через StdLib
- Все курсы по Go
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup