必须用 db.Transaction() 包裹跨表操作且全程使用 tx 实例,否则易致超卖、脏日志或连接卡死;GORM 的 db 无事务上下文,混用 db 与 tx 会绕过事务或造成嵌套失效。

直接结论:用 db.Transaction() 包裹所有跨表操作,且所有数据库调用必须基于传入的 tx 实例,不能混用原始 db。
为什么跨表操作不加事务就容易出问题
比如下单接口要同时写 orders 表、扣 products 库存、记一条 order_logs,这三步若没包在同一个事务里,就可能出现:订单创建成功但库存没扣(超卖)、日志写了但订单失败(脏日志)、或者中间 panic 导致连接卡死。GORM 的 db 实例本身不带事务上下文,每次调用都是独立的自动提交语句。
常见错误现象:
- 代码里混用
db.Create()和tx.Create(),后者生效,前者绕过事务直接提交 - 调用其他 service 函数时传了原始
db,而对方内部又用了db.Transaction(),结果嵌套了两层事务(实际只有一层生效) - 忘记在事务函数里 return error,导致
db.Transaction()误判为成功而提交
正确写法:只用 db.Transaction(),别碰 Begin()
90% 的业务场景(如订单+库存+日志)应该无条件走闭包式事务。它自动处理 panic 捕获、error 回滚、成功提交,不用手写 defer tx.Rollback(),也不会漏 Commit()。
示例:
err := db.Transaction(func(tx *gorm.DB) error {
// 所有操作都用 tx,不是 db
if err := tx.Create(&order).Error; err != nil {
return err // 返回 error 会触发回滚
}
if err := tx.Model(&product).Where("id = ?", pid).Update("stock", gorm.Expr("stock - ?", num)).Error; err != nil {
return err
}
if err := tx.Create(&log).Error; err != nil {
return err
}
return nil // 显式返回 nil 才会提交
})
if err != nil {
// 处理事务失败
}
关键点:
- 函数签名必须是
func(*gorm.DB) error,且返回error类型 - 所有数据库操作对象必须是参数
tx,包括tx.Model()、tx.Clauses()等 - 任意一步返回非 nil error,整个事务立即回滚;函数末尾不返回 error 才提交
跨函数调用时怎么传事务上下文
如果业务逻辑分层(比如 handler → service → repo),必须把 tx 当作参数逐层透传,不能在 repo 层重新拿 db。
错误示范:
// service 层
func CreateOrder(db *gorm.DB, order Order) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := repo.CreateOrder(tx, order); err != nil { // 正确:传 tx
return err
}
if err := repo.DecreaseStock(db, order.ProductID); err != nil { // 错误:传了 db!
return err
}
return nil
})
}
正确做法:
- 所有下层函数签名统一接受
*gorm.DB参数,且文档/注释明确该参数应为事务对象 - 避免在 repo 层封装
db.Transaction()—— 事务边界应在 service 或 handler 层定死 - 如果某函数既可能被事务内调用,也可能单独使用,可通过判断
tx.Statement.ConnPool == nil做 fallback(但不推荐,增加复杂度)
高并发下还要注意锁和隔离级别
跨表事务本身不解决并发冲突。比如两个请求同时扣同一商品库存,仅靠事务无法阻止“读-改-写”竞态。这时候得加锁或用乐观锁。
行级锁示例(悲观锁):
err := db.Transaction(func(tx *gorm.DB) error {
var product Product
// 加 FOR UPDATE 锁住目标行
if err := tx.Clauses(clause.Locking{
Strength: clause.LockingStrengthUpdate,
}).Where("id = ?", pid).First(&product).Error; err != nil {
return err
}
if product.Stock <p>注意事项:</p>
-
FOR UPDATE只在事务内有效,且依赖数据库引擎(MySQL 支持,SQLite 不支持) - 锁粒度尽量小,避免
SELECT * FROM products这种全表扫描式锁 - 乐观锁(如加
Version字段)适合冲突少的场景,但需业务层处理重试逻辑
真正容易被忽略的是:事务隔离级别默认是数据库驱动决定的(MySQL 默认 REPEATABLE READ),但 GORM 不会自动帮你设成 READ COMMITTED —— 如果你依赖“读已提交”语义,得显式配置:db.Session(&gorm.Session{PrepareStmt: true}).Transaction(...) 并在初始化时设置连接级 isolation。











