news 2026/9/26 2:05:19

从表设计到高并发防超卖:Spring Boot库存扣减体系完整实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从表设计到高并发防超卖:Spring Boot库存扣减体系完整实战

做后端开发、电商系统或者进销存系统时,库存模块往往是绕不开的核心业务。很多项目初期“先做能跑的功能”,到后面做秒杀、下单、退款时,库存扣减就开始出各种问题:超卖、少卖、流水对不上、数据不一致。这篇文章会围绕库存管理这个业务场景,从数据库表设计、核心接口实现,到高并发防超卖方案,完整梳理一套可落地的库存扣减体系。内容包括完整DDL、Spring Boot核心代码、Redis Lua脚本和常见排查思路,适合初学后端、正在做毕设或负责订单库存模块的开发者收藏备用。

1. 库存管理的核心概念

1.1 库存到底是什么

从业务层面看,库存是“可销售商品数量”的抽象。在数据库里,库存通常不是一个简单的数字,而是由总库存、锁定库存、可用库存等字段共同表达的。

很多新手容易把库存理解成“一张表里一个数字”,然后每次下单就执行:

UPDATE inventory SET quantity = quantity - 1 WHERE sku_id = 1001;

这种写法在小流量、单机环境下勉强能运行,但它缺少业务语义,也无法应对并发。真实业务里的库存,至少要区分三个口径:

  • 总库存:商品总共采购/入库了多少件。
  • 锁定库存:已经预占但还没完成支付的库存,比如用户下单后待支付期间占用的库存。
  • 可用库存:当前还能继续被下单的库存。

可以用一个简单公式说明:

可用库存 = 总库存 - 锁定库存

用户下单后,先把“可用库存”减少、把“锁定库存”增加;支付成功后,再把“总库存”和“锁定库存”同时扣减;如果订单取消,则把锁定库存释放,可用库存回补。这个流程相比“直接扣减总数”更严谨,也是主流电商系统常用的库存模型。

1.2 常见的库存业务模型

在不同业务场景下,库存模型会有所差异。

第一种是“现货库存模型”,常见于电商、商城系统。商品入库时增加总库存,用户下单时先锁定库存,支付后扣减总库存,取消订单时回补库存。这种模型需要考虑订单超时释放,以及支付回调与库存扣减的一致性。

第二种是“备货/预售模型”,常见于仓配系统和供应链系统。库存不仅包含可售库存,还包含在途库存、冻结库存、质检库存等。这类模型更复杂,需要增加库存状态字段,比如:

  • 在途
  • 可售
  • 冻结
  • 锁库
  • 出库中

第三种是“门店/多仓库存模型”,需要把库存按仓库维度拆分,每个仓库都有自己的库存表,下单时还需要按仓库计算可用库存。这类模型在ERPSaaS系统里很常见,核心表会带warehouse_id这样的仓库维度。

无论哪种模型,底层都需要做到“扣减有依据、流水可追溯”,这是库存模块设计的第一原则。

1.3 库存模块在系统中的位置

库存模块一般不独立存在,它和商品、订单、支付、售后等模块紧密关联。典型调用链路如下:

商品详情页 -> 查询库存 -> 用户下单 -> 锁定库存 -> 用户支付 -> 支付回调 -> 扣减库存 -> 发货出库 -> 售后/取消 -> 释放库存

所以库存接口不仅要考虑“当前有没有货”,还要和订单状态机联动。这也意味着,库存模块是一个需要强事务、强一致性、并发安全的模块。如果库存扣减失败,订单不能创建;如果取消订单,库存必须回补。任何“先改订单、再改库存”的做法,都要通过事务或者最终一致性机制保证两边不出现偏差。

2. 环境准备与项目结构

2.1 技术栈选择

本文示例采用 Java Spring Boot 作为主技术栈,数据库使用 MySQL,ORM 使用 MyBatis Plus,高并发示例使用 Redis Lua 脚本。这套组合在电商、企业后台系统中非常常见,资料多,容易落地。

技术环境如下,版本需要根据你的项目实际情况调整:

  • JDK:1.8 或 11,如果使用 Spring Boot 3,需要 JDK 17。
  • Spring Boot:2.7.x 或 3.x,示例以 2.7.x 常见写法为主。
  • MySQL:5.7 或 8.0,推荐 8.0。
  • MyBatis Plus:3.5.x。
  • Redis:5.x / 6.x / 7.x 均可。
  • 开发工具:IDEA + Maven + Postman 或 Apifox。

这里说明一下,代码示例并不是版本写死才能运行,关键点是锁库存、扣库存的 SQL 和事务逻辑,你在自己的项目里可以按实际依赖调整。

2.2 数据库准备

启动 MySQL 后,创建数据库:

CREATE DATABASE IF NOT EXISTS inventory_demo DEFAULT CHARACTER SET utf8mb4; USE inventory_demo;

建议使用 utf8mb4 字符集,避免商品名或备注信息出现生僻字时写入报错。

2.3 项目目录结构

在 IDEA 中创建一个 Spring Boot 工程,包名为com.example.inventory。核心目录如下:

src/main/java/com/example/inventory ├── InventoryApplication.java ├── controller │ └── InventoryController.java ├── entity │ ├── Inventory.java │ └── InventoryFlow.java ├── mapper │ └── InventoryMapper.java └── service ├── InventoryService.java └── RedisInventoryService.java src/main/resources ├── application.yml └── lua/ ├── stock_deduct.lua └── stock_release.lua

这是一份非常精简的后端工程结构,实际项目中还会包含公共异常处理、统一返回结构、配置类等,考虑到教程篇幅,本文只保留和库存强相关的文件。

3. 库存表设计与数据模型

3.1 商品表、库存表与流水表设计

先来看三张核心表的设计:商品表、库存表、库存流水表。

商品表比较简单,用于描述商品和SKU基本信息。

CREATE TABLE `product` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `product_name` VARCHAR(128) NOT NULL COMMENT '商品名称', `sku_code` VARCHAR(64) NOT NULL COMMENT 'SKU编码', `price` DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '售价', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_sku_code` (`sku_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

库存表是核心表,记录每个SKU的总库存、锁定库存、可用库存和版本号。

CREATE TABLE `inventory` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `sku_id` BIGINT NOT NULL COMMENT '商品SKU ID', `total_quantity` INT NOT NULL DEFAULT 0 COMMENT '总库存', `locked_quantity` INT NOT NULL DEFAULT 0 COMMENT '锁定库存', `available_quantity` INT NOT NULL DEFAULT 0 COMMENT '可用库存', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_sku_id` (`sku_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表';

这里单独加了一个version字段,用来做乐观锁控制。在高并发场景下,条件更新配合版本号,能有效避免超卖。

库存流水表用于记录每一次库存变化,方便对账和排查。

CREATE TABLE `inventory_flow` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `sku_id` BIGINT NOT NULL COMMENT 'SKU ID', `order_id` VARCHAR(64) DEFAULT NULL COMMENT '订单号', `change_type` TINYINT NOT NULL COMMENT '变化类型:1-锁定 2-扣减 3-释放 4-入库', `quantity` INT NOT NULL COMMENT '变化数量', `before_quantity` INT NOT NULL COMMENT '变化前可用库存', `after_quantity` INT NOT NULL COMMENT '变化后可用库存', `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_sku_id` (`sku_id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';

3.2 为什么需要库存流水表

很多小项目不建流水表,只在库存表上直接更新数量。刚开始问题不大,但当库存数量异常、业务方质疑“为什么少了一件”、运营要求“把某一笔扣减找出来”的时候,没有流水就会非常被动。

库存流水表是库存模块的“审计日志”。每次锁定、释放、扣减都要记录变化的数量、变化前后的库存、关联订单号和操作类型。有了这张表,就可以通过订单号反查操作链路,也能在库存不一致时通过流水重建现场。

这里有个实践建议:流水表的数据量增长会很快,建议定期归档,或者按月份分表。查询最近一个月的流水走热表,历史流水走归档表。

3.3 初始化数据

插入一条商品和库存测试数据:

INSERT INTO product (product_name, sku_code, price) VALUES ('测试手机', 'SKU1001', 3999.00); INSERT INTO inventory (sku_id, total_quantity, locked_quantity, available_quantity, version) VALUES (1, 100, 0, 100, 0);

这里总库存 100,锁定库存 0,可用库存 100,版本号 0。

4. 核心接口实现:库存查询、预占、扣减与回补

4.1 库存查询

库存查询是最基础、也最容易被忽略的接口。高并发下如果每次查询都直接穿透数据库,会给数据库带来较大压力,所以生产环境通常会在库存表前面加一层 Redis 缓存。

先看数据库查询方式,使用 MyBatis Plus 的 LambdaQueryWrapper:

// 文件路径:src/main/java/com/example/inventory/service/InventoryService.java @Override public Inventory getInventoryBySkuId(Long skuId) { Inventory inventory = inventoryMapper.selectOne( new LambdaQueryWrapper<Inventory>() .eq(Inventory::getSkuId, skuId) ); if (inventory == null) { throw new RuntimeException("SKU不存在"); } return inventory; }

如果使用 MyBatis 原生 Mapper,也可以这样写:

@Select("SELECT id, sku_id, total_quantity, locked_quantity, available_quantity, version " + "FROM inventory WHERE sku_id = #{skuId}") Inventory selectBySkuId(@Param("skuId") Long skuId);

这里需要注意,selectOne方法要求查询结果最多只有一条,所以inventory表上必须保留sku_id的唯一索引。否则一旦出现重复数据,查询会直接抛异常。

4.2 预占/锁定库存

锁定库存是下单时最关键的一步。用户点击“提交订单”后,系统先锁定指定数量的库存,防止其他用户把库存买走。这个阶段还不能直接扣减总库存,因为用户可能最终不支付。

锁定库存的 SQL 使用条件更新,保证“可用库存足够才更新”:

// 文件路径:src/main/java/com/example/inventory/mapper/InventoryMapper.java @Update("UPDATE inventory " + "SET locked_quantity = locked_quantity + #{quantity}, " + " available_quantity = available_quantity - #{quantity}, " + " version = version + 1 " + "WHERE sku_id = #{skuId} " + " AND available_quantity >= #{quantity} " + " AND version = #{version}") int lockInventory(@Param("skuId") Long skuId, @Param("quantity") Integer quantity, @Param("version") Integer version);

这里加入available_quantity >= #{quantity}条件后,即使并发请求同时到达,数据库也只会让满足条件的更新成功,从源头上杜绝超卖。

Service 内实现锁定逻辑:

// 文件路径:src/main/java/com/example/inventory/service/InventoryService.java @Transactional(rollbackFor = Exception.class) public boolean lockStock(Long skuId, Integer quantity, String orderId) { Inventory inventory = inventoryMapper.selectOne( new LambdaQueryWrapper<Inventory>() .eq(Inventory::getSkuId, skuId) ); if (inventory == null) { throw new RuntimeException("SKU不存在"); } int rows = inventoryMapper.lockInventory( skuId, quantity, inventory.getVersion() ); if (rows == 0) { throw new RuntimeException("库存不足或版本冲突,请重试"); } // 记录库存流水 saveFlow(skuId, orderId, 1, quantity, inventory.getAvailableQuantity(), inventory.getAvailableQuantity() - quantity, "下单锁定库存"); return true; }

这个方法的执行顺序是:先查一次库存,拿到当前版本号,然后执行条件更新。如果更新影响行数不为 0,说明扣减成功;如果为 0,说明在这期间库存已经被别人修改过,或者可用库存不足,需要让用户重新确认库存。

4.3 扣减库存

用户支付成功后,系统需要把已经锁定的库存真正扣减掉。此时不再检查可用库存,只检查锁定库存是否足够。

// 文件路径:src/main/java/com/example/inventory/mapper/InventoryMapper.java @Update("UPDATE inventory " + "SET total_quantity = total_quantity - #{quantity}, " + " locked_quantity = locked_quantity - #{quantity} " + "WHERE sku_id = #{skuId} " + " AND locked_quantity >= #{quantity}") int deductInventory(@Param("skuId") Long skuId, @Param("quantity") Integer quantity);

这里为什么要同时扣减总库存和锁定库存?因为之前锁定库存时,已经把可用库存减掉了,总库存没有变化。现在支付完成,商品即将出库,所以总库存也要同步减少,同时把锁定的额度释放掉。

对应的 Service 方法:

@Transactional(rollbackFor = Exception.class) public boolean deductStock(Long skuId, Integer quantity, String orderId) { Inventory inventory = getInventoryBySkuId(skuId); int rows = inventoryMapper.deductInventory(skuId, quantity); if (rows == 0) { throw new RuntimeException("锁定库存不足,扣减失败"); } saveFlow(skuId, orderId, 2, quantity, inventory.getAvailableQuantity(), inventory.getAvailableQuantity(), "支付成功扣减库存"); return true; }

这里流水中的变化前可用库存和变化后可用库存没有变化,因为锁定库存时已经扣减了可用库存,扣减阶段不再影响可用库存。

4.4 释放/回补库存

订单取消、超时未支付或售后退货时,需要把之前锁定的库存释放回可用库存。

// 文件路径:src/main/java/com/example/inventory/mapper/InventoryMapper.java @Update("UPDATE inventory " + "SET locked_quantity = locked_quantity - #{quantity}, " + " available_quantity = available_quantity + #{quantity} " + "WHERE sku_id = #{skuId} " + " AND locked_quantity >= #{quantity}") int releaseInventory(@Param("skuId") Long skuId, @Param("quantity") Integer quantity);

释放库存时,仍然要加条件locked_quantity >= #{quantity},避免因为业务重复取消、并发释放导致锁定库存被扣成负数。

Service 方法如下:

@Transactional(rollbackFor = Exception.class) public boolean releaseStock(Long skuId, Integer quantity, String orderId) { Inventory inventory = getInventoryBySkuId(skuId); int rows = inventoryMapper.releaseInventory(skuId, quantity); if (rows == 0) { throw new RuntimeException("释放库存失败,锁定库存不足"); } saveFlow(skuId, orderId, 3, quantity, inventory.getAvailableQuantity(), inventory.getAvailableQuantity() + quantity, "订单取消释放库存"); return true; }

释放库存的流水里,变化前可用库存和变化后可用库存都需要记录,方便核对回补的库存是否准确。

4.5 运行与验证

启动项目后,可以直接用 Postman 或 Apifox 测试接口。

假设项目运行在8080端口,锁定库存接口如下:

POST http://localhost:8080/inventory/lock Content-Type: application/json { "skuId": 1, "quantity": 2, "orderId": "ORDER20250115001" }

返回成功并记录一条库存流水后,再查询库存接口:

GET http://localhost:8080/inventory/get?skuId=1

预期返回:总库存 100,锁定库存 2,可用库存 98,版本号 1。

如果连续使用同一版本号再次锁定,则因为版本冲突而失败。这是乐观锁的一种典型表现。

5. 高并发库存扣减:防超卖与并发控制

5.1 为什么不能直接 update

很多同学刚接触库存扣减时,喜欢先写查询再写更新:

Inventory inventory = getBySkuId(skuId); if (inventory.getAvailableQuantity() >= quantity) { updateById(...); }

这种“先查后改”在多线程并发下存在竞态条件:两个请求同时查到可用库存为 10,同时判断库存足够,然后同时执行扣减,最终可能把库存扣成负数,造成超卖。

即使在单体项目中,也要尽量避免这种写法。除非方法内加锁,否则数据库的隔离级别并不能阻止这种“读-改-写”的并发问题。正确做法是把库存判断和库存扣减放在同一条UPDATE语句里,让数据库通过行锁和条件更新保证原子性。

5.2 乐观锁方案

乐观锁方案依赖版本号或时间戳。每次更新时比较当前版本号,如果版本号不匹配则更新失败。

核心 SQL 如下:

UPDATE inventory SET locked_quantity = locked_quantity + #{quantity}, available_quantity = available_quantity - #{quantity}, version = version + 1 WHERE sku_id = #{skuId} AND available_quantity >= #{quantity} AND version = #{version};

优点:

  • 适合并发冲突不严重的场景。
  • 实现简单,不需要额外加锁。
  • 通过条件更新防止超卖。

缺点:

  • 高并发冲突时大量请求会更新失败。
  • 需要业务层做重试或返回提示。

如果你的项目并发量不是特别高,可以先采用乐观锁方案。它能保证不超卖,但对用户体验来说,高峰期下单失败率会偏高。

5.3 悲观锁方案

悲观锁使用数据库的SELECT ... FOR UPDATE把库存行锁住,然后判断库存并更新。

@Select("SELECT id, sku_id, total_quantity, locked_quantity, available_quantity, version " + "FROM inventory WHERE sku_id = #{skuId} FOR UPDATE") Inventory selectBySkuIdForUpdate(@Param("skuId") Long skuId);

然后 Service 中:

@Transactional(rollbackFor = Exception.class) public boolean lockStockWithPessimisticLock(Long skuId, Integer quantity, String orderId) { Inventory inventory = inventoryMapper.selectBySkuIdForUpdate(skuId); if (inventory.getAvailableQuantity() < quantity) { throw new RuntimeException("库存不足"); } int rows = inventoryMapper.lockInventory(skuId, quantity, inventory.getVersion()); if (rows == 0) { throw new RuntimeException("锁定失败"); } saveFlow(...); return true; }

注意,FOR UPDATE必须在事务中才能生效。事务提交或回滚后释放行锁。

悲观锁方案的特点:

  • 强一致性,不会因为并发冲突导致大量失败。
  • 适合库存热点比较集中的场景。
  • 缺点是会阻塞其他事务,降低吞吐量,且对数据库连接占用时间较长。

在一些企业内部 ERP、进销存系统中,因为并发量有限但数据一致性要求极高,悲观锁反而是更简单的方案。

5.4 Redis Lua 预扣减方案

当并发量非常大,比如秒杀场景,数据库压力会非常大。这时可以把库存预热到 Redis,通过 Lua 脚本原子地完成扣减判断和扣减操作。

Lua 脚本如下:

-- 文件路径:src/main/resources/lua/stock_deduct.lua local key = KEYS[1] local quantity = tonumber(ARGV[1]) local stock = tonumber(redis.call('get', key)) if stock == false then return -1 end if stock - quantity < 0 then return 0 end redis.call('decrby', key, quantity) return 1

释放库存的脚本:

-- 文件路径:src/main/resources/lua/stock_release.lua local key = KEYS[1] local quantity = tonumber(ARGV[1]) local stock = tonumber(redis.call('get', key)) if stock == false then return -1 end redis.call('incrby', key, quantity) return 1

Java 调用示例:

// 文件路径:src/main/java/com/example/inventory/service/RedisInventoryService.java @Service public class RedisInventoryService { @Resource private StringRedisTemplate stringRedisTemplate; public static final String STOCK_KEY_PREFIX = "inventory:stock:"; private DefaultRedisScript<Long> buildDeductScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/stock_deduct.lua")); script.setResultType(Long.class); return script; } public Long deductStockFromRedis(Long skuId, Integer quantity) { String key = STOCK_KEY_PREFIX + skuId; return stringRedisTemplate.execute( buildDeductScript(), Collections.singletonList(key), String.valueOf(quantity) ); } }

这个方案把“判断库存是否充足”和“扣减库存”放在同一个 Lua 脚本中,Redis 会原子执行整个脚本,不会出现超卖。

但要注意,Redis 预扣减成功之后,业务还没有真正落库。如果后续订单创建失败,需要调用释放脚本回补 Redis 库存,同时还要考虑到数据库库存最终扣减。这里有一个经典的异步双写问题:先更新 Redis,再发 MQ 消息异步扣减数据库库存;如果 MQ 消费失败,则需要定时任务对账。

推荐的整体链路是:

  1. 请求进来,先执行 Redis Lua 扣减预库存。
  2. 扣减成功,创建订单,发送“库存扣减消息”。
  3. MQ 消费者收到消息后,在数据库事务中扣减真实库存并记录流水。
  4. 如果数据库扣减失败,则回补 Redis 库存,并记录失败日志。
  5. 定时任务比对 Redis 库存、数据库库存和流水,发现不一致就告警并人工处理。

这种方案结构更复杂,但能支撑较高的并发。

5.5 消息队列异步扣减

通过消息队列把同一个 SKU 的扣减请求串行化,也是一种常见的防超卖方式。

例如使用 RabbitMQ 或 RocketMQ,订单服务在下单时发送一条扣减消息,消息里包含 SKU ID 和数量。消费端可以保证同一 SKU 的消息串行消费,这样即使服务层没有加锁,数据库也不会同时扣减同一个 SKU。

这种方式适合削峰,但会引入消息中间件,增加架构复杂度。订单状态下发后,用户要等待消息处理完成才能看到库存结果。所以一般会和 Redis 预扣减配合使用,而不是完全依赖消息队列保证不超卖。

6. 完整项目代码示例

为了让整套逻辑更完整,这里把核心代码文件串起来。以下代码基于 Spring Boot + MyBatis Plus,重点看库存更新 SQL 与事务控制。

6.1 pom.xml 依赖

<!-- 文件路径:pom.xml --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

注意,不同 Spring Boot 版本对 MyBatis Plus 的兼容性不同。如果使用 Spring Boot 3,需要把javax换成jakarta,并选择适配 Spring Boot 3 的 MyBatis Plus 版本。

6.2 application.yml 配置

# 文件路径:src/main/resources/application.yml server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/inventory_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root redis: host: localhost port: 6379 database: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

如果你的 MySQL 密码、Redis 密码与示例不同,请改成自己的配置。map-underscore-to-camel-case: true可以让数据库字段sku_id自动映射到 Java 属性skuId。

6.3 启动类与实体类

// 文件路径:src/main/java/com/example/inventory/InventoryApplication.java @SpringBootApplication @MapperScan("com.example.inventory.mapper") public class InventoryApplication { public static void main(String[] args) { SpringApplication.run(InventoryApplication.class, args); } }
// 文件路径:src/main/java/com/example/inventory/entity/Inventory.java @Data @TableName("inventory") public class Inventory { @TableId(type = IdType.AUTO) private Long id; private Long skuId; private Integer totalQuantity; private Integer lockedQuantity; private Integer availableQuantity; private Integer version; }
// 文件路径:src/main/java/com/example/inventory/entity/InventoryFlow.java @Data @TableName("inventory_flow") public class InventoryFlow { @TableId(type = IdType.AUTO) private Long id; private Long skuId; private String orderId; private Integer changeType; private Integer quantity; private Integer beforeQuantity; private Integer afterQuantity; private String remark; }

6.4 Mapper 接口

// 文件路径:src/main/java/com/example/inventory/mapper/InventoryMapper.java @Mapper public interface InventoryMapper extends BaseMapper<Inventory> { @Update("UPDATE inventory " + "SET locked_quantity = locked_quantity + #{quantity}, " + " available_quantity = available_quantity - #{quantity}, " + " version = version + 1 " + "WHERE sku_id = #{skuId} " + " AND available_quantity >= #{quantity} " + " AND version = #{version}") int lockInventory(@Param("skuId") Long skuId, @Param("quantity") Integer quantity, @Param("version") Integer version); @Update("UPDATE inventory " + "SET total_quantity = total_quantity - #{quantity}, " + " locked_quantity = locked_quantity - #{quantity} " + "WHERE sku_id = #{skuId} " + " AND locked_quantity >= #{quantity}") int deductInventory(@Param("skuId") Long skuId, @Param("quantity") Integer quantity); @Update("UPDATE inventory " + "SET locked_quantity = locked_quantity - #{quantity}, " + " available_quantity = available_quantity + #{quantity} " + "WHERE sku_id = #{skuId} " + " AND locked_quantity >= #{quantity}") int releaseInventory(@Param("skuId") Long skuId, @Param("quantity") Integer quantity); }

这里不额外定义InventoryFlowMapper,为了节省篇幅,流水写入在 Service 中通过insert方式完成。你也可以创建一个InventoryFlowMapper继承BaseMapper。

6.5 Service 完整实现

// 文件路径:src/main/java/com/example/inventory/service/InventoryService.java @Service public class InventoryService { @Resource private InventoryMapper inventoryMapper; @Transactional(rollbackFor = Exception.class) public boolean lockStock(Long skuId, Integer quantity, String orderId) { Inventory inventory = getInventoryBySkuId(skuId); int rows = inventoryMapper.lockInventory(skuId, quantity, inventory.getVersion()); if (rows == 0) { throw new RuntimeException("库存不足或版本冲突,请重试"); } saveFlow(skuId, orderId, 1, quantity, inventory.getAvailableQuantity(), inventory.getAvailableQuantity() - quantity, "下单锁定库存"); return true; } @Transactional(rollbackFor = Exception.class) public boolean deductStock(Long skuId, Integer quantity, String orderId) { inventoryMapper.deductInventory(skuId, quantity); Inventory inventory = getInventoryBySkuId(skuId); saveFlow(skuId, orderId, 2, quantity, inventory.getAvailableQuantity(), inventory.getAvailableQuantity(), "支付成功扣减库存"); return true; } @Transactional(rollbackFor = Exception.class) public boolean releaseStock(Long skuId, Integer quantity, String orderId) { Inventory inventory = getInventoryBySkuId(skuId); int rows = inventoryMapper.releaseInventory(skuId, quantity); if (rows == 0) { throw new RuntimeException("释放库存失败"); } saveFlow(skuId, orderId, 3, quantity, inventory.getAvailableQuantity(), inventory.getAvailableQuantity() + quantity, "订单取消释放库存"); return true; } public Inventory getInventoryBySkuId(Long skuId) { Inventory inventory = inventoryMapper.selectOne( new LambdaQueryWrapper<Inventory>() .eq(Inventory::getSkuId, skuId) ); if (inventory == null) { throw new RuntimeException("SKU不存在"); } return inventory; } private void saveFlow(Long skuId, String orderId, Integer changeType, Integer quantity, Integer beforeQuantity, Integer afterQuantity, String remark) { InventoryFlow flow = new InventoryFlow(); flow.setSkuId(skuId); flow.setOrderId(orderId); flow.setChangeType(changeType); flow.setQuantity(quantity); flow.setBeforeQuantity(beforeQuantity); flow.setAfterQuantity(afterQuantity); flow.setRemark(remark); inventoryFlowMapper.insert(flow); } }

如果你的项目没有注入InventoryFlowMapper,记得在 Service 中补充定义:

@Resource private InventoryFlowMapper inventoryFlowMapper;

并且创建对应的 Mapper:

// 文件路径:src/main/java/com/example/inventory/mapper/InventoryFlowMapper.java @Mapper public interface InventoryFlowMapper extends BaseMapper<InventoryFlow> { }

6.6 Controller 示例

// 文件路径:src/main/java/com/example/inventory/controller/InventoryController.java @RestController @RequestMapping("/inventory") public class InventoryController { @Resource private InventoryService inventoryService; @GetMapping("/get") public Inventory getInventory(Long skuId) { return inventoryService.getInventoryBySkuId(skuId); } @PostMapping("/lock") public String lock(@RequestBody LockRequest request) { inventoryService.lockStock(request.getSkuId(), request.getQuantity(), request.getOrderId()); return "success"; } @PostMapping("/deduct") public String deduct(@RequestBody LockRequest request) { inventoryService.deductStock(request.getSkuId(), request.getQuantity(), request.getOrderId()); return "success"; } @PostMapping("/release") public String release(@RequestBody LockRequest request) { inventoryService.releaseStock(request.getSkuId(), request.getQuantity(), request.getOrderId()); return "success"; } @Data public static class LockRequest { private Long skuId; private Integer quantity; private String orderId; } }

到这里,一套基于数据库事务条件的库存闭合链路已经跑通。你可以把项目启动后,按照 4.5 中的请求示例测试。

7. 常见问题与排查思路

在实际开发中,库存模块的高频问题往往不是代码写不出来,而是并发或者数据一致性出问题。下面整理了一份排查清单。

问题现象常见原因解决思路
库存扣成负数先查库存再更新,没有原子条件使用条件更新:available_quantity >= quantity
乐观锁冲突导致下单失败并发高,版本号频繁不匹配增加重试机制,或切换悲观锁/Redis Lua
锁定库存后订单未支付,库存不释放没有超时释放机制增加订单超时自动取消任务,并调用释放库存接口
Redis 预扣减成功,但数据库库存最终没扣MQ 消费失败或漏发消息增加定时对账,用流水表比对待扣减记录
库存流水与库存表不一致更新库存和写入流水不在同一事务确保库存更新、流水插入在同一个@Transactional内
重复扣减库存接口没有做幂等控制使用订单号或业务单号做唯一索引,重复请求直接拒绝
查询库存很慢缺少索引或全表扫描为sku_id建唯一索引,流水表建order_id/sku_id索引
同一 SKU 并发下单时吞吐量低使用行锁或悲观锁阻塞使用 Redis 预扣减 + MQ 异步落库

如果遇到库存异常,建议排查顺序如下:

  1. 查库存流水,找对应order_id下的变更记录。
  2. 对比流水中before_quantity和after_quantity,确认变化量是否连续。
  3. 查 Redis 当前库存和数据库库存,看两者是否一致。
  4. 查服务日志,重点看数据库更新影响行数是否为 0。
  5. 如果发现流水缺失,检查事务是否回滚,或者是否绕过了库存服务直接更新数据库。

8. 最佳实践与工程建议

做完一个基础库存模块后,想在项目里真正稳定运行,还需要注意下面这些工程问题。

第一,所有库存变更必须走同一个库存服务。很多系统出现库存不一致,是因为不同模块各写各的UPDATE inventory语句。有的在订单服务扣,有的在售后服务扣,有的直接在 DB 管理工具里手工改。建议所有库存操作都收敛到独立的 inventory 服务或独立 Service 中,方便统一加锁、统一写流水、统一监控。

第二,库存流水要设置唯一约束和幂等键。比如用order_id + change_type + sku_id作为业务幂等键,防止消息重复消费、接口重试导致重复扣减。

可以给inventory_flow增加唯一索引:

ALTER TABLE `inventory_flow` ADD UNIQUE KEY `uk_order_change_sku` (`order_id`, `change_type`, `sku_id`);

第三,库存表更新时,尽量使用行级条件更新,避免“查出来再判断”的写法。哪怕只是简单扣减,也要习惯把库存充足条件写到 SQL 的WHERE中。

第四,引入 Redis 后要考虑缓存与数据库的一致性问题。建议采用以下策略:

  • 启动或运营编辑库存后,主动刷新 Redis 库存。
  • Redis 库存设置合理过期时间,比如 30 分钟,过期后回源数据库。
  • 每次数据库库存扣减成功后,删除 Redis 缓存,让下一次读取重新加载。
  • 定时任务每 5 分钟扫描 Redis 库存和数据库库存差异,异常时告警。

第五,库存扣减失败一定要有明确的错误码。不要说“系统繁忙”,至少区分:

  • 库存不足
  • 商品不存在
  • 重复操作
  • 库存服务超时

这样前端和客户端才能做差异化提示。

第六,生产环境中的库存调整必须走审批和审计。运营手工调整库存是风险很高的操作,建议提供独立的管理端接口,操作记录写入操作日志,并且不能直接连数据库修改业务表。

第七,事务超时和数据库连接需要注意。悲观锁方案中,如果事务内有远程调用,很容易出现长事务。建议库存事务内只做数据库操作,不要嵌套外部 HTTP 请求。远程调用放在事务提交后再执行。

9. 总结与学习路线

本文围绕库存管理完整梳理了从数据模型、表设计到库存锁定、扣减、释放的落地流程,也分析了乐观锁、悲观锁、Redis Lua 预扣减和 MQ 异步消峰几种主流并发方案。你可以根据项目规模选择合适的方案:中小系统先用数据库条件更新即可,高并发场景再逐步引入 Redis 和消息队列。

接下来可以继续学习这些方向:

  • 订单超时自动取消:结合延迟消息或定时任务释放库存。
  • 库存对账系统:通过流水表每日核对订单与库存数据。
  • 分布式事务:跨服务和跨库时如何保证库存与订单一致。
  • 多仓库库存:在库存模型中增加仓库维度,做仓配库存管理。
  • 库存盘点:结合 Redis 和数据库实现高并发盘点逻辑。

在实际项目中,不建议一上来就堆 Redis、MQ、分布式事务。先把单库事务版本的库存链路跑通,再把 Redis 预扣减引入热点商品,最后根据业务体量决定是否引入异步队列。这种循序渐进的方式,更容易排查问题,也更容易保证系统稳定。

如果这篇文章对你有帮助,可以收藏备用。下次遇到库存超卖、锁库存失败或者流水对不上时,按照本文的思路一步步排查,大多数问题都能快速定位。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 2:04:23

定序Probit模型实战:信用卡信用评级从建模到决策

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 2:01:10

Open Code Review:可审计、可验证的开源代码审查范式

1. 这不是又一个“AI代码审查工具”&#xff0c;而是一套可审计、可验证、可嵌入CI的开源协作范式 你有没有遇到过这样的场景&#xff1a;团队里新来一位 junior 开发者&#xff0c;提交了一段看似逻辑通顺的 Python 脚本——它能跑通单元测试&#xff0c;也能在本地环境输出预…

作者头像 李华
网站建设 2026/9/26 2:00:27

Oracle EBS AP预付款管理:从创建到核销的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 2:00:27

【亲测免费】 Pyxel - 一个复古风格的游戏开发框架

Pyxel - 一个复古风格的游戏开发框架 【免费下载链接】pyxel A retro game engine for Python 项目地址: https://gitcode.com/GitHub_Trending/py/pyxel 是一个由 Kitao 制作的 Python 库&#xff0c;它旨在简化2D游戏的开发过程&#xff0c;并提供了一种独特的复古视觉…

作者头像 李华