高并发架构实践:秒杀、抢购等库存扣减一致性问题的Redis解决方案

在电商秒杀、抢购等高并发场景中,库存扣减的一致性一直是系统设计的重中之重。想象一下,大量的用户同时抢购一件商品,如果库存扣减不当,轻则出现超卖,重则引发用户投诉甚至业务损失。很显然,传统的数据库很难应对这种超高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脚本在单次操作中完成库存判断与扣减,如下示例代码所示:

-- 获取库存值,若不存在则默认为0local 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 -1end

该方式无需加锁,单线程执行防止并发冲突的可能。其性能较高,特别适用于中小规模的秒杀活动,也是大部分系统所采用的方案。

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    endifelse    # 库存不足,取消监控    UNWATCH    # 返回-1表示库存不足    return -1endif

其中,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    endifelse    # 返回-2表示加锁失败    return -2endif

在上面的示例种,使用“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 最后小结

通过以上方法,可以有效地解决高并发场景下的库存一致性问题,但需根据业务量级与容忍度在性能与强一致性间权衡。具体怎么用,还得结合业务折衷选择,任何脱离了业务实际情况的架构设计都是耍流氓!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值