事务回滚失效的最隐蔽高频原因是子函数误用全局db变量或新建连接,导致操作脱离当前事务tx;必须所有操作显式传入并复用同一tx实例,优先使用db.transaction()自动处理panic与提交回滚。

事务里调用普通 db 实例,Rollback 不生效
这是最隐蔽也最高频的问题:你在函数里写了 tx.Rollback(),但实际没回滚成功。根本原因是子函数内部用了包级全局的 db 变量,或重新调用了 gorm.Open(),导致操作落在了默认连接池,完全不参与当前事务。
必须确保所有数据库操作都复用同一个事务实例 tx:
- 禁止在事务函数内直接使用未传入的
db;所有业务逻辑函数都要显式接收tx *gorm.DB参数 - 调试时可加断言:
if tx.Statement.ConnPool == nil { panic("tx is not in transaction") } - 若用了
db.Session(),务必确认它是基于tx创建的,比如tx.Session(&gorm.Session{PrepareStmt: true})
panic 后事务悬挂,Commit/Rollback 没触发
手写 defer tx.Rollback() 看似稳妥,实则危险——它不会判断事务是否已提交,也不会捕获 panic。一旦函数中途 panic,defer 执行时事务可能早已被 Commit() 过,再 Rollback 就报 "transaction has already been committed or rolled back"。
正确做法只有一条:优先用 db.Transaction() 封装
-
err := db.Transaction(func(tx *gorm.DB) error { ... })—— 内部自动处理 Commit / Rollback / panic 捕获 - 仅当需要 SavePoint 或嵌套控制时才手动
Begin(),且必须配对Commit()或Rollback(),并在defer中加recover() - 不要依赖
defer tx.Rollback()单独兜底,它不是事务保险丝
嵌套事务误以为能独立提交
GORM 的 tx.Begin() 并非真正嵌套事务,而是保存点(SavePoint)。外层事务不提交,子事务“提交”只是打了个标记,最终仍由外层统一决定原子性。
典型踩坑场景:
- 重复使用相同保存点名,如连续两次
tx.SavePoint("sp1"),后一次会覆盖前一次,导致回滚跳过中间状态 - 在保存点内执行 DDL(如
CREATE TABLE),MySQL 会隐式提交,破坏事务边界 - 误在子事务里调用
tx.Commit(),实际是提交整个外层事务,而非局部
记住:GORM 没有“子事务提交”概念,只有 SavePoint + RollbackTo() 回退到某一点。
事务中并发更新引发死锁
报错类似 ERROR: deadlock detected(PostgreSQL)或 ErrDeadlock(MySQL),本质是多个 goroutine 对同一组记录加锁顺序不一致,形成循环等待。
预防比重试更有效:
- 约定行锁顺序:例如涉及订单和库存时,始终先
SELECT ... FOR UPDATE WHERE order_id = ?,再SELECT ... FOR UPDATE WHERE sku = ? -
FOR UPDATE的 WHERE 条件必须命中索引,否则升级为表锁,放大冲突面 - 把非关键逻辑(如日志、通知)移出事务体,缩短锁持有时间
- 避免长事务,尤其不要在事务里做 HTTP 调用或文件 IO
事务不是万能锁,它是协作契约——你得让所有参与者按同一套规则加锁,否则数据库只能选一个牺牲。











