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 对象的完整生命周期。
3.5 事务操作的常见陷阱
- 忘记回滚: 如果
Commit失败后没有调用Rollback,事务持有的数据库连接不会被释放,导致连接池泄漏。务必使用defer模式。 - 重复提交/回滚: 对已提交或已回滚的 Tx 对象再次调用
Commit或Rollback会返回sql.ErrTxDone错误,代码中应妥善处理。 - 混用 DB 和 Tx: 在事务内部使用
db.Exec()而不是tx.Exec(),该操作会在事务之外执行,无法与事务内的操作保持原子性。 - 长事务: 事务内避免执行耗时的网络调用或大量计算,长时间持有连接会降低连接池的复用效率。
3.6 事务内查询与写入的隔离性示意
下面的 SVG 展示了两个并发事务在默认隔离级别(Read Committed)下,事务内查询与写入的可见性关系。
关键理解: 在 Read Committed 隔离级别下,事务 B 在事务 A 提交之前执行查询,看不到 A 的未提交修改(避免脏读)。事务 A 提交后,事务 B 再次查询才能看到新值。这就是“事务内查询”的隔离性表现。