---
title: "Транзакции: Begin, Commit и Rollback"
description: "Транзакции в pgx: Begin, Commit и Rollback, безопасный defer Rollback, ErrTxClosed и ErrTxCommitRollback, аварийное состояние и атомарный перевод денег."
course: "PostgreSQL в Go: драйвер pgx с нуля до продакшена"
url: "https://golangmaster.ru/courses/pgx/lessons/13-tranzakcii/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

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

*Курс «PostgreSQL в Go: драйвер pgx с нуля до продакшена», урок 13 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/pgx/lessons/13-tranzakcii/*

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

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

- nil — повторный откат безвреден и тих
- ✅ pgx.ErrTxClosed — транзакция уже закрыта
- Панику: транзакция не существует
- Ошибку ErrTxCommitRollback

Rollback после Commit возвращает ErrTxClosed и больше ничего не делает — потому defer Rollback после Begin считается каноническим каркасом: при успехе он безвреден, при любом раннем выходе закрывает транзакцию и освобождает коннект.

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

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

### Что напечатает фрагмент?

```go
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)
```

**Что выведет программа?**

- <nil> и <nil> — оба вызова прошли
- ошибка aborted-транзакции и <nil>
- ✅ ошибка aborted-транзакции и ErrTxCommitRollback
- паника на втором Exec

INSERT дубликата уронил транзакцию в аварийное состояние: второй Exec получает current transaction is aborted. Commit на прерванной транзакции сервер выполняет как ROLLBACK, и pgx честно докладывает ErrTxCommitRollback — сигнал, что наверху потеряли ошибку. Деньги со счёта не спишутся: откат стирает всё.

### Событие в транзакции → результат

| Событие | Результат | Что делать коду |
| --- | --- | --- |
| 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 между кейсами; это, впрочем, тема двадцать третьего урока.

> **Живое место: транзакция вокруг HTTP-вызова.** Типичная ошибка: открыть 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 и тяжёлые вычисления снаружи
- Одиночная команда в транзакции не нуждается

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

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

```go
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)
```

Подсказка: Транзакция открывается Begin, страхуется defer Rollback, команды идут через tx.Exec, подтверждает Commit.

Правильные ответы в пропусках: `Begin`, `Rollback`, `Exec`, `Commit`

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

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

Правильный порядок строк:

1. `func transfer(ctx context.Context, pool *pgxpool.Pool, fromID, toID int, amount int64) error {`
2. `  tx, err := pool.Begin(ctx)`
3. `  if err != nil { return err }`
4. `  defer tx.Rollback()`
5. `  _, err = tx.Exec(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID)`
6. `  if err != nil { return fmt.Errorf("списать: %w", err) }`
7. `  _, err = tx.Exec(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID)`
8. `  if err != nil { return fmt.Errorf("начислить: %w", err) }`
9. `  return tx.Commit(ctx)`
10. `}`

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

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

- Подвисание до Commit: коннект занят
- ✅ Свой свободный коннект из пула — запрос пройдёт
- Ошибку tx is closed
- Тот же tx.Exec — команда попадёт в транзакцию

Пул выдаёт свободные соединения: открытая транзакция заняла один коннект, параллельный Exec возьмёт другой и пройдёт независимо — он даже не увидит незакоммиченные изменения транзакции. Застрять горутина может только при исчерпанном пуле.

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

- **pool.Begin(ctx)** — Берёт коннект из пула и открывает BEGIN. Возвращает pgx.Tx с Exec, Query, QueryRow, Commit и Rollback.
- **pgx.Tx** — Интерфейс транзакции: те же глаголы, что у пула, прибитые к одному коннекту, плюс Commit и Rollback.
- **defer tx.Rollback()** — Каноническая страховка: после Commit возвращает ErrTxClosed безвредно, при любом раннем выходе закрывает транзакцию.
- **ErrTxClosed** — Ответ на Commit или Rollback уже закрытой транзакции. В defer — норма; в логике — признак двойного закрытия.
- **ErrTxCommitRollback** — Commit на прерванной транзакции сервер выполняет как ROLLBACK. Значит: выше по коду проглочена ошибка.
- **current transaction is aborted** — Ответ базы на команды после ошибки внутри транзакции: до Rollback всё отклоняется, повторы бессмысленны.

> **Обёртка, которая пишет этот каркас за вас.** Четыре строки каркаса повторяются в каждом сервисе сотни раз, и pgx предлагает готовую функцию pgx.BeginFunc(ctx, pool, fn): она откроет транзакцию, откатит при ошибке, закоммитит при успехе. Каркас этого урока нужен, чтобы понимать, что происходит внутри и когда стандартная обёртка не подходит, — разберём её построчно в четырнадцатом уроке.

Формат курса прежний: песочница урока покажет тематическую программу на чистом Go, а транзакции гоняйте локально — Go и Postgres из первого урока; таблицу accounts создайте двумя INSERT. Дальше по курсу: в [четырнадцатом](/courses/pgx/lessons/14-tx-pattern/) — паттерн «с транзакцией», где defer Rollback спрятан в обёртку и не забывается никогда; в [пятнадцатом](/courses/pgx/lessons/15-kontekst-tajmauty/) — что контекст делает и чего не делает с открытой транзакцией; в [семнадцатом](/courses/pgx/lessons/17-unikalnost-23505/) — как рулить конфликтом уникальности, самой частой ошибкой внутри транзакций. Проверьте себя перед движением дальше: напишите перевод с намеренной ошибкой во втором UPDATE и убедитесь, что после отката оба баланса целы — это и есть обещание атомарности в руках. И держите в голове меру: транзакция — не про безопасность вообще, а про согласованность конкретной группы операций; там, где группа не нужна, честнее один Exec из пятого урока, чем транзакция ради галочки.

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

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

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