Go 事务 defer Rollback 为什么不会覆盖 Commit
在 Go 的 database/sql 代码里,经常会看到这样的写法:事务开始后立即注册 defer tx.Rollback(),所有 SQL 成功后再调用 tx.Commit()。很多人第一次读到这里会担心:函数返回时,延后的 Rollback 会不会把已经提交的数据撤回来?
不会。Commit 和 Rollback 都会结束同一个 Tx。成功 Commit 后,Tx 已经进入完成状态,函数返回时再执行的 Rollback 只是一次兜底调用,通常返回 sql.ErrTxDone,不会覆盖已经提交的事务结果。
这套模板的关键不在于 Rollback 有“撤销失败”的特殊优先级,而在于 database/sql 会让事务结束操作具备一次性状态。defer 负责覆盖中途返回的失败路径,Commit 负责成功路径。
Commit 和 Rollback 共享什么状态
sql.Tx 代表一次事务。调用 Commit 或 Rollback 后,这次事务就不能再继续执行语句,也不能再次完成。官方文档把这个状态对应的错误定义为 sql.ErrTxDone:已经提交或回滚的事务,再次使用时会返回这个错误。
因此,成功路径可以抽象成“Tx 与 Commit 共同到达已完成状态”。当函数离开作用域,defer 中的 Rollback 再看到这个 Tx 时,已经没有可回滚的活动事务了。它不会重新打开事务,也不会修改数据库中已经提交的结果。
失败路径则不同:如果某个 SQL 调用返回错误,函数在执行到 Commit 之前返回,延后的 Rollback 仍然有机会终止当前事务。也就是说,同一行 defer tx.Rollback() 同时服务于成功路径的清理和失败路径的兜底,但真正结束事务的操作只有一个。
defer 为什么能安全地放在 Commit 前面
注册 defer 时,Rollback 并没有立即执行;它只是把调用安排到当前函数返回前。函数返回时,defer 按后进先出的顺序执行,所以它位于 Commit 之后执行,但这不等于它能改变 Commit 已经完成的状态。
func saveOrder(ctx context.Context, db *sql.DB, userID int64) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// 无论中途哪一步返回,都尝试清理尚未完成的事务。
// 成功 Commit 后这里通常只会得到 sql.ErrTxDone,结果被有意忽略。
defer tx.Rollback()
if _, err = tx.ExecContext(ctx,
"UPDATE accounts SET locked = ? WHERE user_id = ?",
true, userID,
); err != nil {
// SQL 出错时直接返回,让 defer Rollback 负责回滚。
return err
}
if err = tx.Commit(); err != nil {
// 提交失败时保留 Commit 的错误,不让清理动作改写它。
return err
}
return nil
}
这段代码里有三个容易混淆的时刻:
- 注册时:只记录一个延后的调用,不会发送回滚请求。
- Commit 时:成功提交并结束 Tx,后续再次结束事务没有作用。
- 函数返回时:执行 defer;如果事务已经提交,Rollback 的返回值通常是
sql.ErrTxDone。
为什么不要把 Rollback 的错误写回返回值
回滚是清理动作,SQL 执行和 Commit 才是这段业务的主要结果。若把 Rollback 的错误无条件赋给命名返回值,成功 Commit 后的 sql.ErrTxDone 就可能被误报为业务失败;更危险的是,Commit 本身的错误也可能被一个后续清理错误覆盖。
所以最常见的安全模板是直接忽略 Rollback 的返回值:
defer tx.Rollback() // 只兜底清理,不能覆盖 SQL 或 Commit 的原始错误
如果确实要记录清理失败,也应当区分“事务尚未结束时的回滚失败”和“事务已经 Commit 或 Rollback 后得到的 sql.ErrTxDone”。不要把后者当成新的业务故障,更不要用它替换已有的错误。
Commit 失败时应该怎么看
Commit 返回错误时,调用方首先应该保留这个错误并按业务语义处理,而不是假设“defer Rollback 一定已经恢复了一切”。提交失败可能与上下文取消、驱动或数据库连接状态有关;database/sql 会负责结束事务对象,但调用方仍需要把 Commit 错误交给上层。
尤其不要写成下面这种会覆盖原错误的形式:
func badPattern(tx *sql.Tx) (err error) {
// 这里的 defer 会把清理错误写回返回值,可能掩盖 Commit 错误。
defer func() {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
err = rollbackErr
}
}()
// 示例只展示错误归属问题,不代表完整事务操作。
if err = tx.Commit(); err != nil {
return err
}
return nil
}
更稳妥的判断是:Commit 的返回值属于提交结果,SQL 调用的返回值属于业务操作,Rollback 的返回值属于清理动作。三者出现时,优先保留已经产生的主错误;清理日志可以作为附加信息记录,但不应改变主错误的含义。
一张表记住这个事务模板
| 位置 | 应该做什么 | 不要做什么 |
|---|---|---|
| BeginTx 成功后 | 立即注册 defer tx.Rollback() | 等到第一条 SQL 之后才注册 |
| SQL 返回错误 | 直接返回原始错误 | 继续 Commit 一个已知失败的事务 |
| Commit 成功 | 返回 nil,让 defer 做无害兜底 | 把 sql.ErrTxDone 当成提交失败 |
| Commit 返回错误 | 保留 Commit 错误并交给上层 | 无条件用 Rollback 错误覆盖它 |
常见追问
defer tx.Rollback() 会不会额外访问数据库?
它会调用 Tx 的回滚方法,但成功 Commit 后 Tx 已经完成,database/sql 会按完成状态处理这次重复结束请求;它不是一次新的业务回滚,也不会撤销已经提交的数据。
可以不写 defer Rollback 吗?
可以,但你就必须在每个提前返回的分支手动清理,漏掉一个分支就可能让事务保持到驱动或上下文处理它。把 Rollback 注册为兜底通常更容易维护。
Rollback 返回 sql.ErrTxDone 要不要打印错误日志?
如果它发生在成功 Commit 之后,通常不应按业务故障报警。只有在事务尚未由 Commit 或 Rollback 结束、而回滚确实失败时,才需要结合驱动和上下文进一步记录。
参考文档:https://pkg.go.dev/database/sql;Go 标准库源码:https://go.dev/src/database/sql/sql.go。

Go reflect.Value.MapIndex 找不到键时怎样判断空值
