高并发场景下基于Redis的库存扣减与防超卖实践
在高并发的业务场景中,例如电商秒杀活动,如何确保库存扣减的准确性并有效防止超卖是一个核心挑战。不当的库存管理可能导致订单量超过实际库存,从而引发用户投诉、品牌信誉受损等问题。本文将探讨如何在go-zero框架下,利用Redis的Lua脚本和分布式锁机制,构建健壮的库存防超卖方案。
Redis Lua脚本实现原子性库存扣减
Lua是一种轻量级脚本语言,Redis原生支持在服务器端执行Lua脚本。将一系列Redis操作封装到Lua脚本中,可以实现这些操作的原子性执行。这意味着在脚本执行期间,Redis服务器不会处理其他客户端的命令,从而有效避免了并发竞争条件。
Lua脚本的优势
- 减少网络开销: 客户端可以将多个Redis命令打包成一个Lua脚本发送给服务器,显著减少客户端与服务器之间的网络往返次数。
- 原子性操作: Redis将整个Lua脚本作为一个不可分割的整体执行。这保证了脚本内部的所有操作要么全部成功,要么全部失败,无需额外的事务机制即可确保数据一致性。
- 脚本复用性: 客户端发送的脚本会被Redis缓存,后续请求可以通过脚本的SHA1摘要值来执行,进一步提高效率。
系统环境配置示例 (go-zero)
首先,需要在go-zero服务中配置Redis客户端。这通常在ServiceContext中完成:
type ServiceContext struct {
Config config.Config
RedisClient *redis.Redis // go-zero 提供的 Redis 客户端
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
RedisClient: redis.New(c.Redis.Host, func(r *redis.Redis) {
r.Type = c.Redis.Type
r.Pass = c.Pass
}),
}
}
初始化Redis库存数据
在秒杀活动开始前,需要将商品的初始库存预先加载到Redis中,并设置合理的过期时间,通常与活动结束时间一致。
// 假设活动截止时间为 productActivity.EndTime,当前时间为 now
remainingSeconds := productActivity.EndTime.Unix() - now.Unix()
// 构建 Redis 键,结合前缀和商品 ID
productStockKey := fmt.Sprintf("%s%v", model.CacheProductStockPrefix, productActivity.ProductID)
// 将库存数量以字符串形式存储到 Redis 中,并设置过期时间
_, err := l.svcCtx.RedisClient.SetnxExCtx(l.ctx, productStockKey, strconv.FormatInt(productActivity.InitialStock, 10), int(remainingSeconds))
if err != nil {
return nil, errors.Wrapf(xerr.NewErrMsg("Redis库存初始化失败"), "Redis库存初始化错误: %v", err)
}
基于Lua脚本的库存扣减逻辑
以下Lua脚本用于原子性地检查库存并进行扣减。此脚本在扣减指定数量的库存时,如果库存不足,则返回-1。
local stockKey = KEYS[1] -- 获取第一个键,即库存的 Redis 键
local deductionAmount = tonumber(ARGV[1]) -- 获取第一个参数,即需要扣减的数量
-- 获取当前库存值(从 Redis 中获取的值是字符串类型)
local currentStock = redis.call('get', stockKey)
-- 将库存值从字符串转换为数字
currentStock = tonumber(currentStock)
-- 检查库存是否充足且键存在
if currentStock and currentStock >= deductionAmount then
-- 如果库存足够,执行扣减并返回新的库存值
return redis.call('decrby', stockKey, deductionAmount)
else
-- 如果库存不足或键不存在,返回-1
return -1
end
脚本说明:
KEYS[1]用于获取传入的第一个键名(即库存键)。ARGV[1]用于获取传入的第一个参数(即扣减数量)。redis.call('get', stockKey)获取当前库存值,Redis返回的是字符串。tonumber()将字符串库存转换为数字进行比较。redis.call('decrby', stockKey, deductionAmount)原子性地扣减指定数量的库存。
在Go代码中执行Lua脚本
在go-zero服务中,通过EvalCtx方法执行上述Lua脚本,实现库存扣减。
// 构建 Redis 键
productStockKey := fmt.Sprintf("%s%v", model.CacheProductStockPrefix, purchaseRequest.ProductID)
deductQuantity := 1 // 例如,每次购买扣减1个库存
// 执行 Lua 脚本进行原子性库存扣减
scriptResult, err := l.svcCtx.RedisClient.EvalCtx(l.ctx, deductionLuaScript, []string{productStockKey}, deductQuantity)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrMsg("Redis库存扣减失败"), "Redis EvalCtx error: %v", err)
}
// 检查脚本执行结果
if scriptResult == nil || scriptResult.(int64) == -1 {
return nil, errors.Wrap(xerr.NewErrMsg("库存不足"), "商品库存已售罄或不存在")
}
// 扣减成功,scriptResult 即为新的库存值
// 可以选择记录订单或进行其他后续业务逻辑
EvalCtx方法将Lua脚本、键列表和参数列表发送给Redis。Redis服务器会原子性地执行脚本,并将结果返回给客户端。这种方式确保了高并发下的库存数据一致性。
动态调整Redis库存
除了用户购买扣减,业务中可能存在商家手动调整库存的需求(如增加库存或进行批量扣减)。这种场景同样需要原子性操作,以避免数据不一致。我们可以使用一个更通用的Lua脚本来处理这类库存调整。
local targetKey = KEYS[1] -- 库存键
local adjustmentVal = tonumber(ARGV[1]) -- 调整数量(正数表示增加,负数表示扣减)
local currentVal = redis.call('get', targetKey)
if currentVal == false then -- 键不存在,返回错误
return -1
end
currentVal = tonumber(currentVal)
-- 根据调整值进行操作
if adjustmentVal > 0 then
-- 增加库存
return redis.call('incrby', targetKey, adjustmentVal)
else
-- 扣减库存,且需要确保不出现负数库存
-- actualDeduction 为实际要扣减的数量,取 adjustmentVal 的绝对值与当前库存的较小值
local actualDeduction = math.min(math.abs(adjustmentVal), currentVal)
return redis.call('decrby', targetKey, actualDeduction)
end
注意: 上述脚本处理了库存的增减操作,且在扣减时避免了库存变为负数。实际业务中,可以根据具体需求调整逻辑。
分布式锁解决缓存穿透问题
在高并发场景下,如果大量请求查询一个Redis中不存在的商品库存(即缓存穿透),这些请求会直接打到后端数据库,可能瞬间击垮数据库服务。为了避免这种情况,可以引入分布式锁。
什么是分布式锁?
分布式锁是一种在分布式系统中协调多个进程或服务对共享资源访问的机制。其核心特性包括:
- 互斥性: 任意时刻,只有一个客户端能持有锁。
- 高可用性: 锁服务应具备容错性,部分节点故障不影响锁的正常工作。
- 防死锁: 即使持有锁的客户端崩溃,锁也应能自动释放。
- 高性能: 锁的获取与释放操作应尽量高效。
分布式锁与Lua脚本的适用场景对比
- Lua脚本: 主要用于确保单体Redis操作序列的原子性,简化复杂的Redis命令序列,减少网络延迟。它不提供跨服务或跨进程的互斥能力。
- 分布式锁: 主要用于协调跨多个服务或进程对共享资源的访问,防止并发问题(如缓存穿透时对数据库的重复查询)。
在本例中,对于库存扣减这种Redis内部的原子操作,Lua脚本是更直接高效的选择。但对于缓存穿透导致数据库压力的场景,分布式锁则更为合适。
利用分布式锁防护缓存穿透
当请求查询的库存信息不在Redis缓存中时,我们使用分布式锁来控制只有一个请求能够回源到数据库查询,并将数据写入缓存。
// 假设要查询的商品 ID 为 productID
productStockKey := fmt.Sprintf("%s%v", model.CacheProductStockPrefix, productID)
cacheExists, err := l.svcCtx.RedisClient.ExistsCtx(l.ctx, productStockKey)
if err != nil {
// 处理 Redis 错误,例如日志记录并返回系统异常
return nil, errors.Wrapf(xerr.NewErrMsg("查询缓存状态失败"), "Redis ExistsCtx error: %v", err)
}
// 如果缓存中不存在该商品的库存信息
if !cacheExists {
// 尝试获取分布式锁,避免多个请求同时回源数据库
lockKey := fmt.Sprintf("%s_lock:%v", model.CacheProductStockPrefix, productID)
// 使用 SetnxExCtx 获取锁,并设置 5 秒过期时间防止死锁
lockAcquired, acquireErr := l.svcCtx.RedisClient.SetnxExCtx(l.ctx, lockKey, "1", 5)
if acquireErr != nil {
return nil, errors.Wrapf(xerr.NewErrMsg("获取分布式锁失败"), "SetnxExCtx error: %v", acquireErr)
}
if !lockAcquired {
// 未获得锁的请求,表示已有其他请求正在回源查询,此时可以选择:
// 1. 立即返回"系统繁忙,请稍后再试"
// 2. 短暂等待后重试(需谨慎处理,避免过多重试导致雪崩)
return nil, errors.Wrapf(xerr.NewErrMsg("系统繁忙,请稍后再试"), "未能获取到查询锁")
}
// 成功获得锁的请求,负责回源到数据库查询库存并写入缓存
defer func() {
// 无论数据库查询成功与否,最终都要释放锁,避免死锁
_, _ = l.svcCtx.RedisClient.DelCtx(l.ctx, lockKey)
}()
dbStockCount, dbQueryErr := l.svcCtx.ProductModel.GetProductTotalStock(l.ctx, productID) // 假设从数据库查询商品总库存
if dbQueryErr != nil {
return nil, errors.Wrapf(xerr.NewErrMsg("数据库查询库存失败"), "DB query error: %v", dbQueryErr)
}
// 查询到库存后,将其写入 Redis 缓存。
// 缓存过期时间应根据业务场景设定,例如与秒杀活动结束时间保持一致。
initialRedisStock := dbStockCount // 假设直接将数据库总库存作为 Redis 初始库存
cacheDuration := 3600 // 例如,设置缓存有效期为1小时
_, setErr := l.svcCtx.RedisClient.SetExCtx(l.ctx, productStockKey, strconv.FormatInt(initialRedisStock, 10), cacheDuration)
if setErr != nil {
return nil, errors.Wrapf(xerr.NewErrMsg("Redis库存写入失败"), "Redis SetExCtx error: %v", setErr)
}
}
// 此时,无论是否经过数据库回源,Redis中都应该有库存信息了,可以继续进行库存扣减流程
// 例如,再次调用 ExistsCtx 确认或直接执行 Lua 脚本扣减
通过这种方式,只有持有锁的客户端才能访问数据库,大大减轻了数据库在高并发下的压力。未获得锁的请求可以快速返回,避免阻塞,提高了系统的响应性。