3. 基础事务操作:Begin、Commit、Rollback、Tx对象的使用、事务内执行查询

在 Go 的 database/sql 标准库中,事务并非通过连接对象直接管理,而是通过 *sql.Tx 对象完成。本章将系统讲解事务的开启、提交、回滚以及如何在事务内部执行查询操作,这是构建可靠 API 事务边界的基础。

3.1 事务生命周期与核心方法

一个标准的事务操作遵循“开启 → 执行 → 提交/回滚”的三段式生命周期。Go 通过 DB.Begin() 获取 *sql.Tx 对象,然后在该对象上调用 Exec、Query 等方法,最后通过 Tx.Commit() 或 Tx.Rollback() 结束事务。

核心原则: 事务内的所有操作必须使用 Tx 对象的方法,而非 DB 对象的方法。混用会导致操作不在同一事务上下文中执行。
方法 作用 返回值 注意事项
DB.Begin() 开启一个新事务 (*sql.Tx, error) 底层从连接池获取一个专用连接
Tx.Commit() 提交事务,持久化所有变更 error 提交后 Tx 对象不可再用
Tx.Rollback() 回滚事务,撤销所有变更 error 通常配合 defer 确保异常时回滚
Tx.Exec() 执行不返回行的 SQL(INSERT/UPDATE/DELETE) (sql.Result, error) 事务内专用
Tx.Query() 执行返回行的 SQL(SELECT) (*sql.Rows, error) 事务内专用
Tx.QueryRow() 执行返回单行的 SQL *sql.Row 事务内专用

3.2 基础事务操作流程

以下代码展示了最标准的事务操作模式:开启事务、执行两条更新语句、提交。同时使用 defer 确保在发生 panic 或错误时自动回滚,避免连接泄漏。

func TransferFunds(db *sql.DB, fromID, toID int, amount float64) error {
    // 1. 开启事务
    tx, err := db.Begin()
    if err != nil {
        return fmt.Errorf("begin tx failed: %w", err)
    }

    // 2. 使用 defer 确保异常时回滚
    //    如果正常提交,rollback 会返回 sql.ErrTxDone,通常忽略
    defer func() {
        if err != nil {
            // 如果 err 不为 nil,说明前面出错了,执行回滚
            if rbErr := tx.Rollback(); rbErr != nil {
                // 记录回滚错误,但优先返回原始错误
                log.Printf("rollback error: %v", rbErr)
            }
        }
    }()

    // 3. 在事务内执行操作
    //    扣减转出账户
    _, err = tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID)
    if err != nil {
        return fmt.Errorf("debit failed: %w", err)
    }

    //    增加转入账户
    _, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID)
    if err != nil {
        return fmt.Errorf("credit failed: %w", err)
    }

    // 4. 提交事务
    err = tx.Commit()
    if err != nil {
        return fmt.Errorf("commit failed: %w", err)
    }

    return nil
}
最佳实践: 使用 defer 配合命名返回值 err 实现“自动回滚”模式。当函数正常执行到 tx.Commit() 时,err 被赋值为 nil,defer 中的回滚操作会因 err == nil 而跳过(或执行后返回 sql.ErrTxDone,通常被忽略)。

3.3 事务内执行查询

事务内不仅可以执行写入操作,也可以执行查询。事务内的查询能看到事务中尚未提交的变更(取决于隔离级别,默认是读已提交)。这在需要“先查询再判断”的业务场景中非常关键。

func ReserveInventory(db *sql.DB, productID int, quantity int) (bool, error) {
    tx, err := db.Begin()
    if err != nil {
        return false, err
    }
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    // 事务内查询:检查库存是否充足
    var currentStock int
    err = tx.QueryRow("SELECT stock FROM inventory WHERE product_id = $1 FOR UPDATE", productID).Scan(¤tStock)
    if err != nil {
        return false, fmt.Errorf("query stock failed: %w", err)
    }

    if currentStock < quantity {
        // 库存不足,回滚事务
        err = fmt.Errorf("insufficient stock: have %d, need %d", currentStock, quantity)
        return false, err // defer 会触发回滚
    }

    // 扣减库存
    _, err = tx.Exec("UPDATE inventory SET stock = stock - $1 WHERE product_id = $2", quantity, productID)
    if err != nil {
        return false, fmt.Errorf("update stock failed: %w", err)
    }

    // 提交
    err = tx.Commit()
    if err != nil {
        return false, fmt.Errorf("commit failed: %w", err)
    }

    return true, nil
}
注意: 事务内使用 SELECT ... FOR UPDATE 会对查询的行加锁,防止其他事务并发修改。这在库存扣减、余额转账等场景中至关重要,但也会降低并发性能,请仅在必要时使用。

3.4 事务对象的状态流转

理解 *sql.Tx 对象的状态变化,有助于编写健壮的事务代码。下图展示了 Tx 对象的完整生命周期。

Tx 对象状态流转图 DB.Begin() Tx 活跃(Active) 可执行 Exec / Query / QueryRow tx.Commit() 已提交(Committed) tx.Rollback() 已回滚(Rolled Back) Tx 结束,连接归还池

3.5 事务操作的常见陷阱

  • 忘记回滚: 如果 Commit 失败后没有调用 Rollback,事务持有的数据库连接不会被释放,导致连接池泄漏。务必使用 defer 模式。
  • 重复提交/回滚: 对已提交或已回滚的 Tx 对象再次调用 Commit 或 Rollback 会返回 sql.ErrTxDone 错误,代码中应妥善处理。
  • 混用 DB 和 Tx: 在事务内部使用 db.Exec() 而不是 tx.Exec(),该操作会在事务之外执行,无法与事务内的操作保持原子性。
  • 长事务: 事务内避免执行耗时的网络调用或大量计算,长时间持有连接会降低连接池的复用效率。

3.6 事务内查询与写入的隔离性示意

下面的 SVG 展示了两个并发事务在默认隔离级别(Read Committed)下,事务内查询与写入的可见性关系。

事务内查询与写入的隔离性(Read Committed) 事务 A UPDATE accounts SET balance=balance-100 SELECT balance FROM accounts(未提交) COMMIT 事务 B SELECT balance(读不到 A 未提交数据) SELECT balance(读到 A 提交后的数据) 时间轴 →
关键理解: 在 Read Committed 隔离级别下,事务 B 在事务 A 提交之前执行查询,看不到 A 的未提交修改(避免脏读)。事务 A 提交后,事务 B 再次查询才能看到新值。这就是“事务内查询”的隔离性表现。