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库存模块核心技术亮点】

  1. 高并发库存防超卖设计:针对仓储出库高并发场景,基于MySQL悲观锁(FOR UPDATE)实现行级锁,精准锁定库存数据,配合复合索引杜绝锁表问题,彻底解决库存超卖、数据错乱问题。

  2. 多层级事务控制:封装入库、出库全链路事务,上架新增库存、下架扣减库存、单据状态更新原子执行,异常自动回滚,保障仓储数据一致性。

  3. 多方案并发适配:兼容乐观锁、悲观锁、Redis分布式锁三种方案,实现锁降级策略,单机/集群架构可灵活切换,适配不同并发量级。

  4. 精细化库存管理:基于物料、批次、库位、箱码、托盘、货主多维度锁定库存,支持FIFO先进先出,实现分批上下架、库存追溯全流程管控。


九、下篇预告

WMS仓储系统(四)—— 库存模块面试题与项目亮点

  1. 简历项目亮点话术包装

  2. 仓储面试15道高频真题详解

  3. 系统架构图、业务流程图完整版

  4. 项目优化与进阶思路


快速上手 & 项目地址

3分钟快速启动项目

  1. 克隆代码
git clone https://gitee.com/jeeslee/wms.git
  1. 导入MySQL脚本:docs/sql/wms.sql

  2. 修改 application.yml 数据库连接配置

  3. 启动项目

mvn spring-boot:run

开源地址

⭐ Gitee:https://gitee.com/jeeslee/wms

⭐ GitHub:https://gitee.com/li_tongs/jeecgboot-vue3

如果对你有帮助,欢迎点个Star支持作者,持续更新优质实战内容!

上一篇:> WMS仓储系统(二)—— 核心数据库设计与库存模型(附完整表结构)

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐