Go: SQL

Теория: Транзакции

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

Начало транзакции: Begin и BeginTx

В Go работа с транзакциями начинается с вызова Begin() или BeginTx(). Оба метода создают объект *sql.Tx и переводят соединение в режим транзакции. Разница в том, что BeginTx() принимает context.Context и структуру sql.TxOptions. Через эти параметры задают уровень изоляции и признак только для чтения. Такой вариант используют чаще, потому что он позволяет привязать транзакцию к сроку жизни запроса и аккуратно прервать её по таймауту или отмене контекста.

Объект *sql.Tx ведёт себя похоже на *sql.DB, но внутри использует одно конкретное соединение и одну конкретную транзакцию. Все вызовы ExecContext(), QueryContext() и QueryRowContext() через tx выполняются в рамках этого единственного соединения. Это важно и для целостности данных, и для блокировок: база видит операции как часть одной логической последовательности, а не набор разрозненных запросов из пула.

Пример перевода денег

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

ctx := context.Background()

tx, err := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelReadCommitted,
})
if err != nil {
	log.Fatal("begin:", err)
}
defer tx.Rollback() // защитная сетка: откат, если что-то пойдёт не так

// списываем со счёта A
_, err = tx.ExecContext(ctx, `
	UPDATE accounts
	   SET balance = balance - $1
	 WHERE id = $2
`, 100, 1)
if err != nil {
	log.Println("ошибка списания:", err)
	return
}

// зачисляем на счёт B
_, err = tx.ExecContext(ctx, `
	UPDATE accounts
	   SET balance = balance + $1
	 WHERE id = $2
`, 100, 2)
if err != nil {
	log.Println("ошибка зачисления:", err)
	return
}

// фиксируем изменения
if err := tx.Commit(); err != nil {
	log.Println("ошибка коммита:", err)
	return
}

fmt.Println("перевод успешно выполнен")

Здесь код один раз вызывает BeginTx() и получает tx. Сразу после этого ставится defer tx.Rollback(). Этот вызов работает как страховка: если дальше случится ошибка или паника, транзакция откатится автоматически, а соединение вернётся в пул. Когда код дойдёт до успешного Commit(), транзакция завершится, и Rollback уже не сделает ничего лишнего.

Все запросы к базе внутри транзакции идут не через db, а через tx. Это важная дисциплина: как только код стартовал транзакцию, он переключается на tx и работает только с ним до конца. Такой подход не даёт случайно выполнить часть операций мимо транзакции и нарушить атомарность.

Транзакция и пул соединений

Активная транзакция удерживает соединение из пула до момента Commit() или Rollback(). Если забыть вызвать один из этих методов, соединение останется занятым, пул начнёт исчерпываться, а новые запросы будут ждать свободное соединение или получать ошибки по таймауту. Это одна из типичных причин, почему сервис «зависает» при нагрузке. Поэтому шаблон с BeginTx(), защитным defer tx.Rollback() и обязательным tx.Commit() в конце — базовое правило безопасной работы с базой.

Транзакции только для чтения

Иногда транзакция используется только для чтения, например для сложного набора проверок, где важно видеть данные в одном консистентном состоянии. В таких случаях в TxOptions задают флаг ReadOnly: true. Это подсказка СУБД: подобные транзакции не меняют данные, их можно оптимизировать и не держать лишние блокировки.

tx, err := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelReadCommitted,
	ReadOnly:  true,
})
if err != nil {
	return fmt.Errorf("begin readonly tx: %w", err)
}
defer tx.Rollback()

var total int
if err := tx.QueryRowContext(ctx,
	`SELECT COUNT(*) FROM orders WHERE status = 'pending'`,
).Scan(&total); err != nil {
	return fmt.Errorf("SELECT pending orders: %w", err)
}

В таком режиме транзакция даёт консистентное чтение, а база тратит меньше ресурсов на блокировки, чем в обычных модифицирующих операциях.

Уровни изоляции

Уровень изоляции задаёт, как одна транзакция видит изменения других транзакций. Значения вроде sql.LevelReadCommitted, sql.LevelRepeatableRead и sql.LevelSerializable определяют, какие аномалии допустимы: можно ли читать незакоммиченные данные, изменятся ли строки между двумя чтениями, какие конфликты блокировок возможны. На практике для PostgreSQL и MySQL чаще всего хватает LevelReadCommitted. Этот уровень запрещает чтение незакоммиченных изменений и даёт разумный баланс между безопасностью и производительностью.

Более строгие уровни увеличивают предсказуемость поведения, но могут замедлять систему. Например, уровень RepeatableRead удерживает стабильное состояние строк между двумя чтениями внутри одной транзакции.

tx, err := db.BeginTx(ctx, &sql.TxOptions{
	Isolation: sql.LevelRepeatableRead,
})
if err != nil {
	log.Fatal(err)
}
defer tx.Rollback()

var before int
tx.QueryRowContext(ctx, `
	SELECT balance FROM accounts WHERE id = 1
`).Scan(&before)

// параллельный процесс обновляет эту же строку

var after int
tx.QueryRowContext(ctx, `
	SELECT balance FROM accounts WHERE id = 1
`).Scan(&after)

fmt.Println(before, after) // значения совпадают

В этом примере оба чтения видят одну и ту же версию строки, даже если параллельная транзакция успела изменить баланс.

Коммит и откат: чем всё заканчивается

У любой транзакции есть только два исхода. Либо база принимает все изменения и навсегда записывает их в хранилище. Либо база отменяет все изменения, сделанные с момента BeginTx(). За эти два сценария в Go отвечают методы Commit() и Rollback(). От того, как с ними обращается код, зависит состояние данных и здоровье пула соединений.

Commit() сообщает СУБД, что все операции внутри транзакции завершились успешно, и теперь их можно сделать видимыми для остальных. После удачного Commit()транзакция считается закрытой, объект tx больше не подходит для запросов, а соединение освобождается и возвращается в пул. Если Commit() возвращает ошибку, значит база не смогла зафиксировать изменения: время ожидания вышло, произошёл конфликт блокировок, соединение оборвалось или возникла другая внутренняя проблема. В таком случае данных в промежуточном состоянии не остаётся — база откатывает транзакцию целиком.

Rollback() делает противоположное. Этот метод отменяет все изменения, выполненные в рамках транзакции, и также освобождает соединение. Его удобно вызывать через defer сразу после BeginTx(), потому что повторный Rollback() не нанесёт вреда. Если транзакция уже закоммичена, драйвер просто проигнорирует этот вызов. Благодаря этому при любом раннем выходе из функции — из-за ошибки, паники или проверочного условия — не нужно думать, откатили транзакцию или нет, за это отвечает отложенный вызов.

Пример транзакции с ошибкой

Рассмотрим пример, где в середине транзакции возникает ошибка в SQL.

ctx := context.Background()

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	log.Fatal("begin:", err)
}
defer tx.Rollback() // защитная сетка

// списание со счёта
_, err = tx.ExecContext(ctx, `
	UPDATE accounts
	   SET balance = balance - $1
	 WHERE id = $2
`, 100, 1)
if err != nil {
	log.Println("ошибка списания:", err)
	return
}

// зачисление, но с опечаткой в SQL
_, err = tx.ExecContext(ctx, `
	UPDAT accounts
	   SET balance = balance + $1
	 WHERE id = $2
`, 100, 2)
if err != nil {
	log.Println("ошибка зачисления:", err)
	return
}

// до этого места код уже не дойдёт
if err := tx.Commit(); err != nil {
	log.Println("ошибка коммита:", err)
	return
}

fmt.Println("транзакция завершена успешно")

Во второй команде допущена опечатка в ключевом слове UPDATE, база вернёт ошибку, а функция завершится раньше, чем дойдёт до Commit(). При выходе из функции сработает defer tx.Rollback(), и все изменения, включая успешное списание, откатятся. Баланс обоих счетов останется таким же, как до начала операции. Так транзакция реализует атомарность: либо перевели деньги полностью, либо не трогали баланс вообще.

После первой серьёзной ошибки продолжать выполнять запросы через тот же tx нельзя. Многие СУБД, включая PostgreSQL, переводят транзакцию в специальное состояние: любые последующие команды через этот tx получают сообщение, что транзакция прервана и команды игнорируются до Rollback(). Попытка «довыполнить» часть логики или сделать Commit() после серьёзной ошибки не имеет смысл�� — всё равно понадобится откат.

Контекст и завершение транзакции

Контекст влияет и на завершение транзакции. Если в середине работы истёк дедлайн контекста или он был отменён, драйвер перестанет отправлять запросы и в конце тоже не сможет успешно закоммитить изменения. В таких ситуациях задача кода — корректно обработать ошибку и не пытаться продолжать работу в рамках уже несостоявшейся транзакции. Драйвер и СУБД позаботятся о том, чтобы соединение и транзакция не зависли, но бизнес-логика должна правильно отреагировать на срыв операции.

Работа с ошибками внутри транзакций

Ошибки внутри транзакции — обычная ситуация. Не прошла валидация, нарушено ограничение уникальности, не нашлись нужные данные, сработал таймаут. Главное — выстроить такой шаблон, при котором любая ошибка ведёт к аккуратному откату и освобождению ресурсов. В Go это остаётся на стороне разработчика: пакет database/sql не откатывает транзакцию автоматически.

Надёжный подход строится вокруг простого правила. Сразу после BeginTx() ставится defer tx.Rollback(). Любая ошибка в теле функции приводит к return, а отложенный Rollback() отменяет все изменения. Если до конца функции код успешно добрался и выполнил Commit(), второй Rollback() не повредит. Такой шаблон совмещает оба сценария: при успехе транзакция фиксируется, при ошибке — откатывается, а соединение никогда не зависает.

Пример функции с транзакцией

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

func Transfer(ctx context.Context, db *sql.DB, fromID, toID, amount int) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback() // откат по умолчанию

	// уменьшаем баланс отправителя
	if _, err := tx.ExecContext(ctx, `
		UPDATE accounts
		   SET balance = balance - $1
		 WHERE id = $2
	`, amount, fromID); err != nil {
		return fmt.Errorf("debit: %w", err)
	}

	// проверяем баланс после списания
	var balance int
	if err := tx.QueryRowContext(ctx, `
		SELECT balance
		  FROM accounts
		 WHERE id = $1
	`, fromID).Scan(&balance); err != nil {
		return fmt.Errorf("check balance: %w", err)
	}
	if balance < 0 {
		return fmt.Errorf("insufficient funds")
	}

	// увеличиваем баланс получателя
	if _, err := tx.ExecContext(ctx, `
		UPDATE accounts
		   SET balance = balance + $1
		 WHERE id = $2
	`, amount, toID); err != nil {
		return fmt.Errorf("credit: %w", err)
	}

	// пытаемся зафиксировать изменения
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit: %w", err)
	}

	return nil
}

В этой функции каждая точка выхода проходит через Rollback(). Если какой-то запрос вернул ошибку, если проверка логики не прошла, если контекст отменился, функция вернёт ошибку, а отложенный Rollback() освободит соединение и отменит все изменения. Если код дошёл до Commit и он завершился успешно, транзакция зафиксирована, а повторный Rollback() уже ничего не изменит.

Важно не продолжать выполнять запросы после первой серьёзной ошибки. СУБД уже перевела транзакцию в состояние «ошибки» и будет отвергать дополнительные команды. Попытка сделать Commit в таком состоянии тоже закончится ошибкой. Правильная тактика — как можно раньше выйти из функции и позволить Rollback() сделать своё дело.

Типы ошибок и ресурсы внутри транзакции

Иногда внутри транзакции нужно различать типы ошибок. Если запрос вернул sql.ErrNoRows, это не сбой базы, а сигнал, что данные не найдены. В таких случаях код может превратить это в понятную бизнес-ошибку и так же выйти, чтобы откатить транзакцию. Если ошибка другая — сетевой сбой, проблема парсинга, нарушение ограничений, её оборачивают и передают наверх, не пытаясь продолжать выполнение.

Если внутри транзакции используются методы QueryContext, важно закрывать полученный объект rows даже при ошибках обработки. Закрытие rows — это не только освобождение памяти, но и сигнал драйверу, что соединение можно снова использовать. Удобный приём — поставить defer rows.Close() сразу после успешного запроса.

Шаблон withTx

Удобный приём — вынести шаблон работы с транзакцией в отдельную функцию-обёртку и передавать внутрь логику как колбэк. Тогда код, который описывает бизнес-операции, вообще не заботится о Begin(), Commit() и Rollback(), а сосредотачивается только на нужных запросах.

func withTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	if err := fn(tx); err != nil {
		return err // при ошибке откат сработает автоматически
	}

	return tx.Commit()
}

После этого любая операция, в которой есть несколько связанных запросов, может выглядеть компактно и безопасно.

err := withTx(ctx, db, func(tx *sql.Tx) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE accounts
		   SET balance = balance - 100
		 WHERE id = 1
	`)
	if err != nil {
		return err
	}

	_, err = tx.ExecContext(ctx, `
		UPDATE accounts
		   SET balance = balance + 100
		 WHERE id = 2
	`)
	return err
})

Иногда в проекте используют другой приём: активную транзакцию (*sql.Tx) кладут в контекст и передают этот контекст дальше — в репозитории и сервисы. Тогда любая функция нижнего уровня может достать из контекста либо текущую транзакцию, либо, если транзакции нет, обычный *sql.DB. Получается единый интерфейс работы с базой: код одинаково работает и внутри транзакции, и вне её.

Чтобы всё выглядело аккуратно, поверх этого обычно пишут небольшую обёртку: отдельный пакет, который умеет класть транзакцию в контекст и доставать её оттуда. В итоге в репозитории используется что-то вроде:

db.GetTxFromContext(ctx)

Такой подход удобен, но требует договорённости: все части проекта должны одинаково читать и использовать контекст. Только тогда система ведёт себя предсказуемо, а транзакции действительно становятся «прозрачными» для остального кода.

Подведём итоги

Любая ошибка внутри транзакции должна означать откат. Rollback() по умолчанию стоит сразу после BeginTx(). После первой ошибки tx больше не используют для новых запросов. Commit() вызывается только в том случае, если все операции прошли успешно. При таком шаблоне транзакции не зависают, соединения не утекут, а данные останутся согласованными даже при сложных сценариях и неожиданных сбоях.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845