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

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

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

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

Транзакции: Begin, Commit и Rollback

Транзакции в pgx начинаются с Begin: пул отдаёт соединение, на нём открывается pgx.Tx, и всё, что вы дальше исполняете через tx.Exec и tx.Query, подтверждается одним Commit или стирается одним Rollback. Разберём устройство транзакции, безопасный defer Rollback, аварийное состояние после ошибки и перевод денег — эталонную задачу, где без атомарности всё ломается.

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

Перевод денег — история, с которой обычно и начинается работа с транзакциями: списать сто рублей с одного счёта, начислить их другому. Между двумя 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 достаточны, а изоляция — осознанное исключение, к которому вернёмся, когда встретим отчёты с согласованным срезом данных.

Перевод денег без щелей

GO
package 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?

Аварийное состояние и честный откат

GO
tx, 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 после CommitErrTxClosed, безвреденТак и задумано для defer
tx.Exec после CommitErrTxClosedОшибка каркаса: закрывать позже
Пул Begin, всё окКоннект занят до Commit/RollbackНе держать транзакцию на долгие вызовы

Что делает транзакцию транзакцией#

За тремя глаголами стоит терминология ACID, и на уровне pgx достаточно трёх её букв. Атомарность — уже знакомое «всё или ничего»: два UPDATE либо оба записаны, либо оба забыты. Изоляция — параллельные транзакции не видят чужих полусделанных изменений: по умолчанию Postgres работает в режиме read committed, где каждая команда видит только закоммиченное, — для большинства сервисов этого хватает, а уровни вроде serializable меняют поведение под нагрузкой и заслуживают отдельного чтения. Долговечность — после Commit данные переживают падение сервера: журнал упреждающей записи гарантирует это до вашего возврата из Commit. Атомарность и есть то, зачем транзакции ставят в код: два UPDATE — классика, но не единственный случай. Полезно вешать транзакцию на любую группу, где логика ломается наполовину: вставка заказа вместе с пунктами, списание лимита вместе с записью об использовании, переезд данных между таблицами при миграции. Обратное тоже важно: одиночный Exec атомарен сам по себе — оборачивать одну команду в транзакцию значит только занять коннект дольше и ничего не выиграть. Практический бонус транзакций, о котором часто вспоминают позже: в тестах они работают как undo — открыли, нагенерировали данных, откатили — и таблицы чисты без TRUNCATE между кейсами; это, впрочем, тема двадцать третьего урока.

Каркас транзакции в pgx одним списком#

  • tx, err := pool.Begin(ctx) — сразу проверьте err
  • defer tx.Rollback() — страховка от каждого раннего выхода
  • Все запросы группы — через tx.Exec и tx.Query, не через пул
  • Ошибку любого tx-вызова не глотать: аварийное состояние ждёт Rollback
  • Успех — tx.Commit(ctx) с проверкой возвращённой ошибки
  • ErrTxClosed в defer — норма; ErrTxCommitRollback — потерянная ошибка выше
  • Внутри транзакции — только база: HTTP и тяжёлые вычисления снаружи
  • Одиночная команда в транзакции не нуждается

Соберите каркас транзакции

Восстановите четыре опорные строки: открытие, страховка, работа, подтверждение.

tx, err := pool.(ctx) if err != nil { return err } defer tx.() _, err = tx.(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", 100, 1) if err != nil { return err } return tx.(ctx)

Соберите перевод денег

Расставьте строки функции перевода в правильном порядке.

Строки

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

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

Проверка: занятие коннекта

Транзакция открыта и живёт. Что увидит параллельная горутина, вызвавшая pool.Exec с UPDATE другой таблицы?

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

Термин

pool.Begin(ctx)

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

Берёт коннект из пула и открывает BEGIN. Возвращает pgx.Tx с Exec, Query, QueryRow, Commit и Rollback.

1 / 6

Формат курса прежний: песочница урока покажет тематическую программу на чистом 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, первый перевод запишется. Разницу запишите в комментарий к функции — это граница между «ошибкой базы» и «ошибкой кода». Добавьте логирование времени жизни транзакции и оцените, что случится с пулом из четвёртого урока при паре сотен параллельных переводов.

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

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

Нужен ли BeginFunc, или каркас из урока — уже идиома?

Каркас с defer Rollback — основа, его пишут руками во многих командах и это нормально. BeginFunc из четырнадцатого урока убирает повторяемость и человеческий фактор: забыть defer, спутать порядок. Выбирать стоит по размеру проекта, но понимать каркас обязан каждый — обёртка из него и состоит.

Что будет с транзакцией при отмене контекста?

В pgx отмена контекста не откатывает транзакцию автоматически: ctx в Begin влияет только на саму команду BEGIN, а дальше отмена рвёт выполнение конкретного запроса. Открывать Commit и Rollback всё равно нужно — нюансы и безопасный каркас разберём в пятнадцатом уроке, посвящённом контекстам и таймаутам.

Всегда ли держать соединение до Commit?

Да, это свойство протокола: одна транзакция живёт на одном коннекте, и другого не бывает. Отсюда и дисциплина: транзакция короткая, без внешних вызовов, с быстрым финалом. Если работа по смыслу длинная — делите на несколько транзакций или переносите тяжелую часть наружу.

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

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

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

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

MongoDB в Go: официальный драйвер mongo-driver v2

Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.

  • Подключаться к MongoDB и настраивать клиента
  • Моделировать документы структурами и bson-тегами
  • Делать CRUD, сложные фильтры и проекции
  • Строить агрегации $match/$group/$lookup