Как работать с транзакциями в Go с использованием пакета `database/sql`?

Ответ

Для работы с транзакциями в Go используется объект *sql.Tx, который создается из *sql.DB.

Основной флоу работы с транзакцией:

  1. Начать транзакцию с помощью db.Begin() или db.BeginTx().
  2. Выполнять все запросы (SELECT, INSERT, UPDATE, DELETE) через объект транзакции tx, а не через db.
  3. Если любая из операций завершилась ошибкой, откатить транзакцию с помощью tx.Rollback().
  4. Если все операции успешны, зафиксировать изменения с помощью tx.Commit().

Пример с использованием defer для надежности:

Этот паттерн гарантирует, что транзакция будет отменена, если что-то пойдет не так.

db, err := sql.Open("postgres", "user=... password=... dbname=... sslmode=disable")
if err != nil {
    log.Fatal(err)
}
defer db.Close()

// 1. Начинаем транзакцию
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil) // Используем BeginTx для передачи контекста
if err != nil {
    log.Fatal(err)
}
// 2. Откладываем Rollback. Если Commit выполнится успешно, 
// Rollback вернет ошибку sql.ErrTxDone, которую можно безопасно игнорировать.
defer tx.Rollback()

// 3. Выполняем операции внутри транзакции
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - 100 WHERE id = 1")
if err != nil {
    // Ошибка уже обработается в defer tx.Rollback()
    log.Printf("Failed to debit account: %v", err)
    return
}

_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + 100 WHERE id = 2")
if err != nil {
    log.Printf("Failed to credit account: %v", err)
    return
}

// 4. Если все хорошо, коммитим транзакцию
if err = tx.Commit(); err != nil {
    log.Fatal(err)
}

fmt.Println("Transaction committed successfully")

Важные моменты:

  • Изоляция: Для установки уровня изоляции транзакции (например, sql.LevelSerializable) используйте db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}).
  • Контекст: Передавайте context.Context в BeginTx и ExecContext/QueryRowContext для управления временем выполнения и отменой запросов.
  • Всегда используйте tx: После db.Begin(), все операции должны выполняться через объект tx, чтобы они были частью одной транзакции.

Ответ 18+ 🔞

А, смотри-ка, транзакции в Go! Ну это, блядь, как в банке: либо всё прошло, либо нихуя не прошло. Представь, что ты переводишь бабки с одного счёта на другой. Нельзя же, чтобы с одного списали, а на второй не зачислили — это ж пиздец, народ бунтовать начнёт.

Вот, значит, главный герой у нас — *sql.Tx. Рождается он из обычной *sql.DB, как я из бутылки водки после тяжёлого дня.

Как с этим чудом работать, чтобы не обосраться:

  1. Начинаем дело. Зовём db.Begin() или db.BeginTx(). Это как сказать: "Так, народ, все операции — в одну кучу, будем считать их одной неделимой хуйнёй".
  2. Работаем ТОЛЬКО через него. Получили объект tx — и все запросы (SELECT, INSERT, UPDATE) делаем через него. Если вдруг начнёшь тыкать в исходный db — это уже будет отдельная операция, и вся твоя транзакция нахуй не нужна. Запомнил? Запомни, блядь.
  3. Если пиздец случился — откатываемся. tx.Rollback() — наша кнопка "отмена". Всё, что наделали внутри транзакции, стирается, как твои обещания бросить пить.
  4. Если всё заебок — фиксируем. tx.Commit() — это приказ: "Всё, что натворили, записываем на века в базу". После этого точка, возврата нет.

Пример с defer — чтоб наверняка

Тут мы используем defer, это как поставить охранника у двери, который в любом случае, даже если ты сдохнешь на полпути, сделает откат. Умно, да?

db, err := sql.Open("postgres", "user=... password=... dbname=... sslmode=disable")
if err != nil {
    log.Fatal(err)
}
defer db.Close()

// 1. Запускаем движок транзакции
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil) // BeginTx — чтобы контекст прикрутить
if err != nil {
    log.Fatal(err)
}
// 2. Ставим охранника-откатчика. Если в конце всё ок и будет Commit,
// этот Rollback вернёт ошибку sql.ErrTxDone, но нам на неё похуй.
defer tx.Rollback()

// 3. Делаем свои тёмные делишки внутри транзакции
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - 100 WHERE id = 1")
if err != nil {
    // Ошибка — охранник (defer tx.Rollback()) уже всё откатит
    log.Printf("Не списали сотку с первого счёта: %v", err)
    return
}

_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + 100 WHERE id = 2")
if err != nil {
    log.Printf("Не зачислили сотку на второй счёт: %v", err)
    return
}

// 4. Если дошли сюда — значит, всё чики-пуки. Коммитим!
if err = tx.Commit(); err != nil {
    log.Fatal(err)
}

fmt.Println("Транзакция прошла, все довольны, можно и выпить")

А теперь, блядь, важные детали, на которых все погорают:

  • Изоляция, ёпта. Хочешь, чтобы твою транзакцию никто не видел, пока она не завершится? Используй db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}). Это как самый строгий уровень, чтоб никаких гонок данных, блядь.
  • Контекст — твой друг. Тыкай context.Context везде, куда можно (BeginTx, ExecContext). Он поможет, если запрос завис — просто отменишь его по таймауту, а не будешь ждать до второго пришествия.
  • ПОВТОРЯЮ, КАК МАНТРУ: После BeginTx ВСЁ делаем через tx.ExecContext или tx.QueryRowContext. Если сделаешь через db.Exec — это будет отдельный запрос, и он НЕ откатится, если основная транзакция накроется. Пиздец тебе тогда, а не атомарность.

Вот и вся магия. Не так страшен чёрт, как его малюют. Главное — не путать, через какой объект работаешь, а то будет тебе ебаный стыд перед базой данных.