一次 FOR UPDATE 没有生效的排查记录
有一段时间,我在一个下单流程里用 SELECT ... FOR UPDATE 锁住库存行,逻辑上看着没问题,但并发压测时依然能复现超卖。
这篇记录一下排查过程,因为结论本身很简单,但找错的方向很有代表性。
现象
并发下扣减库存,最终库存出现了负数。代码大致是这样:
func (r *Repo) Deduct(ctx context.Context, skuID string, qty int) error {
var stock Stock
if err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("sku_id = ?", skuID).
First(&stock).Error; err != nil {
return err
}
if stock.Available < qty {
return ErrInsufficientStock
}
return r.db.Model(&Stock{}).
Where("sku_id = ?", skuID).
Update("available", gorm.Expr("available - ?", qty)).Error
}
SQL 里确实有 FOR UPDATE,看起来锁是加上了。
第一个错误方向:怀疑是锁没加对
我最先去确认 SQL。打开 Debug():
SELECT * FROM `stocks` WHERE sku_id = 'xxx' ORDER BY `stocks`.`id` LIMIT 1 FOR UPDATE
语句没问题,FOR UPDATE 在。
于是开始怀疑 GORM 的 clause.Locking 用法,翻源码、换写法、试 clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}——都没用,还是超卖。
真正的问题:整个操作根本不在一个事务里
FOR UPDATE 的行锁,生命周期绑定在事务上。事务提交或回滚时锁才释放。
而上面那段代码,两次数据库调用是各自独立的:
- 第一条
SELECT ... FOR UPDATE在没有显式事务时,走的是 autocommit——语句执行完,事务立刻结束,锁当场就释放了 - 第二条
UPDATE是另一个 autocommit 事务
所以锁在「查」和「改」之间那段时间里,压根不存在。另一个 goroutine 完全可以在这中间插进来查到同样的库存值。
修法很直接——把两步包进一个事务:
func (r *Repo) Deduct(ctx context.Context, skuID string, qty int) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stock Stock
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("sku_id = ?", skuID).
First(&stock).Error; err != nil {
return err
}
if stock.Available < qty {
return ErrInsufficientStock
}
return tx.Model(&Stock{}).
Where("sku_id = ?", skuID).
Update("available", gorm.Expr("available - ?", qty)).Error
})
}
关键变化是两次调用都用 tx,而不是 r.db。
这件事的教训
行锁是事务的附属品,不是语句的附属品。
顺着这条线,还有几个同源的坑值得一起记住:
事务里用了 r.db 而不是 tx。 只要有一次调用漏了,那一步就跑在事务外。这类错误在 code review 里很难看出来,因为两个变量的类型一模一样。可以在 Repo 层加一个约定:所有需要参与事务的方法,参数名一律叫 tx。
锁的范围比想象中大。 FOR UPDATE 锁的是索引命中的行。如果 sku_id 上没有索引,或者查询条件用不上索引,InnoDB 可能退化成锁一堆行甚至锁表。加锁字段上必须有合适的索引。
加锁顺序会带来死锁。 如果一次事务要锁多行,不同事务的加锁顺序不一致就可能互相等待。批量扣减时按主键排序后再依次加锁,能消掉大部分这类死锁。
持有锁的时间要尽量短。 锁是在事务里,那么事务里的耗时操作(HTTP 调用、消息投递、复杂计算)都会延长持锁时间。把非数据库操作挪到事务外,通常是收益最明显的优化。
怎么快速验证
排查这类问题时,有两个手段特别有效:
一是打开 SQL 日志看事务边界。 GORM 的 Debug() 会打印 BEGIN / COMMIT。如果两条 SQL 之间没有 BEGIN,那它们一定不在同一个事务里。
二是用两个终端手工复现。 开两个数据库会话,都执行 BEGIN; SELECT ... FOR UPDATE;,观察第二个是否阻塞。如果第二个立刻返回,那锁就没生效。这比压测复现快得多,也更能定位到具体是哪一步。
压测能告诉你「有问题」,但不一定能告诉你「问题在哪」。手工构造最小场景往往更快。