事务必须与HTTP请求生命周期对齐,使用db.Transaction封装整个业务逻辑,显式使用tx对象操作数据库,避免跨请求复用、混用读写分离或在事务中执行非DB操作。

事务作用域必须与HTTP请求生命周期对齐
在Gin中,一个接口处理函数对应一次完整业务操作,事务就该覆盖从数据库读写开始到响应返回前的全部逻辑。不能把事务塞进某个子函数里就不管了,也不能跨请求复用同一个tx对象。
常见错误是:在中间件里db.Begin(),然后靠defer tx.Rollback()收尾——这根本不可靠,panic时可能没执行到defer,或已经Commit过了还强行回滚。
- 始终用
db.Transaction(func(tx *gorm.DB) error { ... })封装整个业务逻辑,它自动处理Commit/Rollback/panic捕获 - 若需提前退出(比如参数校验失败),直接
return err,别手动tx.Rollback() - 事务内所有DB操作必须显式使用传入的
tx,比如tx.Create(&order),而不是db.Create(&order),否则操作落在默认连接池,不参与事务
嵌套事务其实是SavePoint,不是真嵌套
tx.Transaction()看起来像开启子事务,实际只是新建一个带保存点(SavePoint)的作用域,底层仍共享同一数据库连接。它不能独立提交,最终仍由外层事务统一决定成败。
典型误用:在扣库存失败后只回滚内部tx2,以为订单还能保留——其实只要外层事务没Commit,整块操作都还没落库。
- SavePoint名要唯一,重复命名会导致前一个被覆盖,回滚时跳过中间状态
- 避免在SavePoint内执行DDL(如
CREATE TABLE),MySQL会隐式提交,破坏原子性 - 真正需要“局部回滚”的场景(比如批量导入中某条失败不影响其余),才用
tx.SavePoint("sp1")+tx.RollbackTo("sp1")
事务里别混用读写分离路由
一旦进入db.Transaction(),所有操作强制走主库,GORM的dbresolver插件自动失效。你写tx.Preload("User").Find(&order),查的仍是主库,不会因为Preload是“读”就切到从库。
容易踩坑的是:想在事务里先查余额(强一致),再扣减。有人会写db.Clauses(dbresolver.Write).First(&u)——这毫无意义,事务内本来就只能走主库。
- 事务中所有
tx.Xxx()调用天然具备强一致性,无需额外声明 - 不要在事务里调用
db.Raw("SELECT ..."),而要用tx.Raw(...),否则脱离事务上下文 - 如果事务里真要查从库(极少见,比如审计日志同步),得另开独立连接,不能复用
tx
事务粒度要小,非关键逻辑必须移出去
长事务=长锁持有时间=高冲突概率。比如下单事务里顺手发短信、写ES、推MQ,这些和数据库一致性无关的操作,硬塞进去只会拖慢整个事务,放大死锁风险。
真实线上事故里,80%的死锁不是SQL写得差,而是事务裹挟了不该裹挟的东西。
- 把日志记录、通知发送、缓存更新等操作移到
db.Transaction()回调返回成功之后 - 涉及多表更新时,严格约定加锁顺序:比如总是先
tx.First(&order)再tx.First(&inventory),避免A锁order+B锁inventory、B锁inventory+A锁order的循环等待 - 用
SELECT ... FOR UPDATE时,WHERE条件必须命中索引,否则MySQL会升级为表锁,影响范围远超预期


















