db.Transaction()与tx.Begin()混用会破坏事务边界:前者开启新事务并自动管理提交/回滚,后者仅创建savepoint;正确做法是外层用db.Transaction(),内部用tx.SavePoint()+tx.RollbackTo()实现局部回滚。

事务嵌套时 db.Transaction() 和 tx.Begin() 混用会直接破坏事务边界
很多人以为用 db.Transaction() 包一层,再在内部调用 tx.Begin() 就是“嵌套事务”,其实这是错误认知。GORM 的 db.Transaction() 本质是开启一个新事务并自动提交/回滚;而 tx.Begin() 是在已有事务内手动创建子事务(即 savepoint),二者语义完全不同,混用会导致事务提前提交或 panic。
常见错误现象:sql: transaction has already been committed or rolled back 或部分操作未回滚。
- 只在最外层用
db.Transaction(),内部所有数据库操作都复用该*gorm.DB实例(即传入的tx) - 需要局部回滚时,不用
tx.Begin(),改用tx.SavePoint("name")+tx.RollbackTo("name") - 避免在函数签名中暴露
*gorm.DB类型参数——这容易诱使调用方误开新事务
用 SavePoint 替代“伪嵌套事务”是更可控的选择
GORM 本身不支持真正的嵌套事务(即 PostgreSQL 的 BEGIN TRANSACTION 套 BEGIN TRANSACTION),但通过 savepoint 可以模拟局部回滚行为,且不会影响外层事务状态。
使用场景:订单创建中需同时扣库存、写日志、发通知,其中日志写入失败不应导致库存回退。
- 设置保存点:
tx.SavePoint("after_inventory_deduct") - 执行可能失败的操作后,出错时调用:
tx.RollbackTo("after_inventory_deduct") - 成功后可选释放:
tx.ReleaseSavePoint("after_inventory_deduct")(非必需,事务结束自动清理) - 注意:MySQL 5.7+、PostgreSQL 支持 savepoint;SQLite 部分版本有兼容性问题,需实测
业务函数不该自行管理事务,应由调用方统一控制
把事务逻辑下沉到 UserRepo.Create() 或 OrderService.Place() 这类函数里,等于把事务生命周期和业务职责耦合在一起,一旦多个服务互相调用,层级立刻失控。
正确做法是让这些函数只接受一个 *gorm.DB 参数,不做 Begin/Commit,把事务决策权交给最上层 handler 或 usecase。
- DAO 层函数签名示例:
func (r *UserRepo) Create(tx *gorm.DB, u *User) error - UseCase 层组装事务:
db.Transaction(func(tx *gorm.DB) error { return orderSvc.Place(tx, req) }) - 这样无论
Place()内部调多少层 repo 方法,都共享同一事务上下文,无嵌套风险
调试时怎么快速识别事务是否被意外提交?
最容易被忽略的是 GORM 的 auto-commit 行为:只要调用 db.Create() 等方法时传入的是未开启事务的 *gorm.DB 实例,就会走默认连接池直连,操作立即生效。
建议在开发阶段加一层轻量检查:
- 在事务入口处打日志:
log.Printf("starting tx with id: %v", tx.Statement.ConnPool.(sql.Tx).LastInsertId())(需类型断言) - 启用 GORM 日志:
gorm.Config{Logger: logger.Default.LogMode(logger.Info)},观察每条 SQL 是否带BEGIN/COMMIT - 对关键路径加断点,检查
tx.Statement.DB是否始终指向同一个事务对象,而非中途被替换成db
事务嵌套不是靠“多写几层 Transaction()”来解决的,而是靠明确分层 + savepoint 控制粒度 + 统一入口管控。越早把事务决策提到 usecase 层,后续维护时越不容易掉进回滚失效、部分提交的坑里。


















