Go: GORM

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

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

В GORM транзакции живут поверх стандартного механизма базы данных. Под капотом всегда тот же сценарий: BEGIN, набор INSERT/UPDATE/DELETE, а в конце COMMIT или ROLLBACK. Разница в том, как вы этим управляете из кода.

Транзакции через db.Transaction

Самый безопасный и «правильный по умолчанию» способ — обернуть набор операций в db.Transaction(). Вы передаёте функцию, GORM сам открывает транзакцию, передаёт в неё tx *gorm.DB и ждёт результат. Вернули nil — делается COMMIT. Вернули ошибку — ROLLBACK. При панике GORM тоже откатит транзакцию.

Пример: создаём пользователя и его заказ как одну операцию.

type User struct {
	ID   uint
	Name string
}

type Order struct {
	ID     uint
	UserID uint
	Total  int
}

err := db.Transaction(func(tx *gorm.DB) error {
	user := User{Name: "Анна"}

	if err := tx.Create(&user).Error; err != nil {
		return err // любая ошибка — сигнал на откат
	}

	order := Order{UserID: user.ID, Total: 1000}

	if err := tx.Create(&order).Error; err != nil {
		return err
	}

	// всё прошло без ошибок — транзакция закоммитится
	return nil
})

if err != nil {
	log.Println("транзакция откатилась:", err)
}

В логах при включённом logger.Info вы увидите последовательность:

BEGIN;
INSERT INTO users (name) VALUES ('Анна') RETURNING id;
INSERT INTO orders (user_id, total) VALUES (1, 1000);
COMMIT;

Если вторая вставка упадёт, GORM вместо COMMIT выполнит ROLLBACK, и пользователь не останется «висеть» в базе без заказа.

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

func CreateOrder(tx *gorm.DB, userID uint, total int) error {
	return tx.Create(&Order{UserID: userID, Total: total}).Error
}

err := db.Transaction(func(tx *gorm.DB) error {
	// здесь важно передать именно tx, а не db
	if err := CreateOrder(tx, 1, 2000); err != nil {
		return err
	}
	return nil
})

Через WithContext() транзакцию можно привязать к контексту с тайм-аутом. Если время вышло, запросы отменятся, транзакция откатится:

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
	// какие-то тяжёлые операции
	return nil
})

Ручное управление: Begin, Commit, Rollback

Иногда одной функции db.Transaction() недостаточно. Например, границы транзакции шире одной функции, или момент COMMIT должен зависеть от нескольких независимых веток логики. В таких случаях можно управлять транзакцией вручную.

Транзакция начинается с db.Begin(). Этот вызов возвращает новый объект *gorm.DB, привязанный к активной транзакции. Далее все операции выполняются через него. В конце — Commit() или Rollback().

Базовый сценарий:

tx := db.Begin()
if tx.Error != nil {
	log.Fatalf("не удалось открыть транзакцию: %v", tx.Error)
}

user := User{Name: "Иван"}
if err := tx.Create(&user).Error; err != nil {
	tx.Rollback()
	return
}

order := Order{UserID: user.ID, Total: 1200}
if err := tx.Create(&order).Error; err != nil {
	tx.Rollback()
	return
}

if err := tx.Commit().Error; err != nil {
	log.Println("ошибка коммита:", err)
}

SQL-последовательность такая же: BEGIN → несколько INSERTCOMMIT. Если что-то идёт не так, вы сами вызываете Rollback(), и база возвращается в исходное состояние.

Чтобы не размазывать Rollback() по коду, удобно повесить его в defer:

tx := db.Begin()
if tx.Error != nil {
	log.Fatal(tx.Error)
}
defer tx.Rollback()

if err := tx.Create(&User{Name: "Анна"}).Error; err != nil {
	return
}

if err := tx.Create(&Order{UserID: 1, Total: 500}).Error; err != nil {
	return
}

// если мы дошли сюда, всё прошло успешно — коммитим
if err := tx.Commit().Error; err != nil {
	log.Println("ошибка коммита:", err)
}

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

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

func SaveInvoice(tx *gorm.DB, inv *Invoice) error {
	return tx.Create(inv).Error
}

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

+7 800 100 22 47

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

+7 495 085 21 62

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

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