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

当库存清零时则扣减失败

相关推荐
曲幽16 小时前
掏出手机就能搭个 WebDAV 同步服务器?这操作有点香
go·termux·tampermonkey·sync·webdav·filebrowser·gowebdav·koreader
Code_Artist1 天前
🦜用 GoAI 从零打造一个 AI Agent 脚手架工程:重新定义智能体开发范式!
go·agent·ai编程
ShuiShenHuoLe2 天前
OS的常用函数
go
踏着七彩祥云的小丑2 天前
Go学习第8天:接口 + 泛型 + 错误处理
开发语言·学习·golang·go
蓝宝石的傻话3 天前
rpi-cam:给 Raspberry Pi 造的轻量级 ONVIF 相机服务
go·iot·nvr
蓝宝石的傻话3 天前
VictoriaMetrics指标流聚合三年回顾与现状(2026)
go·prometheus·victoriametrics
踏着七彩祥云的小丑3 天前
Go学习第7天:Map集合 + 递归函数 + 类型转换
开发语言·学习·golang·go
踏着七彩祥云的小丑4 天前
Go 学习第6天:结构体 + 切片 + range遍历
开发语言·学习·golang·go
壮Sir不壮4 天前
GO语言——GMP调度模型
linux·开发语言·golang·go·操作系统·线程·协程