Go中无真正嵌套事务,需用SAVEPOINT模拟局部回滚,事务必须显式传递*sql.Tx参数,通过Querier接口抽象统一入口,且Commit/Rollback须由外层严格控制。
Go 里没有真正的嵌套事务,sql.Tx 不支持 BeginTx 再 BeginTx
postgresql、mysql 等数据库本身不支持标准 sql 的嵌套事务(savepoint 是替代方案,不是嵌套事务),而 go 标准库 database/sql 的 sql.tx 也明确禁止在已有事务上调用 begin 或 begintx——会直接 panic:"sql: transaction already in progress"。这不是 bug,是设计使然。
常见错误现象:在已开启的 *sql.Tx 上调用 db.Begin(),或误以为传入同一个 *sql.DB 就能“续上”事务上下文。
- 事务必须显式传递,不能靠全局或闭包隐式捕获
- 所有需要事务一致性的操作(包括子逻辑)都得接收
*sql.Tx作为参数 - 若强行封装成“自动开启事务”的函数,极易漏传或错传事务对象,导致部分操作跑在
*sql.DB上,破坏一致性
用 Savepoint 模拟嵌套回滚边界
真正可行的“嵌套控制”是通过 SAVEPOINT 实现局部回滚,而不影响外层事务。但标准 database/sql 不提供 Savepoint 接口,需手写 SQL 并依赖驱动支持(如 pgx 或 mysql 驱动)。
以 pgx/v5 为例,它暴露了 Savepoint 和 RollbackTo 方法:
// 外层事务
tx, _ := conn.Begin(ctx)
defer tx.Rollback(ctx)
// 创建保存点
sp, _ := tx.Savepoint(ctx, "sp_inner")
// 执行可能失败的子逻辑
_, err := tx.Exec(ctx, "INSERT INTO orders (...) VALUES (...)")
if err != nil {
// 仅回滚到保存点,不影响外层
tx.RollbackTo(ctx, sp)
// 继续执行其他分支逻辑...
} else {
tx.Release(ctx, sp) // 显式释放保存点(非必需,但推荐)
}
-
Savepoint名称必须唯一且可预测(避免随机名,方便调试) -
RollbackTo后,该保存点之后的所有语句失效,但事务仍活跃,可继续操作 - MySQL 驱动(如
go-sql-driver/mysql)需手动执行SAVEPOINT sp_name和ROLLBACK TO SAVEPOINT sp_name,无封装方法
用函数参数 + 接口抽象统一事务入口
把“是否在事务中”变成显式契约,比试图自动检测更可靠。定义一个接受 Querier 接口的业务函数:
type Querier interface {
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
}
func CreateUser(q Querier, name string) error {
_, err := q.ExecContext(context.Background(), "INSERT INTO users(name) VALUES ($1)", name)
return err
}
调用时按需传入:
- 独立操作 → 传
*sql.DB - 事务内调用 → 传
*sql.Tx - 测试时 → 传 mock 实现,完全绕过 DB
这样既避免了事务传播的隐式耦合,又让嵌套逻辑天然支持事务上下文——只要外层传的是 *sql.Tx,整个调用链就运行在同一个事务中。
别忘了 tx.Commit() 和 defer tx.Rollback() 的配对时机
最容易被忽略的是:defer tx.Rollback() 必须在 tx 创建后立即声明,且不能被提前 return 覆盖;否则一旦中间 panic 或忘记 commit,事务就永远挂起,直到连接超时被服务端 kill。
- 正确模式:创建
tx后立刻defer tx.Rollback(),并在成功路径末尾显式tx.Commit() - 不要在子函数里决定 commit/rollback——事务生命周期必须由最外层控制
- 如果用了
Savepoint,RollbackTo不影响外层Commit的有效性
事务的边界从来不在代码缩进里,而在你显式调用 Begin 和 Commit 的那两行之间。任何试图绕过它的“嵌套”设计,最终都会变成状态管理的噩梦。


















