在电商秒杀、抢购等高并发场景中,库存扣减的一致性一直是系统设计的重中之重。想象一下,大量的用户同时抢购一件商品,如果库存扣减不当,轻则出现超卖,重则引发用户投诉甚至业务损失。很显然,传统的数据库很难应对这种超高QPS的情况,而Redis则凭借自身的高性能优点成为解决这一难题的首选方案。
01 库存的扣减问题
在Redis中,最直接的库存扣减方式就是常用的“读-改-写”方式:即首先通过GET命令读取当前库存,然后计算扣减后的值,最后使用SET命令写回新值。
这种操作在比较低的并发环境下基本没有什么问题。但是,随着请求量的激增,其问题就暴露出来了。如下图所示:
这里假设初始库存为100,两个线程同时执行:
-
线程A读取库存100,计算100-10=90;
-
线程B也同时读取库存100,计算100-10=90;
-
这时候,线程A写入90,线程B也同样写入90,最终库存则变为90,而我们希望的正确值应该为80。
很显然,这种“读-改-写”的方式由于缺乏其原子性,在高并发环境下会导致数据被覆盖,从而导致超卖等现象,使其成为高并发场景中的典型问题。究其原因,是因为Redis的GET和SET是独立命令,缺乏其事务性保障,多个客户端的并发操作无法保证数据一致性。
02 原子性操作:Lua脚本
Redis支持Lua脚本,通过Lua脚本可以将多条命令封装为一个原子操作。Redis的单线程模型确保了Lua脚本在执行期间不会有其他命令插队,从而利用其单线程执行特性来避免了并发冲突的可能。与此同时,DECRBY本身也是原子操作,直接在内存中完成扣减,避免了“读-改-写”的并发问题。
通过Lua脚本在单次操作中完成库存判断与扣减,如下示例代码所示:
-- 获取库存值,若不存在则默认为0
local product_stock = tonumber(redis.call('GET', KEYS[1]) or 0)
-- 获取扣减量
local count = tonumber(ARGV[1])
-- 检查扣减量是否合法(非空且大于0)
if not count or count <= 0 then
-- 返回-2表示扣减量非法
return -2
end
-- 判断库存是否足够
if product_stock >= count then
-- 扣减库存并返回新库存值
return redis.call('DECRBY', KEYS[1], count)
else
-- 返回-1表示库存不足
return -1
end
该方式无需加锁,单线程执行防止并发冲突的可能。其性能较高,特别适用于中小规模的秒杀活动,也是大部分系统所采用的方案。
02 原子性操作:WATCH与事务(乐观锁)
而对于不希望引入Lua脚本的场景,可以使用Redis的WATCH和事务机制实现乐观锁。
Redis的WATCH和EXEC可以提供类似于事务的机制,通过WATCH监控库存键值,再结合MULTI/EXEC事务实现乐观锁。如下面的伪代码片段所示:
# 监控库存key,开始乐观锁
WATCH product_stock
# 获取当前库存
product_stock = GET product_stock
# 判断库存是否足够
if product_stock >= count then
# 开启事务
MULTI
# 扣减库存
DECRBY product_stock count
# 执行事务
if EXEC then
# 返回0表示成功
return 0
else
# 返回-2表示事务失败(库存被修改)
return -2
endif
else
# 库存不足,取消监控
UNWATCH
# 返回-1表示库存不足
return -1
endif
其中,WATCH监控product_stock,如果在EXEC前key被其他客户端修改,则事务会执行失败,直接返回-2;若库存足够且事务成功,则返回0。
这种乐观锁假设冲突较少,适用于读多写少、冲突概率较低的高并发场景。
03 分布式锁:SETNX与RedLock
在更高的并发或分布式环境下,单节点原子性可能不足,这种场景下分布式锁就成为了备选方案之一。例如,您可以使用SETNX来实现简单锁,如下面的伪代码片段所示:
# 尝试加锁,设置5秒过期时间
SET product_lock_123 "token" NX PX 5000
# 判断加锁是否成功
if lock_success then
# 获取库存
product_stock = GET product_stock
# 双重检查库存是否足够
if product_stock >= count then
# 扣减库存
DECRBY product_stock count
# 释放锁
DEL product_lock_123
# 返回0表示成功
return 0
else
# 释放锁
DEL product_lock_123
# 返回-1表示库存不足
return -1
endif
else
# 返回-2表示加锁失败
return -2
endif
在上面的示例种,使用“SET NX PX ”确保互斥性,PX 防止死锁,双重检查避免锁过期后的数据不一致性。锁成功后执行库存操作,以确保单线程处理同一商品,从而避免其冲突。它适用于分布式环境下的中小规模扣减场景。
除此之外,很多人都推荐使用“ SETNX + EXPIRE”来实现锁,其实这里还是推荐使用“ SET ... NX PX ”来实现分布式锁:
-
首先,它是原子操作,从而避免了“SETNX + EXPIRE”的非原子性问题;
-
其次,它是 Redis 官方推荐的方式(详细参考 Redlock 算法)。
当然,在 Redis 集群中,单点失效可能导致锁失效,而RedLock 通过多节点加锁来提升可靠性,您可以直接使用RedLock。
04 异步处理:消息队列削峰
在面临更高秒杀与抢购的并发场景中,您还可以通过异步处理结合 “Redis 预扣库存、消息队列异步更新和定时任务回补库存”的方式进行处理,其主要设计思路如下:
-
Redis 预扣库存:利用 Redis 的内存操作和原子性,快速预扣库存并生成订单,以确保库存扣减原子性,避免超卖等情况,降低实时压力;
-
消息队列削峰:将订单操作记录推入RabbitMQ或者RocketMQ等消息队列,从而将数据库写操作从实时转为异步,平滑流量峰值;
-
幂等性保障:通过 Redis Set 的 SADD 操作防止重复订单,确保操作唯一性;
-
库存回补与版本号校验:支付超时后,定时任务根据版本号校验回补库存,保证数据一致性,避免并发回补导致库存错误。
如下面的流程图所示:
通过 Redis 预扣库存、消息队列异步更新和定时任务回补库存,该方案在高并发秒杀场景中实现了性能与一致性的平衡,同时,该方案需要注意如下几点:
-
JVM 限流:单机每 10ms 限制请求数(如 100 次),防止瞬时流量过载 Redis,保护Redis热点Key。
-
库存预热:活动开始前,将热点商品库存加载至 Redis 分片(如 product_stock:sku_id),提升访问效率。
-
队列积压处理:设置队列长度阈值(如 10 万条),超限时触发报警并扩容消费者。
-
定时任务:使用任务调度系统,每分钟扫描超时订单,以及时回补库存。
-
Redis 持久化与降级方案:如启用 AOF(每秒同步)或 RDB(定时快照),防止宕机数据丢失;若 Redis 不可用,是否切换至数据库悲观锁(SELECT FOR UPDATE),QPS 降至数百。
05 最后小结
通过以上方法,可以有效地解决高并发场景下的库存一致性问题,但需根据业务量级与容忍度在性能与强一致性间权衡。具体怎么用,还得结合业务折衷选择,任何脱离了业务实际情况的架构设计都是耍流氓!