db.Transaction 是最安全选择,自动捕获 panic 并回滚;手写 Begin/Commit 易漏回滚、误复用事务或混用 db 导致操作绕过事务,应严格使用传入的 tx 实例并每个错误分支 return err。

用 db.Transaction 而不是手写 Begin/Commit
绝大多数场景下,直接调用 db.Transaction 是最安全的选择。它自动捕获 panic 并回滚,省去手动管理状态的负担;而手写 Begin 容易漏掉 Rollback、误复用已提交的 tx、或混用 db 导致操作绕过事务。
常见错误现象:
- 在
db.Transaction函数体里仍用db.Create()—— 这条记录直连数据库,事务形同虚设 - 分支逻辑中忘记
return err,函数静默返回nil,事务不提交也不报错 - 函数内发生 panic 后被 recover 住,但没显式 return error,
db.Transaction误判为成功
正确姿势是:所有 CRUD 操作必须用传入的 tx 实例,且每个错误分支都 return err:
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&User{Name: "Alice"}).Error; err != nil {
return err // 必须 return,否则不回滚
}
if err := tx.Create(&Order{UserID: 1}).Error; err != nil {
return err
}
return nil // 只有这里 return nil 才会 commit
})
手写 Begin 时,为什么 tx.Rollback() 经常不生效
根本原因是:事务对象不可复用,且回滚时机错位。一旦 tx.Commit() 或 tx.Rollback() 执行过,该 tx 就进入 invalid 状态,再调任何方法(包括再次 Rollback())都会 panic 报 "invalid transaction"。
立即学习“go语言免费学习笔记(深入)”;
容易踩的坑:
- 用
defer tx.Rollback()包裹整个事务块 —— 成功时也会执行,导致误回滚 - 在
tx.Commit().Error != nil判断后,没检查是否已 rollback 过,又调一次tx.Rollback() - 把
tx当成全局变量反复使用,比如在循环里重复tx.Create()
可靠写法是显式控制路径:
tx := db.Begin()
if err := tx.Create(&User{}).Error; err != nil {
tx.Rollback() // 立即回滚,不再继续
return err
}
if err := tx.Create(&Log{}).Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Commit().Error; err != nil {
tx.Rollback() // commit 失败才 rollback
return err
}
嵌套操作失败时,如何只回滚局部而不影响外层
GORM 的“嵌套事务”本质是数据库 SAVEPOINT,不是真正独立事务。MySQL 和 PostgreSQL 都支持,但行为一致:子事务失败只需 RollbackTo,外层已执行的操作不受影响。
关键点:
- 不能用
db.Transaction套db.Transaction—— 第二层会脱离上下文,变成新事务 - 必须用
tx1.Begin()得到tx2,所有子操作只走tx2 -
tx2.Rollback()等价于tx1.RollbackTo("sp_xxx"),不会波及tx1已做的修改 - 最终是否提交,由
tx1.Commit()或tx1.Rollback()决定
示例:订单创建成功,但冻结资金失败,只回滚资金操作,保留订单记录:
tx1 := db.Begin()
defer func() {
if r := recover(); r != nil {
tx1.Rollback()
}
}()
if err := tx1.Create(&Order{}).Error; err != nil {
tx1.Rollback()
return err
}
tx2 := tx1.Begin() // 设 savepoint
if err := tx2.Create(&FundLock{}).Error; err != nil {
tx2.Rollback() // 只回滚到 tx2 开始前,Order 仍存在
// 继续走业务逻辑,比如发告警、记日志
} else {
// tx2.Commit() 实际只是标记 savepoint,无实质提交
}
// 外层按需决定最终 fate
if shouldAbortAll {
tx1.Rollback()
} else {
tx1.Commit()
}
HTTP 调用、缓存写入这些 I/O 操作能不能放事务里
绝对不能。事务生命周期应严格限定在数据库操作范围内。HTTP 请求超时、Redis 写入延迟、甚至日志上报失败,都会让事务长时间挂起,快速耗尽连接池,拖垮整个服务。
正确做法:
- 把 I/O 操作移出事务函数体,改用
tx.AfterCommit()注册回调 -
AfterCommit只对当前tx生效,若传错对象(比如用了原始db)钩子就失效 - 异步任务必须带幂等性设计,因为
AfterCommit不保证 100% 执行(进程 crash、节点宕机等情况) - 强一致性要求的场景(如支付),建议用本地消息表 + 定时补偿,而非依赖钩子
例如订单提交后发通知:
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&Order{}).Error; err != nil {
return err
}
tx.AfterCommit(func(tx *gorm.DB) {
go notifyUserAsync(orderID) // 异步触发,不阻塞事务
})
return nil
})
事务最难的不是语法,而是边界意识:什么时候该拆成多个事务,什么时候必须用 savepoint,以及哪些操作看似无关却会悄悄破坏原子性。GORM 的 API 很简洁,但数据库语义和业务约束才是真正的复杂来源。


















