GORM事务必须显式开启并手动提交或回滚,因默认autocommit=true,每个操作独立;秒杀等强一致性场景须用db.Begin()启动事务,内含SELECT FOR UPDATE和带stock>0条件的UPDATE,并检查RowsAffected,避免超卖。

事务必须显式开启,不能依赖自动提交
GORM 默认每个 Save、Create、Update 都是独立事务(autocommit=true),秒杀场景下这等于把库存扣减和订单创建拆成两个原子操作,根本防不住超卖。你得手动调用 db.Begin() 启动事务,并在成功后显式 tx.Commit(),失败时 tx.Rollback()。别信“GORM 会自动处理”这种说法——它不会。
常见错误是只对写操作加了事务,但忘了校验库存那一步也得在事务内做 SELECT FOR UPDATE。否则并发请求仍可能同时读到相同库存值,再各自扣减。
- 必须用
SELECT ... FOR UPDATE锁住库存行,GORM 写法是tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item) - 避免在事务外查库存再传入事务,那已经是过期快照
- 事务生命周期要短:只包必要操作(查库存、扣减、建订单),别塞日志记录、HTTP 调用这类非数据库动作
WHERE 条件里必须带库存余量判断,不能只靠程序逻辑
即使开了事务、用了 FOR UPDATE,如果只执行 UPDATE inventory SET stock = stock - 1 WHERE id = ?,还是可能扣出负数。因为事务只保证操作串行,不保证业务规则——两个请求同时拿到 stock=1,都执行 -1,结果就是 -1。
真正安全的做法是把库存校验下沉到 SQL 层,让数据库自己拒绝非法更新:
result := tx.Model(&Inventory{}).Where("id = ? AND stock > 0", itemID).Update("stock", gorm.Expr("stock - 1"))然后立刻检查 result.RowsAffected == 1。为 0 就说明库存不足或已被抢光,直接返回失败,不建订单。
- 这个 WHERE 条件必须包含
stock > 0,不能只靠 Go 层 if 判断 -
gorm.Expr是必须的,否则 GORM 会把stock - 1当作字面值插入 - 不要用
Find + Save模式更新库存,那是两轮 SQL,中间有窗口期
事务失败后,缓存和下游服务必须有补偿机制
事务回滚只影响数据库,但如果你之前已更新 Redis 中的预扣库存,或已发 MQ 消息,这些操作不会自动撤销。Gin 接口里常见的“先减缓存再扣 DB”模式,在事务失败时会导致 Redis 和 MySQL 数据不一致。
正确做法是把缓存和消息类操作放在 Hooks 或事务提交后触发:
- 用
AfterCommitHook 发送订单创建成功事件,而不是在事务内publish - Redis 扣库存应延迟到事务确认提交后才执行,或改用 Lua 脚本+DB 双检(更重,但强一致)
- 如果用了乐观锁(比如 version 字段),记得事务失败时清掉本地缓存,防止下次读到脏数据
连接池和隔离级别不配好,事务形同虚设
就算代码写对了,MySQL 连接池最大连接数设成 10,而秒杀瞬间来了 500 请求,大量 goroutine 卡在 db.Begin() 等连接,实际并发度远低于预期。更隐蔽的是隔离级别:InnoDB 默认是 REPEATABLE READ,但它对“当前读”(如 SELECT ... FOR UPDATE)其实等效于 READ COMMITTED,这点很多人误以为高隔离级别能防幻读,其实秒杀场景下关键还是行锁粒度和索引是否生效。
- 检查
db.SetMaxOpenConns(),至少设为预估峰值 QPS 的 2–3 倍 - 确保库存表主键或查询条件字段有索引,否则
FOR UPDATE会锁整张表 - 避免在事务中执行
SELECT * FROM inventory这类无条件全表扫描,会升级为表锁
事务本身不解决跨系统一致性,它只管 MySQL 里的几行数据。真正难的不是写对 Begin/Commit,而是想清楚哪些操作必须捆进同一个事务边界,哪些必须靠异步补偿来兜底——尤其当你的库存状态分散在 Redis、DB、甚至第三方服务里时。


















