一文搞懂库存扣减的三种姿势,再也不怕超卖
前言
电商系统里,库存扣减是最常见的并发场景。
两个用户同时下单,库存只剩 1 件,谁能买到?
这就是并发控制要解决的问题。
今天用最精简的方式,讲清楚三种方案:原子操作、悲观锁、乐观锁。
一、超卖是怎么发生的?
先看一段错误的代码:
go
go
// ❌ 先查后改 ------ 经典错误
func DeductStock(productID uint) {
var p Product
db.First(&p, productID) // 1. 查询库存:10
p.Stock = p.Stock - 1 // 2. 计算:9
db.Save(&p) // 3. 保存:9
}
并发执行时:
| 时刻 | 用户A | 用户B | 库存 |
|---|---|---|---|
| T1 | 查询 → 10 | 10 | |
| T2 | 查询 → 10 | 10 | |
| T3 | 计算 → 9 | 10 | |
| T4 | 计算 → 9 | 10 | |
| T5 | 保存 → 9 | 9 | |
| T6 | 保存 → 9 | 9 ❌ |
卖了 2 件,库存只减了 1 件 → 超卖!
二、方案一:原子操作 ⭐⭐⭐⭐⭐
一句话解释
把"读-算-写"合并成一条 SQL,数据库保证不可分割。
代码实现
go
less
// ✅ 一条 SQL 搞定
db.Model(&Product{}).
Where("id = ? AND stock >= 1", productID).
Update("stock", gorm.Expr("stock - 1"))
实际生成的 SQL
sql
ini
UPDATE products
SET stock = stock - 1
WHERE id = 1 AND stock >= 1;
优缺点
| 优点 | 缺点 |
|---|---|
| 代码最简洁 | 只能处理简单加减 |
| 性能最高 | 复杂业务逻辑不适用 |
| 天然原子性 | - |
使用场景
✅ 适合 99% 的库存扣减场景,优先选择!
三、方案二:悲观锁 🔒
一句话解释
"先锁住再改,别人必须排队等。"
代码实现
go
go
// ✅ 悲观锁实现
db.Transaction(func(tx *gorm.DB) error {
var p Product
// SELECT ... FOR UPDATE ------ 行锁
tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", productID).
First(&p)
if p.Stock < 1 {
return errors.New("库存不足")
}
p.Stock -= 1
tx.Save(&p)
return nil
})
原理图解
sql
sql
-- 事务1:先锁住
BEGIN;
SELECT * FROM products WHERE id = 1 FOR UPDATE; -- 加锁
-- 此时事务2的查询会被阻塞
UPDATE products SET stock = 9 WHERE id = 1;
COMMIT; -- 释放锁,事务2才能继续
优缺点
| 优点 | 缺点 |
|---|---|
| 绝对安全,不会超卖 | 性能差,高并发下排队 |
| 实现直观 | 可能死锁 |
| 适合高冲突场景 | 占用数据库连接时间长 |
使用场景
✅ 秒杀、抢购等高冲突场景
四、方案三:乐观锁 🕊️
一句话解释
"带版本号更新,对不上就重试。"
代码实现
go
go
type Product struct {
ID uint
Stock int
Version int // 版本号字段
}
func DeductStock(productID uint) error {
for retry := 0; retry < 3; retry++ {
var p Product
db.First(&p, productID)
oldVersion := p.Version
result := db.Model(&Product{}).
Where("id = ? AND version = ? AND stock >= 1",
productID, oldVersion).
Updates(map[string]interface{}{
"stock": gorm.Expr("stock - 1"),
"version": gorm.Expr("version + 1"),
})
if result.RowsAffected > 0 {
return nil // 成功
}
// 版本号变了,重试
time.Sleep(time.Millisecond * 10)
}
return errors.New("更新失败,请重试")
}
原理图解
text
ini
用户A: 查询(version=1) → 更新(where version=1) → 成功(version=2)
用户B: 查询(version=1) → 更新(where version=1) → 失败 → 重试
优缺点
| 优点 | 缺点 |
|---|---|
| 性能好,无锁等待 | 需要写重试逻辑 |
| 不会死锁 | 冲突严重时性能下降 |
使用场景
✅ 低冲突的普通商品
五、一图看懂选哪个
text
markdown
你的业务场景是什么?
│
├── 简单扣库存(无复杂业务)
│ └── ✅ 原子操作 ------ 最推荐!
│
├── 秒杀/抢购(高并发冲突)
│ └── ✅ 悲观锁 + 事务
│
├── 普通商品(低冲突)
│ └── ✅ 乐观锁
│
└── 复杂业务(多表操作)
└── ✅ 悲观锁 + 事务
六、终极对比表
| 维度 | 原子操作 | 悲观锁 | 乐观锁 |
|---|---|---|---|
| 一句话 | 一条SQL搞定 | 先锁再改 | 版本号校验 |
| 并发安全 | ✅ | ✅ | ✅ |
| 性能 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| 代码量 | 少 | 中 | 多 |
| 适用场景 | 大多数场景 | 秒杀抢购 | 普通商品 |
| 需要重试 | ❌ | ❌ | ✅ |
| 死锁风险 | ❌ | ✅ | ❌ |
七、生产级代码(直接复制用)
go
go
// ✅ 最推荐的方案:原子操作 + 事务
func DeductStock(productID uint, quantity int) error {
return db.Transaction(func(tx *gorm.DB) error {
// 原子扣减,同时检查库存
result := tx.Model(&Product{}).
Where("id = ? AND stock >= ?", productID, quantity).
Update("stock", gorm.Expr("stock - ?", quantity))
if result.RowsAffected == 0 {
return errors.New("库存不足")
}
// 创建订单...
// 记录流水...
return nil
})
}
八、总结
记住四句话:
- 先查后改 = 超卖风险 ❌
- 原子操作 = 简单高效,首选 ✅
- 悲观锁 = 先锁再改,适合高并发 🔒
- 乐观锁 = 版本重试,适合低冲突 🕊️
没有银弹,根据业务场景选择!