从超卖到原子性:Redis Lua 解决秒杀库存扣减实战
1.背景与问题
在写秒杀系统的时候,由于多个下单操作之间是并行执行的,就会导致超卖问题,即产品售出超过了产品库存,导致库存变为负数。由于下单操作是并行进行的,单纯的判断库存是否大于0并不能防止超卖现象的发生。不解决超卖问题的后果就是订单多于库存,无法正常发货交货。
2.排查过程
第一阶段:MySQL直接扣减(问题暴漏)
go
//最初代码 查库存 + 判断 + 库存扣减
product, _ := db.First(&product,id)
if product.Stock > 0 {
product.Stock--
db.Save(&product)
}
问题:三步操作并非原子性的,是并行执行的,当多个请求同时进行时,就会出现超卖问题。
压测现象:10个并发请求, 库存变成-9,订单10个,超卖9个
第二阶段:MySQL悲观锁(性能差)
go
// 尝试:SELECT FOR UPDATE
db.Transaction(func(tx *gorm.DB) error {
tx.Set("gorm:query_option", "FOR UPDATE").First(&product, id)
if product.Stock > 0 {
product.Stock--
tx.Save(&product)
}
return nil
})
结果 :超卖解决,但 QPS 只有 200,数据库连接被锁占用,吞吐量太低。
第三阶段:Redis DECR(新问题)
go
//尝试redis原子扣减
stock , _ := rdb.Decr(ctx, "stock:1").Result()
if stock < 0 {
// 已经扣减成负数了,无法回滚
}
问题 :DECR 不判断直接扣,库存变成 -5,反向超卖
第四阶段:Redis Lua (最终方案)
Lua脚本把查和扣打包成原子操作,Redis单线程执行,解决并发问题
go
// 在go里定义lua脚本和操作
var deductStockScript = redis.NewScript(`
local productKey = KEYS[1] -- 商品库存key,如:"product:1001:stock"
local quantity = tonumber(ARGV[1]) -- 购买数量
--获取当前库存
local currentStock = tonumber(redis.call("get",productKey) or 0)
--检查库存是否充足
if currentStock < quantity then
return -1 --库存不足
end
-- 扣减库存
local newStock = currentStock - quantity
redis.call("set",productKey, newStock)
return newStock --返回剩余库存
`)
// InitProductStock 初始化商品库存到redis
func InitProductStock(productID string, stock int) error {
key := "product:" + productID + ":stock"
return rdb.Set(context.Background(), key, stock, 0).Err()
}
// SafeDeductStock 安全扣除库存
func SafeDeductStock(productID string, quantity int) (int, error) {
key := "product:" + productID + ":stock"
result, err := deductStockScript.Run(context.Background(), rdb, []string{key}, quantity).Int()
if err != nil {
return -1, err
}
if result == -1 {
return -1, nil
}
return result, nil
}
定义redis连接和初始化
go
var rdb *redis.Client
func init() {
rdb = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
log.Fatal("无法连接redis: ", err)
}
}
在main.go里将库存加载到到redis
go
if err := initRedisStock(db); err != nil {
panic("Redis 库存初始化失败: " + err.Error())
}
func initRedisStock(db *gorm.DB) error {
var products []model.Product
if err := db.Find(&products).Error; err != nil {
return err
}
for _, p := range products {
if err := database.InitProductStock(strconv.Itoa(int(p.ID)), p.Stock); err != nil {
return err
}
}
return nil
}
在handler里使用
go
func Buy(c *gin.Context, db *gorm.DB) {
productIDStr := c.PostForm("product_id")
userID := c.PostForm("user_id")
// 1. 参数校验
productID, err := strconv.ParseUint(productIDStr, 10, 64)
if err != nil {
c.JSON(400, gin.H{"message": "参数错误"})
return
}
// 2. 扣 Redis 库存(原子操作)
remaining, err := database.SafeDeductStock(productIDStr, 1)
if err != nil {
c.JSON(500, gin.H{"message": "系统错误"})
return
}
if remaining == -1 {
c.JSON(400, gin.H{"message": "库存不足"})
return
}
// 3. 创建订单(调用 DAO)
order := model.Order{
ProductID: uint(productID),
UserID: userID,
Status: "success",
}
if err := dao.CreateOrder(db, &order); err != nil {
c.JSON(500, gin.H{"message": "订单创建失败"})
return
}
c.JSON(200, gin.H{
"message": "购买成功",
"stock": remaining,
})
}
方案对比
| 方案 | QPS | 超卖? | 问题 |
|---|---|---|---|
| MySQL直接扣减 | 500 | 是 | 非原子操作 |
| MySQL悲观锁 | 200 | 否 | 性能差 |
| Redis DECR | 5000 | 是 | 超卖 |
| Redis Lua | 4500 | 否 | 综合最优 |
3.核心方案:lua 原子操作
为什么Lua能保证原子性?
Redis内置了Lua脚本引擎,这使得我们可以在服务端原子性的执行一系列命令并且以此来避免网络多次往返,保证操作的原子性
Redis执行Lua脚本是原子的,将下单的查库存和库存扣减编写成一个原子性的操作,防止由于并行操作导致的超卖问题。
4.验证与数据


当库存清零时则扣减失败