从超卖到原子性:Redis Lua 解决秒杀库存扣减实战

从超卖到原子性: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.验证与数据

当库存清零时则扣减失败

相关推荐
Grassto8 小时前
16 Go Module 常见问题汇总:依赖冲突、版本不生效的原因
golang·go·go module
怕浪猫1 天前
第16章:标准库精讲(二)net/http、json、time
后端·go·编程语言
下次一定x1 天前
深度解析Kratos服务注册:从框架入口到Consul落地实现
后端·go
cppgo3 天前
for range的使用注意事项
go
cppgo3 天前
使用bufio Writer时,手动调用Flush()的必要性
go
我叫黑大帅3 天前
深入理解Go语言的核心:Type-Value Pair(类型-值对)
后端·面试·go
我叫黑大帅3 天前
深入理解Go语言结构体标签:用途、用法与注意事项
后端·面试·go
lifallen3 天前
CPU 可见性、乱序执行与 Go 内存模型
java·开发语言·数据结构·go