必须用 db.Transaction 包裹 + gorm.Expr 在 SQL 层原子扣减,否则高并发超卖;典型错误是先查再更新,导致并发读写覆盖;正确做法是 WHERE 校验余额并用 Expr 一行 SQL 完成计算与写入。

直接用 db.Transaction 包裹扣减逻辑,配合 gorm.Expr 在 SQL 层完成原子计算,否则高并发下必然超卖。
为什么不能先查再更新
典型错误是:先 db.First() 读出当前余额,Go 里做减法,再 db.Save() 写回去。这在并发场景下完全失效——两个请求同时读到余额为 100,各自减 1 后都写回 99,实际应为 98。
- 数据库层面无锁,纯应用层计算无法保证一致性
- 中间可能被其他服务或手动 SQL 绕过 ORM 修改数据
- 即使加
SELECT ... FOR UPDATE,也得确保整个事务块内持有连接,且不能有 I/O 阻塞
正确写法:Expr + Transaction 组合
必须把「读取当前值 → 计算新值 → 写入」三步压进一条 SQL,并由事务保证多语句间隔离。
- 库存扣减:
db.Model(&product).Where("id = ? AND stock >= ?", productID, 1).Update("stock", gorm.Expr("stock - 1")) - 账户余额扣减:
db.Model(&account).Where("id = ? AND balance >= ?", accountID, amount).Update("balance", gorm.Expr("balance - ?", amount)) - 关键点:WHERE 条件中必须校验足够余额,否则
Update返回RowsAffected == 0,需据此判断失败并回滚
事务边界必须严格,别混用 db 和 tx
常见静默失败:在 db.Transaction 函数体内,误用原始 db 对象执行操作,导致该操作脱离事务上下文。
- ✅ 正确:所有操作都走传入的
tx *gorm.DB参数,例如tx.Model(&p).Update(...) - ❌ 错误:写成
db.Model(&p).Update(...)—— 这条 SQL 直连数据库,不参与事务,扣减立即生效 - ⚠️ 注意:
tx.Commit()或tx.Rollback()后,tx不可复用;若需重试,必须新开事务
超卖兜底和并发冲突处理
RowsAffected 是唯一可信信号,不能只依赖 error 判断是否扣减成功。
-
result := tx.Model(&p).Where(...).Update(...); if result.Error != nil { return err }不够 —— 错误可能是网络问题,不是业务拒绝 - 必须检查:
if result.RowsAffected == 0 { return errors.New("insufficient stock") } - 若需重试,不要在事务内循环;应在外部捕获该错误后,重新发起请求或降级为队列异步处理
- 避免在事务里调用 HTTP、Redis、日志等 I/O,它们应移至
tx.AfterCommit()注册回调中执行
最易忽略的是 WHERE 条件的完备性——少一个 AND stock >= ?,就等于把原子性让渡给运气;而混用 db 和 tx 则会让整个事务形同虚设,表面成功,实则数据早已错乱。











