库存扣减的并发难题:超卖·悲观锁·乐观锁·Redis 预扣减 4 种方案实战

库存扣减的并发难题:超卖·悲观锁·乐观锁·Redis 预扣减 4 种方案实战

库存扣减的并发难题:超卖·悲观锁·乐观锁·Redis 预扣减 4 种方案实战

🌐演示地址:http://ruoyioffice.com | 📦源码1·GitHub:ruoyi-office | 📦源码2·GitCode:ruoyi-office | 📦源码3·Gitee:ruoyi-office | 💬微信:17156169080(备注「RuoYi Office」)

库存扣减是几乎每个有库存的系统都绕不开的并发难题。它的本质只有一句话:"判断库存够不够"和"扣减库存"这两步不是原子的,并发下就会超卖。库存只剩 1 件,两个请求同时来,都查到"还有 1 件",于是都扣,结果库存变成 -1,超卖了。本文系统讲透 4 种主流解法——数据库悲观锁、条件 UPDATE(乐观锁/CAS)、Redis 预扣减、分布式锁,每种都配可落地代码与适用场景,并结合RuoYi Office 里 ERP 进销存用"条件 UPDATE"、WMS 仓储用"SELECT FOR UPDATE 悲观锁"的两套真实源码对照,帮你想清楚"什么并发量该用什么方案"。

▲ 全景图:超卖根因(查询与扣减非原子)→ 4 种解法(悲观锁 / 条件 UPDATE / Redis 预扣减 / 分布式锁)→ 适用并发量与一致性强弱对照

引言:超卖是怎么发生的?

先把问题讲清楚。最朴素的库存扣减代码长这样——几乎所有超卖都源于它

// ❌ 反例:查询与扣减非原子,并发必超卖ErpStockDOstock=stockMapper.selectById(id);// 线程A、B 都查到 count = 1if(stock.getCount()>=buyCount){// 都判断"够"stock.setCount(stock.getCount()-buyCount);// 都算出 0stockMapper.updateById(stock);// 都写 0,实际卖出了 2 件}

问题在于"读—判断—写"三步之间没有任何保护,多个线程交错执行,判断都基于同一个旧值。要解决它,核心思路只有两类:

  • 让这三步串行化(加锁,悲观):同一时刻只让一个线程进入临界区。
  • 让"判断+扣减"变成一个原子操作(CAS,乐观):在写的瞬间再校验一次条件。

下面 4 种方案,本质都是这两类思路的不同实现与权衡。

概念定义:超卖(Oversell)指实际扣减的库存超过了真实可用库存,导致库存为负或卖出不存在的货。它是典型的"竞态条件(Race Condition)"问题。


一、方案一:数据库悲观锁(SELECT … FOR UPDATE)

1.1 原理

悲观锁的思路是"先占坑再干活":扣减前先用SELECT ... FOR UPDATE把库存行锁住,事务提交前别的事务改不了这行,从而把"读—判断—写"串行化。

@Transactional(rollbackFor=Exception.class)publicvoiddeduct(Longid,BigDecimalbuyCount){// 1. 加行锁:其它事务在本事务提交前无法修改/锁定该行StockDOstock=stockMapper.selectByIdForUpdate(id);// SELECT ... FOR UPDATE// 2. 锁内判断 + 扣减(此刻独占,安全)if(stock.getQuantity().compareTo(buyCount)<0){throwexception(STOCK_NOT_ENOUGH);}stockMapper.updateQuantity(id,stock.getQuantity().subtract(buyCount));}

对应的 Mapper:

<selectid="selectByIdForUpdate"resultType="StockDO">SELECT * FROM stock WHERE id = #{id} FOR UPDATE</select>

1.2 RuoYi Office 的真实落地:WMS 仓储用悲观锁

RuoYi Office 的 WMS 仓储模块就是悲观锁路线。因为一张出库/移库单常涉及多个 SKU,它先把本次涉及的所有库存行批量SELECT ... FOR UPDATE锁住,再在内存里整体计算校验、批量更新——保证"整单一致":

privateMap<Item,Tuple>changeInventoryList(List<Item>items){// 1. 批量加锁:把本次涉及的库存行全部 SELECT ... FOR UPDATE 锁住List<WmsInventoryDO>inventories=getOrCreateInventoryList(items);inventories=inventoryMapper.selectListByIdsForUpdate(convertSet(inventories,WmsInventoryDO::getId));// 2. 锁内逐条计算 + 校验充足for(Itemitem:items){WmsInventoryDOinv=findInventory(inventories,item);BigDecimalafter=inv.getQuantity().add(item.getQuantity());// 出库为负if(after.compareTo(BigDecimal.ZERO)<0){throwbuildInventoryQuantityNotEnoughException(item,inv.getQuantity());}inv.setQuantity(after);}// 3. 校验全通过后批量更新(已加锁,安全)inventoryMapper.updateBatch(/* ... */);}

1.3 优缺点

维度评价
一致性⭐⭐⭐⭐⭐ 强一致,最稳妥
适合场景一单多明细、移库改两仓等需要"整单原子"的复杂场景
缺点行锁持有期间其它事务阻塞,并发吞吐受限;务必缩短事务、按固定顺序加锁防死锁

二、方案二:条件 UPDATE(乐观锁 / CAS)

2.1 原理

乐观锁的思路是"先干活,写时再校验":不提前加锁,而是把"判断库存够不够"塞进 UPDATE 的 WHERE 条件里,靠数据库行级锁在更新瞬间完成"判断+扣减"。扣得动返回 1,库存不足返回 0:

UPDATEstockSETcount=count-#{buyCount}WHEREid=#{id} AND count >= #{buyCount}

应用层用"影响行数"判断成败,0 行就说明并发下库存不足,回滚即可。

2.2 RuoYi Office 的真实落地:ERP 进销存用条件 UPDATE

RuoYi Office 的 ERP 进销存就是条件 UPDATE 路线。库存扣减压成一条原子 SQL,不加显式锁:

defaultintupdateCountIncrement(Longid,BigDecimalcount,booleannegativeEnable){LambdaUpdateWrapper<ErpStockDO>wrapper=newLambdaUpdateWrapper<ErpStockDO>().eq(ErpStockDO::getId,id);if(count.compareTo(BigDecimal.ZERO)>0){// 入库:直接加wrapper.setSql("count = count + "+count);}elseif(count.compareTo(BigDecimal.ZERO)<0){// 出库:扣减if(!negativeEnable){wrapper.ge(ErpStockDO::getCount,count.abs());// 关键:count >= 扣减量}wrapper.setSql("count = count - "+count.abs());}returnupdate(null,wrapper);// 返回影响行数}

Service 层按影响行数兜底——0 行说明被别人扣走了,抛异常回滚:

intupdateCount=stockMapper.updateCountIncrement(stock.getId(),count,NEGATIVE_STOCK_COUNT_ENABLE);if(updateCount==0){throwexception(STOCK_COUNT_NEGATIVE2,productName,warehouseName);}

2.3 版本号乐观锁(另一种写法)

如果不是扣减而是"覆盖式更新",可以加version字段做乐观锁,更新时带版本条件,版本不匹配则失败重试:

UPDATEstockSETcount=#{newCount}, version = version + 1WHEREid=#{id} AND version = #{oldVersion}

2.4 优缺点

维度评价
一致性⭐⭐⭐⭐ 强一致(单行原子)
性能不加显式锁,吞吐高于悲观锁
适合场景单行库存扣减、并发中等(大多数进销存/后台场景)
缺点高冲突时大量更新失败需重试;多明细整单一致不如悲观锁直观

三、方案三:Redis 预扣减(高并发秒杀)

3.1 原理

当并发量飙升到秒杀级别(每秒上万请求),数据库扛不住了。这时把库存预热到 Redis,用 Redis 的原子操作先扣减,再异步同步到数据库。Redis 单线程 + 原子命令天然防超卖:

// 库存预热:活动开始前把可售库存放进 RedisstringRedisTemplate.opsForValue().set("stock:"+skuId,"1000");// 扣减:DECRBY 原子返回扣后值,小于 0 说明超卖,立即补回publicbooleandeduct(LongskuId,intbuyCount){Longremain=stringRedisTemplate.opsForValue().increment("stock:"+skuId,-buyCount);if(remain!=null&&remain>=0){returntrue;// 预扣成功,发 MQ 异步落库 + 创建订单}stringRedisTemplate.opsForValue().increment("stock:"+skuId,buyCount);// 回补returnfalse;// 库存不足}

3.2 用 Lua 脚本保证"判断+扣减"原子

上面DECRBY再回补有细微时间窗,更严谨的做法是用 Lua 脚本把"判断+扣减"在 Redis 端原子执行:

-- deduct.lua:库存足才扣,返回剩余;不足返回 -1localstock=tonumber(redis.call('GET',KEYS[1]))ifstock==nilthenreturn-1endifstock<tonumber(ARGV[1])thenreturn-1endreturnredis.call('DECRBY',KEYS[1],ARGV[1])

Redis 预扣成功后,通过消息队列(如 RocketMQ)异步创建订单、扣减数据库库存,把数据库从"抗并发"中解放出来,只做最终落库。

3.3 优缺点

维度评价
性能⭐⭐⭐⭐⭐ 抗万级 QPS,秒杀首选
适合场景秒杀、抢购、大促等超高并发瞬时扣减
缺点架构复杂;需处理 Redis 与 DB 最终一致、超卖回补、缓存预热与重建、宕机数据恢复

四、方案四:分布式锁(Redisson)

4.1 原理

在分布式/微服务环境下,悲观锁锁的是单库的行,跨服务的复杂业务(如"扣库存 + 加积分 + 记日志"要整体串行)可以用分布式锁。RuoYi Office 框架内置 Redisson,加锁即一行:

@ResourceprivateRedissonClientredissonClient;publicvoiddeduct(LongskuId,intbuyCount){RLocklock=redissonClient.getLock("lock:stock:"+skuId);lock.lock();// 也可 tryLock(等待, 持有, 单位)try{// 临界区:查库存、判断、扣减、写其它表,整体串行}finally{lock.unlock();}}

更优雅的是用框架的@Lock注解(若提供)或 AOP,把锁逻辑与业务解耦。

4.2 优缺点

维度评价
适用跨服务/跨库的复杂临界区,或需要锁"业务"而非"某一行"
缺点锁粒度大则并发低;需设合理超时与看门狗续期;强依赖 Redis 可用性

注意:分布式锁与数据库锁不是二选一。常见组合是"分布式锁锁业务边界 + 数据库条件 UPDATE 兜底防超卖",双保险。


五、选型决策:什么并发量用什么方案

结论先行:没有"最好的"方案,只有"最匹配并发量与一致性要求"的方案。一张对照表说清楚:

方案并发量级一致性实现复杂度典型场景RuoYi Office 对应
条件 UPDATE(乐观/CAS)中(百~千 QPS)⭐ 低进销存、后台扣减、单行ERP 进销存
悲观锁 FOR UPDATE中(百~千 QPS)⭐⭐ 中一单多明细、移库、复杂事务WMS 仓储
Redis 预扣减高(万级 QPS)最终一致⭐⭐⭐⭐ 高秒杀、抢购、大促(可扩展)
分布式锁 Redisson中高⭐⭐⭐ 中高跨服务复杂临界区框架内置

实战建议

  1. 绝大多数企业后台系统(进销存、ERP、WMS、CRM),用条件 UPDATE 或悲观锁就够了——它们是强一致、易理解、零外部依赖的方案。不要一上来就上 Redis 预扣减,过度设计。
  2. 单行扣减优先条件 UPDATE(性能好),多明细整单一致优先悲观锁(逻辑清晰)——这正是 RuoYi Office 里 ERP 与 WMS 的分工。
  3. 只有真到秒杀级并发才需要 Redis 预扣减 + MQ 异步落库,并接受"最终一致"的复杂度成本。
  4. 跨服务复杂业务再叠加分布式锁,但仍要用数据库条件更新兜底防超卖。

六、避坑清单

表现对策
先查后改非原子偶发超卖用条件 UPDATE 或锁内判断,绝不"读—判断—写"裸奔
事务范围过大悲观锁阻塞严重缩短事务、只锁必要行、尽早提交
加锁顺序不一致死锁多行加锁按固定顺序(如 ID 升序)
Redis 与 DB 不一致预扣成功但落库失败MQ 重试 + 对账补偿 + 超卖回补
乐观锁高冲突大量更新失败评估冲突率,必要时改悲观锁或排队
库存行不存在首次扣减报错不存在则创建,唯一索引冲突回查(WMS 的做法)
反向操作不对称反审核退库存出错审核/反审核用对称的"加/退"流水

七、RuoYi Office 的双方案实践

RuoYi Office 是少有的"在同一套代码里同时演示两种库存并发方案"的开源项目,非常适合学习对照:

模块方案核心代码设计动机
ERP 进销存条件 UPDATE(乐观)count = count - x WHERE count >= x单行扣减为主,追求吞吐
WMS 仓储悲观锁(FOR UPDATE)selectListByIdsForUpdate批量锁定一单多 SKU、移库改两仓,要整单一致

两者都做到了强一致防超卖,区别只在"单行高吞吐"还是"多明细整单一致"的取舍——这恰恰说明:方案选型要跟着业务场景走,而不是跟着"听起来高级"走。


八、快速体验

在线演示:http://ruoyioffice.com/web/(账号admin/ 密码admin123

验证超卖防护

  1. 进入 ERP 进销存,对某产品库存调到很小(如 1),开两张大额销售出库单,先后审核,观察第二张被"库存不足"拦截(条件 UPDATE 生效)。
  2. 进入 WMS 仓储,对某 SKU 开出库单超量出库,观察"库存不足"拦截(悲观锁生效)。
  3. 阅读源码ErpStockMapper.updateCountIncrementWmsInventoryServiceImpl.changeInventoryList,对照两种实现。

源码仓库

平台地址
GitHubhttps://github.com/yuqing2026/ruoyi-office
GitCodehttps://gitcode.com/zhouzhongyan/ruoyi-office
Giteehttps://gitee.com/yqzy1688/ruoyi-office

结语

库存扣减的并发难题,核心就一句话:让"判断"和"扣减"原子化。实现路径无非两条——加锁串行(悲观),或写时校验(乐观/CAS),再叠加 Redis 与分布式锁应对更高并发与更复杂边界。

最重要的不是记住四种方案,而是建立选型直觉:先问并发量级和一致性要求,再选方案。企业后台用条件 UPDATE 或悲观锁就稳了,秒杀才上 Redis 预扣减——RuoYi Office 的 ERP 与 WMS 双实现就是最好的教材。

如果你正在被库存超卖困扰,欢迎参考 RuoYi Office 的源码实现,也欢迎在评论区聊聊:你们线上的库存扣减,用的是哪种方案?踩过哪些坑?


常见问题(FAQ)

库存扣减用乐观锁还是悲观锁好?

看场景。单行库存扣减、并发中等,优先条件 UPDATE(乐观锁),性能好、零依赖;一单多明细或移库等需要"整单一致"的复杂事务,优先悲观锁SELECT ... FOR UPDATE。RuoYi Office 里 ERP 用前者、WMS 用后者,可对照学习。

条件 UPDATE 真能防超卖吗?

能。UPDATE stock SET count = count - x WHERE id = ? AND count >= x利用数据库行级锁在更新瞬间完成"判断+扣减",库存不足时影响 0 行,应用层据此回滚,并发再高也不会扣成负数。

什么时候才需要 Redis 预扣减?

只有真正的秒杀/抢购级超高并发(万级 QPS 瞬时)才需要。普通企业进销存、后台系统用数据库锁或条件 UPDATE 完全够用,盲目上 Redis 预扣减属于过度设计,还要额外处理一致性与回补。

RuoYi Office 的库存并发是怎么做的?

双方案:ERP 进销存用条件 UPDATE(count = count - x WHERE count >= x)防超卖;WMS 仓储用悲观锁selectListByIdsForUpdate批量锁定库存行后内存整体计算校验。两者都强一致防超卖,且都开源可参考。

分布式锁能替代数据库锁吗?

不建议完全替代。分布式锁适合锁"跨服务的业务边界",但仍应用数据库条件 UPDATE 兜底防超卖,形成双保险。单纯依赖分布式锁,一旦 Redis 抖动就可能失效。


💡想要体验 RuoYi Office 的强大功能?

🌐在线演示:http://ruoyioffice.com/web/(账号 admin / admin123)

📦源码仓库:GitHub | GitCode | Gitee

💬技术咨询:添加微信17156169080,备注「RuoYi Office」

如果觉得不错,请给个 Star 支持一下!