库存扣减必须用事务+显式锁,仅db.Transaction不够;因无锁时查询与更新间存在时间窗口致超卖,且混用DB实例会导致锁失效;悲观锁需SELECT...FOR UPDATE,乐观锁靠version字段CAS校验。

直接说结论:库存扣减必须用事务 + 显式锁(悲观或乐观),裸调 db.Update 或 db.Exec 在并发下必然超卖。
为什么单纯用 db.Transaction 还不够
很多人以为只要包一层 db.Transaction(func(tx *gorm.DB) error { ... }) 就万事大吉,但实际线上出问题的全是这种写法:
- 只用了事务,没加锁 → 查询库存和更新库存之间存在时间窗口,两个请求查到同一份“足够库存”,然后都扣成功
- 用了
tx.Model(&stock).Where(...).Update(...)→ 这是“无条件更新”,不校验当前值,可能覆盖别人刚更新的结果 - 事务里混用不同
*gorm.DB实例(比如传了未带事务上下文的db)→ 锁失效、回滚不生效
悲观锁扣减:适合强一致性、QPS 不高的场景
核心是:在事务内用 SELECT ... FOR UPDATE 锁住要操作的行,再校验、再更新。GORM 通过 clause.Locking 实现:
err := db.Transaction(func(tx *gorm.DB) error {
var stock Stock
// 关键:Cl.ause(clause.Locking{Strength: "UPDATE"}) 触发 FOR UPDATE
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("goods_id = ?", goodsID).
First(&stock).Error; err != nil {
return err
}
<pre class="brush:php;toolbar:false;">if stock.Quantity <p>})
</p>
- 必须在同一个
tx上执行查询和更新,不能用外部db -
First必须带Clauses,否则锁不住;Find不支持该 Clause - MySQL 默认隔离级别(REPEATABLE READ)下,
FOR UPDATE会锁住索引记录 + 间隙,防止幻读
乐观锁扣减:适合高并发、冲突少的场景
不依赖数据库锁,靠版本号(version 字段)做 CAS 检查。失败时需业务层决定重试还是报错:
func deductStock(db *gorm.DB, goodsID uint, needNum int) error {
var stock Stock
if err := db.Where("id = ?", goodsID).First(&stock).Error; err != nil {
return err
}
if stock.Quantity // 原子更新:仅当 version 未变才允许扣减,并递增 version
result := db.Model(&stock).
Where("id = ? AND version = ?", goodsID, stock.Version).
UpdateColumns(map[string]interface{}{
"quantity": gorm.Expr("quantity - ? ", needNum),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("stock version conflict, retry required")
}
return nil
}
- 表必须有
version(int 类型)字段,并在模型中声明Version uint标签 -
UpdateColumns避免覆盖其他字段;不用Save或Updates,它们不支持Expr条件更新 - 返回
RowsAffected == 0表示被别人抢先更新,此时 version 已变,当前操作失效
容易被忽略的三个硬伤
线上事故往往不是逻辑错,而是这些细节漏掉:
-
db.Transaction的回调函数里,所有 DB 操作必须用传入的tx,不能用原始db—— 否则锁和事务上下文全丢 - MySQL 的
FOR UPDATE在非事务块里执行,语句结束即释放锁;所以必须确保整个流程在Transaction内完成 - 乐观锁的
version字段必须设为NOT NULL DEFAULT 0,且首次插入时显式赋初值,否则WHERE version = NULL永远不成立











