WMS仓储系统(三)—— 库存扣减的并发控制(面试必问)
WMS仓储系统(三)—— 库存扣减的并发控制(面试必问)
接上一篇:WMS仓储系统(二)—— 核心数据库设计与库存模型(附完整表结构):我们完成了核心数据库设计与库存模型。本文将深入高并发场景下的库存扣减,基于项目实际代码分析并发隐患,并提供工业级解决方案。这是面试最高频的考点之一。
快速回顾
| 文章 | 核心内容 |
|---|---|
| 第一篇 | WMS整体业务流程、技术栈、项目定位 |
| 第二篇 | 20+张核心业务表设计、库存模型、核心业务SQL |
| 第三篇(本文) | 高并发库存扣减、三种锁方案、面试并发难题解决 |
一、问题复现:为什么必须做并发控制?
1.1 经典线上超卖事故案例
电商大促、仓库批量出库场景下,无并发控制的库存代码,极易引发超卖、库存数据错乱生产事故:
| 项目 | 数据 |
|---|---|
| 商品初始库存 | 10件 |
| 并发请求数 | 100个 |
| 实际成功下单 | 80件 |
| 剩余账面库存 | 10件 |
| 超卖数量 | 70件 |
| 事故后果:用户订单无法履约、平台赔付资金、企业信誉受损、库存数据彻底错乱。 |
1.2 并发超卖的核心根源
库存查询、库存扣减非原子操作,多线程同时读取到相同库存,各自完成扣减,引发超卖。
❌ 存在严重漏洞的原始代码:
@Transactional
public void sell(String goodsCode, int qty) {
// 步骤1:查询库存
Inventory inventory = inventoryMapper.selectByGoodsCode(goodsCode);
// 步骤2:判断库存是否充足
if (inventory.getCount() >= qty) {
// 步骤3:扣减库存(非原子操作,并发失效)
inventory.setCount(inventory.getCount() - qty);
inventoryMapper.updateById(inventory);
}
}
并发执行流程图解
| 时间 | 线程A | 线程B | 实际库存 |
|---|---|---|---|
| T1 | 查询库存 = 10 | - | 10 |
| T2 | - | 查询库存 = 10 | 10 |
| T3 | 扣减5件,库存更新为5 | - | 5 |
| T4 | - | 基于旧数据扣减5件,更新为5 | 5 |
| 最终错误结果:总共卖出10件商品,库存剩余5件,超卖5件! |
1.3 本项目核心并发风险点
| 操作场景 | 核心风险 | 风险等级 |
|---|---|---|
| 出库下架 | 多订单并发扣减同一库存,引发超卖 | 🔴 高 |
| 入库上架 | 同一库位并发上架,数据覆盖更新 | 🟡 中 |
| 库存转移 | 同一库存并发转移,数据不一致 | 🟡 中 |
| 盘点调整 | 盘点期间库存被出库,产生盘点差异 | 🟡 中 |
二、方案一:数据库乐观锁(中等并发推荐)
2.1 核心原理
基于CAS自旋思想,通过版本号 version 字段做并发校验。更新数据时,仅当数据库版本号与查询版本号一致时更新成功,否则判定为并发冲突,重试操作。
2.2 数据库字段改造
-- 库存表新增乐观锁版本号字段
ALTER TABLE `wms_inventory`
ADD COLUMN `version` INT DEFAULT 0 COMMENT '乐观锁版本号';
-- 带版本号的原子更新SQL
UPDATE wms_inventory
SET base_count = #{newCount}, version = version + 1
WHERE id = #{id} AND version = #{oldVersion};
-- 更新行数为0,代表数据已被修改,触发并发重试
2.3 实体类配置(MyBatis-Plus)
package org.jeecg.modules.wms.inventory.entity;
import com.baomidou.mybatisplus.annotation.Version;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("wms_inventory")
public class WmsInventory {
private String id;
private String goodsCode;
private String baseCount;
// ... 省略其他原有业务字段
/**
* 乐观锁版本号
* MyBatis-Plus 自动实现版本号自增
*/
@Version
@TableField(fill = FieldFill.INSERT)
private Integer version;
}
2.4 乐观锁插件配置
package org.jeecg.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 开启乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
2.5 自动填充版本号配置
package org.jeecg.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
// 新增数据默认版本号为0
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "version", Integer.class, 0);
}
// 更新时版本号自动+1,无需手动填充
@Override
public void updateFill(MetaObject metaObject) {}
}
2.6 Service层完整实现(带重试机制)
package org.jeecg.modules.wms.auditOut.service.impl;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.NumberUtil;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.modules.wms.inventory.entity.WmsInventory;
import org.jeecg.modules.wms.inventory.mapper.WmsInventoryMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class WmsAuditOutOptimisticServiceImpl {
@Autowired
private WmsInventoryMapper wmsInventoryMapper;
// 最大重试次数
private static final int MAX_RETRY = 3;
@Transactional(rollbackFor = Exception.class)
public void shelfOffWithOptimisticLock(String inventoryId, String shelfOffQua) {
int retryCount = 0;
// 自旋重试
while (retryCount < MAX_RETRY) {
WmsInventory inventory = wmsInventoryMapper.selectById(inventoryId);
// 校验库存是否充足
if (NumberUtil.compare(inventory.getBaseCount(), shelfOffQua) < 0) {
throw new JeecgBootException("库存不足");
}
// 计算最新库存数量
String newCount = Convert.toStr(NumberUtil.sub(inventory.getBaseCount(), shelfOffQua));
inventory.setBaseCount(newCount);
// 乐观锁更新,版本号不匹配则更新行数为0
int updated = wmsInventoryMapper.updateById(inventory);
if (updated > 0) {
return;
}
retryCount++;
}
throw new JeecgBootException("系统繁忙,请稍后重试");
}
}
2.7 乐观锁优缺点
| 优点 | 缺点 |
|---|---|
| 无锁等待、性能极高 | 高并发冲突场景下,重试开销大 |
| MyBatis-Plus原生支持,实现简单 | 需要新增version冗余字段 |
| 无死锁风险,安全性高 | 超高并发场景下成功率下降 |
三、方案二:数据库悲观锁(高冲突单机场景推荐)
3.1 核心原理
通过 SELECT ... FOR UPDATE 悲观锁,在事务查询阶段锁定数据行,其他事务必须等待当前事务提交、锁释放后,才能操作数据,彻底杜绝并发冲突。
3.2 Mapper层锁查询实现
package org.jeecg.modules.wms.inventory.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.jeecg.modules.wms.inventory.entity.WmsInventory;
import java.util.Date;
public interface WmsInventoryMapper extends BaseMapper<WmsInventory> {
/**
* 悲观锁查询库存数据(精确多维度锁定)
*/
@Select("SELECT * FROM wms_inventory " +
"WHERE bin_code = #{binCode} " +
"AND kw_code = #{kwCode} " +
"AND tray_code = #{trayCode} " +
"AND cus_code = #{cusCode} " +
"AND goods_batch = #{goodsBatch} " +
"AND goods_pro_date = #{goodsProDate} " +
"AND goods_unit_code = #{goodsUnitCode} " +
"AND goods_code = #{goodsCode} " +
"AND qc_mark = #{qcMark} " +
"FOR UPDATE")
WmsInventory selectForUpdate(@Param("binCode") String binCode,
@Param("kwCode") String kwCode,
@Param("trayCode") String trayCode,
@Param("cusCode") String cusCode,
@Param("goodsBatch") String goodsBatch,
@Param("goodsProDate") Date goodsProDate,
@Param("goodsUnitCode") String goodsUnitCode,
@Param("goodsCode") String goodsCode,
@Param("qcMark") String qcMark);
}
3.3 Service层完整业务实现
package org.jeecg.modules.wms.auditOut.service.impl;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.NumberUtil;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.modules.wms.auditOut.entity.WmsAuditOut;
import org.jeecg.modules.wms.auditOut.mapper.WmsAuditOutMapper;
import org.jeecg.modules.wms.common.constant.CommonWms;
import org.jeecg.modules.wms.inventory.entity.WmsInventory;
import org.jeecg.modules.wms.inventory.mapper.WmsInventoryMapper;
import org.jeecg.modules.wms.outOrder.entity.WmsOutOrderHeader;
import org.jeecg.modules.wms.outOrder.mapper.WmsOutOrderHeaderMapper;
import org.jeecg.modules.wms.stockOut.entity.WmsStockOut;
import org.jeecg.modules.wms.stockOut.mapper.WmsStockOutMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class WmsAuditOutPessimisticServiceImpl {
@Autowired
private WmsAuditOutMapper wmsAuditOutMapper;
@Autowired
private WmsOutOrderHeaderMapper wmsOutOrderHeaderMapper;
@Autowired
private WmsStockOutMapper wmsStockOutMapper;
@Autowired
private WmsInventoryMapper wmsInventoryMapper;
@Transactional(rollbackFor = Exception.class)
public void shelfOff(WmsAuditOut wmsAuditOut) {
// 查询待下架单据
WmsAuditOut auditOut = wmsAuditOutMapper.selectById(wmsAuditOut.getId());
String shelfOffQua = wmsAuditOut.getShelfOffQua();
// 查询出库单主单
WmsOutOrderHeader outOrderHeader = wmsOutOrderHeaderMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WmsOutOrderHeader>()
.eq(WmsOutOrderHeader::getOrderNum, auditOut.getOrderNum())
);
// 区分良品/不良品库存
String qcMark = "BLPCK".equals(outOrderHeader.getOrderTypeCode())
? CommonWms.QC_BAD_MARK
: CommonWms.QC_GOOD_MARK;
// 悲观锁锁定当前库存行,禁止其他事务修改
WmsInventory inventory = wmsInventoryMapper.selectForUpdate(
auditOut.getBinCode(),
auditOut.getKwCode(),
auditOut.getTrayCode(),
auditOut.getCusCode(),
auditOut.getGoodsBatch(),
auditOut.getGoodsProDate(),
auditOut.getGoodsUnitCode(),
auditOut.getGoodsCode(),
qcMark
);
if (inventory == null) {
throw new JeecgBootException("库存不存在:" + auditOut.getGoodsCode());
}
// 库存充足校验
if (NumberUtil.compare(inventory.getBaseCount(), shelfOffQua) < 0) {
throw new JeecgBootException("库存不足,当前库存:" + inventory.getBaseCount());
}
// 扣减库存
String newCount = Convert.toStr(NumberUtil.sub(inventory.getBaseCount(), shelfOffQua));
inventory.setBaseCount(newCount);
int updated = wmsInventoryMapper.updateById(inventory);
if (updated == 0) {
throw new JeecgBootException("库存扣减失败");
}
// 更新待下架单据状态与数量
auditOut.setShelfOkQua(Convert.toStr(NumberUtil.add(shelfOffQua, auditOut.getShelfOkQua())));
auditOut.setShelfSyQua(Convert.toStr(NumberUtil.sub(auditOut.getGoodsQua(), auditOut.getShelfOkQua())));
wmsAuditOutMapper.updateById(auditOut);
// 生成已下架库存记录
createStockOutRecord(auditOut, shelfOffQua);
}
/**
* 生成下架流水记录,用于库存追溯
*/
private void createStockOutRecord(WmsAuditOut auditOut, String quantity) {
WmsStockOut stockOut = new WmsStockOut();
stockOut.setGoodsCode(auditOut.getGoodsCode());
stockOut.setGoodsQua(quantity);
stockOut.setAuditId(auditOut.getId());
stockOut.setDownStatus(CommonWms.FINISH_SHELF_OFF);
wmsStockOutMapper.insert(stockOut);
}
}
3.4 核心索引优化(避免锁表)
关键重点:FOR UPDATE 必须走索引,否则会锁全表,引发严重性能问题!
-- 建立库存多维度复合索引,精准行锁
CREATE INDEX idx_inventory_location ON wms_inventory(
bin_code, kw_code, tray_code, cus_code, goods_code, goods_batch
);
-- 校验SQL是否走索引(type不能为ALL)
EXPLAIN SELECT * FROM wms_inventory
WHERE bin_code = 'B001' AND kw_code = 'KW001'
FOR UPDATE;
3.5 悲观锁优缺点
| 优点 | 缺点 |
|---|---|
| 强数据一致性,彻底杜绝超卖 | 存在死锁风险,需手动规避 |
| 逻辑简单、稳定性高 | 并发场景下存在等待,性能偏低 |
| 适配高冲突库存扣减场景 | 无索引时会锁全表,风险极高 |
3.6 死锁预防方案
-- 1. 设置锁超时时间,避免无限等待
SET innodb_lock_wait_timeout = 5;
-- 2. 查看数据库死锁日志,快速定位问题
SHOW ENGINE INNODB STATUS;
代码层面最优解:统一锁获取顺序,避免循环等待
四、方案三:Redis分布式锁(集群/微服务必备)
4.1 核心原理
基于Redis SETNX原子命令实现跨进程、跨服务互斥锁,解决集群/微服务部署场景下,数据库锁失效的问题,适配分布式高并发架构。
4.2 引入Redisson依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.0</version>
</dependency>
4.3 Redis基础配置
# application.yml
spring:
redis:
host: localhost
port: 6379
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
4.4 分布式锁完整业务实现
package org.jeecg.modules.wms.auditOut.service.impl;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.NumberUtil;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.modules.wms.auditOut.entity.WmsAuditOut;
import org.jeecg.modules.wms.inventory.entity.WmsInventory;
import org.jeecg.modules.wms.inventory.mapper.WmsInventoryMapper;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class WmsAuditOutRedisLockServiceImpl {
@Autowired
private RedissonClient redissonClient;
@Autowired
private WmsInventoryMapper wmsInventoryMapper;
public void shelfOffWithRedisLock(WmsAuditOut wmsAuditOut, String shelfOffQua) {
// 精细化锁粒度:商品+批次+库位,避免全局锁性能浪费
String lockKey = String.format("lock:inventory:%s:%s:%s",
wmsAuditOut.getGoodsCode(),
wmsAuditOut.getGoodsBatch(),
wmsAuditOut.getKwCode()
);
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁:等待3秒,锁超时10秒,自动续期
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
throw new JeecgBootException("系统繁忙,请稍后重试");
}
// 查询库存并校验
WmsInventory inventory = wmsInventoryMapper.selectById(wmsAuditOut.getInventoryId());
if (NumberUtil.compare(inventory.getBaseCount(), shelfOffQua) < 0) {
throw new JeecgBootException("库存不足");
}
// 扣减库存
inventory.setBaseCount(Convert.toStr(NumberUtil.sub(inventory.getBaseCount(), shelfOffQua)));
wmsInventoryMapper.updateById(inventory);
// 省略下架记录生成、状态更新逻辑...
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new JeecgBootException("获取锁失败,操作异常");
} finally {
// 仅当前线程持有锁时,才释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
4.5 锁粒度分级设计
| 锁粒度 | Key示例 | 适用场景 |
|---|---|---|
| 粗粒度 | lock:inventory:SKU001 | 低并发、简单业务场景 |
| 细粒度(推荐) | lock:inventory:SKU001:BATCH01:KW-A-01 | 中高并发、仓储核心场景 |
| 超细粒度 | lock:inventory:SKU001:BATCH01:KW-A-01:箱码123 | 超高并发、精细库存管理 |
4.6 分布式锁优缺点
| 优点 | 缺点 |
|---|---|
| 支持跨进程、跨微服务集群部署 | 存在网络开销,依赖Redis组件 |
| Redisson自动续期、可重入、防死锁 | 需要维护Redis集群,增加运维成本 |
| 锁粒度灵活,性能可控 | 极端场景下锁过期可能引发重复执行 |
五、三种方案对比与生产选型建议
5.1 全维度对比
| 维度 | 乐观锁 | 悲观锁 | Redis分布式锁 |
|---|---|---|---|
| 实现难度 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 并发性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| 数据一致性 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 死锁风险 | 无 | 有 | 低 |
| 额外组件依赖 | 无 | 无 | Redis |
| 适用架构 | 单机、读多写少 | 单机、写多读少 | 集群/微服务 |
5.2 本项目生产选型标准
┌─────────────────────────────────────────────────────────┐
│ 单机部署 + 并发量 < 100 QPS → 悲观锁(推荐) │
│ │
│ 单机部署 + 并发量 100-500 QPS → 乐观锁(推荐) │
│ │
│ 集群部署 / 微服务架构 → 分布式锁(必须) │
└─────────────────────────────────────────────────────────┘
5.3 企业级最佳实践:混合锁策略
优先使用乐观锁提升性能,冲突重试失败后,降级为悲观锁保证一致性,兼顾性能与安全。
/**
* 智能锁降级策略:乐观锁优先,失败降级悲观锁
*/
public void shelfOffWithSmartLock(WmsAuditOut wmsAuditOut) {
// 重试3次乐观锁
for (int i = 0; i < 3; i++) {
try {
shelfOffWithOptimisticLock(wmsAuditOut);
return;
} catch (JeecgBootException e) {
// 最后一次重试失败,降级悲观锁
if (i == 2) {
shelfOffWithPessimisticLock(wmsAuditOut);
}
}
}
}
六、代码优化前后对比
6.1 优化前(存在并发漏洞)
// ❌ 错误代码:查询、判断、更新非原子操作
WmsInventory inventory = wmsInventoryMapper.selectOne(queryWrapper);
if (inventory.getBaseCount() >= shelfOffQua) {
inventory.setBaseCount(...);
wmsInventoryMapper.updateById(inventory);
}
6.2 优化后(悲观锁安全代码)
// ✅ 安全代码:事务内行锁锁定,原子执行
WmsInventory inventory = wmsInventoryMapper.selectForUpdate(...);
if (inventory.getBaseCount() >= shelfOffQua) {
inventory.setBaseCount(...);
// 校验更新行数,确保扣减成功
int updated = wmsInventoryMapper.updateById(inventory);
if (updated == 0) {
throw new JeecgBootException("库存扣减失败");
}
}
6.3 核心优化总结
| 优化维度 | 优化前 | 优化后 |
|---|---|---|
| 并发安全 | ❌ 无锁,超卖严重 | ✅ 行锁保证绝对安全 |
| 操作原子性 | ❌ 查询更新分离 | ✅ 事务内原子执行 |
| 异常处理 | ❌ 无失败校验 | ✅ 行数校验+事务回滚 |
| 面试亮点 | 无技术亮点 | ✅ 企业级并发解决方案 |
七、面试高频追问(标准答案)
Q1:FOR UPDATE 为什么会锁表?如何避免?
A:当查询条件无索引/索引失效时,MySQL无法精准锁定行,会触发表锁。解决方案:为库存查询条件建立复合索引,确保SQL走行级锁。
Q2:如何彻底避免悲观锁死锁?
A:1、统一所有事务的锁获取顺序;2、设置锁超时时间;3、避免长事务,缩短锁持有时间。
Q3:乐观锁和悲观锁如何选型?
A:读多写少、冲突概率低用乐观锁;写多读少、强一致性、高冲突场景(WMS库存扣减)用悲观锁。
Q4:微服务集群部署如何解决库存并发?
A:数据库锁仅适用于单机,集群场景必须使用Redisson分布式锁,实现跨服务互斥。
Q5:库存扣减失败如何保证数据一致?
A:通过 @Transactional 事务自动回滚,结合重试机制,保证库存、单据、流水数据完全一致。
八、项目简历亮点(直接复用)
【WMS库存模块核心技术亮点】
-
高并发库存防超卖设计:针对仓储出库高并发场景,基于MySQL悲观锁(FOR UPDATE)实现行级锁,精准锁定库存数据,配合复合索引杜绝锁表问题,彻底解决库存超卖、数据错乱问题。
-
多层级事务控制:封装入库、出库全链路事务,上架新增库存、下架扣减库存、单据状态更新原子执行,异常自动回滚,保障仓储数据一致性。
-
多方案并发适配:兼容乐观锁、悲观锁、Redis分布式锁三种方案,实现锁降级策略,单机/集群架构可灵活切换,适配不同并发量级。
-
精细化库存管理:基于物料、批次、库位、箱码、托盘、货主多维度锁定库存,支持FIFO先进先出,实现分批上下架、库存追溯全流程管控。
九、下篇预告
WMS仓储系统(四)—— 库存模块面试题与项目亮点
-
简历项目亮点话术包装
-
仓储面试15道高频真题详解
-
系统架构图、业务流程图完整版
-
项目优化与进阶思路
快速上手 & 项目地址
3分钟快速启动项目
- 克隆代码
git clone https://gitee.com/jeeslee/wms.git
-
导入MySQL脚本:
docs/sql/wms.sql -
修改
application.yml数据库连接配置 -
启动项目
mvn spring-boot:run
开源地址
⭐ Gitee:https://gitee.com/jeeslee/wms
⭐ GitHub:https://gitee.com/li_tongs/jeecgboot-vue3
如果对你有帮助,欢迎点个Star支持作者,持续更新优质实战内容!
更多推荐




所有评论(0)