db.Begin()返回新*gorm.DB实例而非开启db事务,混用db操作会绕过事务;必须全程使用tx对象,提交/回滚需显式配对并recover panic,嵌套实为SAVEPOINT,I/O操作须移至tx.AfterCommit()。

db.Begin() 返回的是新事务实例,不是“在 db 上开启事务”
很多人误以为 tx := db.Begin() 是给原始 db 加了个事务开关,后续继续用 db.Create() 就自动进事务——这是错的。GORM 的 Begin() 实际返回一个全新的 *gorm.DB 实例,它绑定了当前数据库连接的事务上下文;而原始 db 仍是无事务的普通连接池句柄。
常见错误现象:db.Create() 成功写入但未回滚,tx.Rollback() 后数据还在库里。
- ✅ 正确做法:所有操作必须走
tx,例如tx.Create(&u)、tx.Where("id = ?", 1).First(&u) - ❌ 危险操作:混用
db.Create()或db.First(),这些完全绕过事务 - ⚠️ 注意:
tx提交或回滚后不可复用,再调tx.Create()会 panic 报"invalid transaction"
手动 Commit/Rollback 必须配对 + recover,不能只靠 defer
defer tx.Rollback() 看似稳妥,实则埋雷:如果前面已 tx.Commit(),defer 仍会执行导致 panic;若中间 panic 但没 recover,程序崩溃前 rollback 可能来不及触发。
典型错误写法:
tx := db.Begin()
defer tx.Rollback() // ❌ 没判断是否已 Commit,也没捕获 panic
if err := tx.Create(&u).Error; err != nil {
return err
}
return tx.Commit().Error正确做法是显式控制流程,并用 recover() 拦截 panic:
- 用
if err := tx.Commit().Error; err != nil { tx.Rollback() }显式处理提交失败 - 在函数开头加
defer func() { if r := recover(); r != nil { tx.Rollback() } }()捕获 panic - 或者更简单:直接改用
db.Transaction(),它内部已封装了这套逻辑
嵌套调用 Begin 实际是 SavePoint,不是独立子事务
GORM 不支持真正意义上的嵌套事务(即子事务可单独提交)。当你在已有事务内调 tx.Begin(),底层只是执行 SAVEPOINT sp_xxx,属于同一事务的局部回滚点。
容易踩的坑:
- 重复 SavePoint 名(如两次
tx.SavePoint("sp"))会导致前一个被覆盖,RollbackTo("sp")只回滚到最后一次定义的位置 -
inner.RollbackTo("sp")不影响外层已执行的语句,整个事务最终仍需outer.Commit()或outer.Rollback() - 在 SavePoint 内执行 DDL(如
tx.Exec("CREATE TABLE ...")),MySQL 会隐式提交,直接破坏事务原子性
HTTP/缓存/日志等 I/O 操作绝不能放在事务函数体内
事务体里做 HTTP 调用、Redis 写入、S3 上传或打日志,轻则拖慢事务、锁表时间变长,重则因超时或网络抖动导致连接池耗尽、服务雪崩。
正确姿势:
- 把 I/O 移出事务体,改用
tx.AfterCommit(func() { /* http.Post(...) */ })异步触发 -
AfterCommit钩子只对当前tx生效,若传错对象(比如传了原始db)或新建了tx.WithContext(),钩子就失效 - 对强一致性要求的场景(如扣库存+发通知),优先走本地消息表 + 定时任务补偿,而非塞进事务
最常被忽略的一点:db.Transaction() 只捕获 panic 并回滚,不校验业务逻辑错误,也不自动提交——return nil 前忘了 tx.Commit(),事务就静默丢弃了。


















