gorm的for update在秒杀中易失效,因未满足事务包裹、唯一索引命中、显式提交三条件;且隔离级别、幂等缺失、外部调用拖长事务等导致超卖,应作为redis预减后的第二道闸门。

为什么 GORM 的 FOR UPDATE 在秒杀里容易失效
直接用 db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", skuID).First(&stock) 锁一行再扣减,在低并发下能跑通,但秒杀场景下基本扛不住。根本问题不是语法错,而是锁住的只是「单行」,而 MySQL 的 FOR UPDATE 在非唯一索引或范围条件上会升级为间隙锁(gap lock),导致无关商品也被阻塞;更关键的是,事务一旦没及时提交,锁就一直挂着,连接池快速耗尽,后续请求全卡在 waiting for table metadata lock 或超时失败。
FOR UPDATE 必须搭配事务 + 唯一索引 + 显式提交
想让它真正起作用,三个条件缺一不可:
- 必须包裹在
db.Transaction()里,不能只靠Find()加锁后手动改再 Save —— 那样锁在 Find 后就释放了 - WHERE 条件必须命中唯一索引(如主键或
sku_id上的 UNIQUE 索引),否则可能锁住整个索引区间 - 扣减逻辑完成后必须立刻
tx.Commit(),不能 defer 或漏写;若中间出错要tx.Rollback() - 建议加
Options: "NOWAIT"避免无限等待:clause.Locking{Strength: "UPDATE", Options: "NOWAIT"},失败时直接报ErrLockWaitTimeout而非 hang 住
即使加了锁,超卖仍可能发生的真实原因
很多人以为上了 FOR UPDATE 就万事大吉,结果压测时还是超卖。常见根因有:
- MySQL 隔离级别是
REPEATABLE READ(默认),SELECT ... FOR UPDATE在无唯一索引时会锁住 gap,但插入新记录(如下单)可能绕过——尤其当订单表没建user_id + sku_id联合唯一索引时 - 应用层没做幂等:同一请求重试多次,每次都在不同事务里执行了锁+扣减
- 库存字段没设
CHECK (stocks >= 0),数据库层不拦负数,只靠代码判断易被跳过 - 事务中混用了 Redis 或外部调用(比如发 MQ),导致事务时间拉长,锁持有太久
别单独依赖 FOR UPDATE,它只是兜底不是主力
真实秒杀链路里,FOR UPDATE 的定位应该是「第二道闸门」:Redis 预扣减已筛掉 95% 流量,剩下进 DB 的请求才走这层锁。这意味着:
- DB 层每秒最多承受几百次请求,所以事务必须极轻量——只查、只改库存字段,不做日志、不调远程服务、不嵌套子查询
- 库存扣减成功后,必须异步落单(比如写 Kafka),不能同步 INSERT 订单,否则又拖慢事务
- 务必配监控:跟踪
innodb_row_lock_time_avg和Threads_waiting_for_lock,一旦平均锁等待超 10ms 就得告警
最常被忽略的一点:FOR UPDATE 锁的是「当前读」快照,但如果库存字段被其他未提交事务修改过,你查到的值可能是旧的——所以锁之后必须立刻 SELECT ... FOR UPDATE 再读一次,不能复用之前缓存的值。











