关于订单库存扣减的应用实践

针对电商秒杀场景下的库存扣减问题,本文提出了一种利用Redis与Lua脚本结合的方法,有效解决了高并发环境下库存超卖的风险,确保了库存操作的原子性和顺序性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

关于订单库存扣减的应用实践

一: 背景

​ 在电商的业务场景中每个商品都是有库存的,而且可能存在很多限售的运营策略。我们团队面临社区电商的业务场景更为复杂。不仅仅是库存限售,存在区域,门店,用户,运营分组,物流等的限售策略。如何面对日单量千万级别(未来更多),和多个维度的限售策略而不超卖,少卖是一个必须解决的问题。

​ 下面就是库存扣减的流程图。冲图种我们可以看出,要保证整个扣减库存不出问题,限购查询和库存的扣减必须是原子性的而且要单线程执行。

​ 现在处理这种场景存在多种方案。但是要保证高性能和高可用,大部分方案并不满足。

二:探索

1. 历史数据库的事务特性和唯一主键的实现原子操作和单线程操作

​ 基于数据库的事务,扣减库存的操作方法同一个事务中进行库存扣减,事务中任何操作失败,执行回滚操作。从而保证原子性。单纯靠数据库的事务,只能在单体的项目中。如何要分布式的项目中,就无法保证单线程操作了。

​ 那如何在多进程中实现单线程扣减库存呢?我们可以利用数据库的唯一索引。具体操作步骤:

  • 新建立一张表:t_lock_tbl,同时将商品ID作为唯一索引。
  • 进行扣减库存之前在表中插入商品ID,然后进行数据库更新。
  • 更新结束后上次刚才插入数据库中的记录,释放锁。

A线程进程扣减库存时候,插入了该商品的id,当B线程扣减该商品的库存的时候,同样也会在数据库中插入该商品ID,A线程没有执行完B线程插入同一个商品ID就会报主键重复的错误,这样就扣减库存失败。

这种方案,功能上是可以实现,但是过分依赖数据库,无法满足性能要求,而且存在很多获取锁失败的情况,用户体验差。

2. 利用分布式锁

​ Redis 或者 ZooKeeper 来实现一个分布式锁,以商品维度来加锁,在获取到锁的线程中,按顺序去执行商品库存的查询和扣减,这样就同时实现了顺序性和原子性。

​ 其实这个思路是可以的,只是不管通过哪种方式实现的分布式锁,都是有弊端的。以 Redis 的实现来说,仅仅在设置锁的有效期问题上,就让人头大。如果时间太短,那么业务程序还没有执行完,锁就自动释放了,这就失去了锁的作用;而如果时间偏长,一旦在释放锁的过程中出现异常,没能及时地释放,那么所有的业务线程都得阻塞等待直到锁自动失效,这样可能导致CPU飙升,系统吞吐量下降。这与我们要实现高性能的系统是相悖的。所以通过分布式锁的方式可以实现,但不建议使用。

3. Redis + lua 脚本

​ reids,单线程支持顺序操作,而且性能优异,但是不支持事务回滚。但是通过redis+lua脚本可以实现redis操作的原子性。这种方案同时满足顺序性和原子性的要求了。

这里能帮我们实现 Redis 执行 Lua 脚本的命令有两个,一个是 EVAL,另一个是 EVALSHA。

原生 EVAL 方法的使用语法如下:

EVAL script numkeys key [key ...] arg [arg ...]

​ 其中 EVAL 是命令,script 是我们 Lua 脚本的字符串形式,numkeys 是我们要传入的参数数量,key 是我们的入参,可以传入多个,arg 是额外的入参。但这种方式需要每次都传入 Lua 脚本字符串,不仅浪费网络开销,同时 Redis 需要每次重新编译 Lua 脚本,对于我们追求性能极限的系统来说,不是很完美。所以这里就要说到另一个命令 EVALSHA 了,原生语法如下:

EVALSHA sha1 numkeys key [key ...] arg [arg ...]

​ 可以看到其语法与 EVAL 类似,不同的是这里传入的不是脚本字符串,而是一个加密串 sha1。这个 sha1 是从哪来的呢?它是通过另一个命令 SCRIPT LOAD 返回的,该命令是预加载脚本用的,语法为:

SCRIPT LOAD script

​ 这样的话,我们通过预加载命令,将 Lua 脚本先存储在 Redis 中,并返回一个 sha1,下次要执行对应脚本时,只需要传入 sha1 即可执行对应的脚本。这完美地解决了 EVAL 命令存在的弊端,所以我们这里也是基于 EVALSHA 方式来实现的。既然有了思路,也有了方案,那我们开始用代码实现它吧。首先我们根据以上介绍的库存扣减核心操作,完成核心 Lua 脚本的编写。其主要实现的功能就是查询库存并判断库存是否充足,如果充足,则做相应的扣减操作,脚本内容如下:


-- 调用Redis的get指令,查询活动库存,其中KEYS[1]为传入的参数1,即库存key
local c_s = redis.call('get', KEYS[1])
-- 判断活动库存是否充足,其中KEYS[2]为传入的参数2,即当前抢购数量
if not c_s or tonumber(c_s) < tonumber(KEYS[2]) then
   return 0
end
-- 如果活动库存充足,则进行扣减操作。其中KEYS[2]为传入的参数2,即当前抢购数量
redis.call('decrby',KEYS[1], KEYS[2])

然后我们将 Lua 脚本转成字符串,并添加脚本预加载机制。

​ 预加载可以有多种实现方式,一个是外部预加载好,生成了 sha1 然后配置到配置中心,这样 Java 代码从配置中心拉取最新 sha1 即可。另一种方式是在服务启动时,来完成脚本的预加载,并生成单机全局变量 sha1。我们这里先采取第二种方式,代码结构如下图所示:

以上是将 Lua 脚本转成字符串形式,并通过 @PostConstruct 完成脚本的预加载。然后新增 EVALSHA 方法,如下图所示:

方法入参为活动商品库存 key 以及单次抢购数量,并在内部调用 Lua 脚本执行库存扣减操作。看起来是不是很简单?在写完底层核心方法之后,我们只需要在下单之前,调用该方法即可,具体如下图所示:

三:总结

​ 技术的角度分析了库存超卖发生的两个原因。一个是库存扣减涉及到的两个核心操作,查询和扣减不是原子操作;另一个是高并发引起的请求无序。所以我们的应对方案是利用 Redis 的单线程原理,以及提供的原生 EVALSHA 和 SCRIPT LOAD 命令来实现库存扣减的原子性和顺序性,并且经过实测也确实能达到我们的预期,且性能良好,从而有效地解决了秒杀系统所面临的库存超卖挑战。以后再遇到类似的问题,你也可以用同样的解决思路来应对。

<think>我们正在讨论高并发场景下的库存扣减解决方案。库存扣减是一个典型的高并发、高一致性问题,需要确保在大量请求同时到达时,库存数据准确无误且系统性能稳定。根据引用[1],电商大促库存扣减需要解决两大核心问题:数据一致性性能瓶颈。引用[1]提出了一种“缓存原子操作+数据库乐观锁+分布式事务”的三层防护体系。引用[2]则讨论了在高并发场景下使用Redis分布式锁来解决超卖问题,并提到了RedLock算法,但也指出其存在一些待商榷的问题。结合这两点,我们可以设计一个综合方案:1.**缓存层(Redis)**:使用Redis预减库存,利用Lua脚本保证原子性操作,避免超卖。2.**数据库层(MySQL)**:使用乐观锁(版本号)进行并发控制,确保数据库层面的数据一致性。3.**分布式事务**:使用Seata等分布式事务框架保证缓存数据库之间的最终一致性。具体步骤:-步骤1:用户下单时,首先在Redis中执行Lua脚本扣减库存(原子操作),扣减成功则进入下一步,否则返回库存不足。-步骤2:将库存扣减消息发送到消息队列,异步更新数据库库存。这里使用消息队列可以削峰填谷,缓解数据库压力。-步骤3:消费消息时,使用数据库乐观锁更新库存(updatesetstock=stock-1,version=version+1whereid=#{id}andversion=#{old_version}),如果更新失败则进行重试或回滚Redis中的库存(补偿机制)。-步骤4:为了保证分布式事务的最终一致性,可以使用SeataAT模式,将Redis操作数据库操作纳入同一个全局事务中(但注意Redis本身不支持两阶段提交,所以需要结合消息队列补偿机制)。然而,引用[2]提到分布式锁的问题,我们也可以考虑在缓存层使用分布式锁来保证只有一个请求可以操作同一个商品库存,但分布式锁在高并发场景下可能成为瓶颈。因此,我们更倾向于使用Redis的原子操作(Lua脚本)来避免锁竞争。另外,关于分布式锁的选择,引用[2]提到了RedLock,但指出其存在争议。因此,在大部分场景下,使用Redis单节点分布式锁(结合Lua)已经足够,如果对一致性要求极高,可以考虑使用ZooKeeper的分布式锁(但性能较低)。因此,综合方案如下:方案一(推荐):原子操作+异步更新-优点:性能高,避免锁竞争。-步骤:1.Redis使用Lua脚本扣减库存(原子操作)。2.扣减成功后,发送消息到消息队列。3.消费者异步更新数据库,使用乐观锁重试机制。方案二:分布式锁+数据库事务-使用Redisson分布式锁(或ZooKeeper)保证同一时间只有一个请求可以操作某个商品库存。-在锁的保护下,先查Redis库存,再更新数据库,再更新Redis(或者只更新数据库,然后失效缓存)。-优点:逻辑简单;缺点:性能受分布式锁影响,且可能出现死锁等问题。考虑到高并发场景,我们更推荐方案一,因为方案一将压力分散到消息队列,数据库通过异步更新乐观锁重试来保证最终一致性。但是,方案一需要处理消息丢失重复消费的问题。引用[1]提到了消息幂等性:利用UUID缓存实现重复消息的安全处理。具体做法:-生产者:在发送消息时,为每条消息生成一个唯一的业务ID(如订单ID+商品ID),并将这个ID记录在Redis中(设置过期时间)。-消费者:消费消息时,先检查这个业务ID是否已经处理过(通过Redis记录),如果已经处理则直接丢弃,否则正常处理。另外,为了防止消息丢失,我们需要保证消息队列的可靠性(如RabbitMQ的持久化、确认机制等)。总结最佳实践:1.预加载库存:将商品库存加载到Redis中(例如:商品ID为key,库存数量为value)。2.扣减库存:使用RedisLua脚本原子扣减(如果扣减库存>=0,则成功;否则回滚扣减并返回失败)。3.发送消息:扣减成功后,将扣减信息(商品ID、数量、业务唯一ID)发送到消息队列。4.消费消息:消费者接收到消息后,先检查唯一ID是否已处理(通过Redis),若未处理则更新数据库(使用乐观锁),更新成功后标记该消息已处理;若更新失败(版本号不匹配),则重试(设置最大重试次数)或回滚Redis库存(需要回滚脚本)。5.回滚机制:如果数据库更新失败(比如重试多次后仍然失败),则需要将Redis中扣减库存加回去(同样使用Lua脚本保证原子性),并通知订单服务取消订单。注意:回滚操作也要保证幂等性。关于性能优化:-Redis连接数:使用连接池,避免每个请求都创建新连接。-数据库锁竞争:使用乐观锁代替悲观锁,减少锁竞争。这个方案能够有效应对高并发,同时保证数据最终一致性。下面,我们给出一个简化的代码示例(仅展示关键步骤):**RedisLua脚本(扣减库存)**:```lua--KEYS[1]:商品库存key--ARGV[1]:扣减数量--返回:1成功,0失败localstock=tonumber(redis.call('get',KEYS[1])or"0")ifstock>=tonumber(ARGV[1])thenredis.call('decrby',KEYS[1],ARGV[1])return1elsereturn0end```**Java代码(使用SpringBootRedisTemplate)**:```javapublicbooleandeductStock(StringproductId,intnum){Stringscript="上面的Lua脚本";RedisScript<Long>redisScript=newDefaultRedisScript<>(script,Long.class);Longresult=redisTemplate.execute(redisScript,Collections.singletonList("stock:"+productId),String.valueOf(num));returnresult!=null&&result==1;}```**发送消息到MQ**:```java@AutowiredprivateRabbitTemplaterabbitTemplate;publicvoidorder(StringproductId,intnum){//生成唯一业务IDStringbizId=UUID.randomUUID().toString();if(deductStock(productId,num)){//扣减成功,发送消息Map<String,Object>message=newHashMap<>();message.put("productId",productId);message.put("num",num);message.put("bizId",bizId);rabbitTemplate.convertAndSend("stock.deduction",message);}else{thrownewRuntimeException("库存不足");}}```**消费者**:```java@RabbitListener(queues="stock.deduction")publicvoidhandleStockDeduction(Map<String,Object>message){StringbizId=(String)message.get("bizId");StringproductId=(String)message.get("productId");Integernum=(Integer)message.get("num");//幂等性检查if(redisTemplate.opsForValue().get(bizId)!=null){//已处理,直接返回return;}//更新数据库booleansuccess=updateDbStock(productId,num,bizId);if(success){//标记已处理redisTemplate.opsForValue().set(bizId,"processed",10,TimeUnit.MINUTES);}else{//更新失败,重试或回滚Redis(这里需要回滚,回滚后可以重发消息或者记录日志)rollbackRedisStock(productId,num);}}privatebooleanupdateDbStock(StringproductId,intnum,StringbizId){//使用乐观锁更新数据库//伪代码:updateproductsetstock=stock-?,version=version+1whereid=?andversion=?//这里需要先查询当前版本号,然后更新,更新成功返回true,失败返回false//实际中可以使用重试机制(比如最多重试3次)intretry=0;while(retry<3){Productproduct=productDao.selectById(productId);if(product.getStock()<num){//库存不足,需要回滚Redisreturnfalse;}intupdated=productDao.updateStock(productId,product.getVersion(),product.getStock()-num,product.getVersion()+1);if(updated>0){returntrue;}retry++;}returnfalse;}privatevoidrollbackRedisStock(StringproductId,intnum){//回滚Redis库存:增加库存Stringscript="redis.call('incrby',KEYS[1],ARGV[1])";RedisScript<Void>redisScript=newDefaultRedisScript<>(script,Void.class);redisTemplate.execute(redisScript,Collections.singletonList("stock:"+productId),String.valueOf(num));}```注意:上述代码仅为示例,实际应用中需要考虑更多细节,比如事务管理、异常处理、重试策略等。最后,我们还需要注意监控报警,确保在出现问题时能够及时发现处理。综上所述,高并发库存扣减的解决方案需要结合缓存原子操作、消息队列、数据库乐观锁以及幂等性处理,才能同时满足高性能高一致性的要求。§§相关问题§§1.如何保证RedisMySQL之间的数据一致性?2.在高并发场景下,消息队列如何选型(RabbitMQ,Kafka,RocketMQ)?3.库存扣减中如何实现分布式事务的最终一致性?4.除了乐观锁,数据库层面还有哪些并发控制机制?5.如何处理库存扣减中的失败回滚(补偿事务)?</think>### 高并发场景下的库存扣减解决方案 在高并发场景下(如电商大促),库存扣减需同时解决**数据一致性****性能瓶颈**两大核心问题。以下是经过验证的完整解决方案框架: #### 一、分层防护体系(推荐方案) 1. **缓存层原子操作** - 使用 Redis Lua 脚本实现预扣减原子操作 ```lua -- KEYS[1]:库存key, ARGV[1]:扣减数量 local stock = tonumber(redis.call('GET', KEYS[1])) if stock >= tonumber(ARGV[1]) then redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 -- 成功 else return 0 -- 库存不足 end ``` - **优势**:单线程执行避免并发冲突,性能可达 10万+ QPS[^1] 2. **数据库乐观锁** - MySQL 更新时添加版本号校验: ```sql UPDATE inventory SET stock = stock - #{num}, version = version + 1 WHERE product_id = #{id} AND version = #{old_version} AND stock >= #{num} ``` - **优势**:无锁竞争,适合读多写少场景[^1] 3. **分布式事务保障** - 采用 Seata AT 模式实现最终一致性: ```mermaid graph LR A[订单服务] -->|TCC请求| B(库存服务) B -->|预扣减| C[Redis] B -->|异步| D[MySQL] D -->|成功| E[提交日志] D -->|失败| F[补偿回滚] ``` #### 二、关键技术细节 1. **并发冲突处理** - **Redis 连接优化**:连接池化 + Pipeline 批处理 - **热点商品拆分**:库存分桶(如 1000 件拆为 10 个桶) - **限流机制**:令牌桶限流保护数据库 2. **分布式锁进阶方案** ```java // Redisson 红锁实现(多 Redis 节点) RLock lock1 = redisson1.getLock("lock"); RLock lock2 = redisson2.getLock("lock"); RLock lock3 = redisson3.getLock("lock"); RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3); lock.lock(); try { // 临界区操作 } finally { lock.unlock(); } ``` - **注意**:红锁需权衡性能与一致性[^2] 3. **消息幂等性保障** - 唯一ID(UUID+业务标识) + Redis 去重表 - 消费端校验: ```java if(redis.setnx(messageId, "processed", 300s) == 1){ // 处理业务 } ``` #### 三、性能优化对比 | 方案 | QPS | 一致性级别 | 实现复杂度 | |---------------------|----------|------------|----------| | 纯数据库悲观锁 | < 1k | 强一致性 | ★★☆☆☆ | | Redis原子操作 | 100k+ | 最终一致性 | ★★★☆☆ | | 分布式锁+数据库 | 5k-10k | 强一致性 | ★★★★☆ | > **推荐选择**: > - 秒杀场景:Redis原子操作 + 异步队列 > - 普通下单:Seata AT模式 + 乐观锁 > - 金融级场景:红锁 + 数据库事务 #### 四、容灾设计 1. **缓存降级**:Redis 故障时切数据库限流模式 2. **超时补偿**:引入库存对账服务,定时修复差异 3. **监控指标**:库存波动率、扣减失败率、Redis连接数 > 该方案已在电商大促中验证,支撑百万级 QPS 库存操作[^1]
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值