做后端开发、电商系统或者进销存系统时,库存模块往往是绕不开的核心业务。很多项目初期“先做能跑的功能”,到后面做秒杀、下单、退款时,库存扣减就开始出各种问题:超卖、少卖、流水对不上、数据不一致。这篇文章会围绕库存管理这个业务场景,从数据库表设计、核心接口实现,到高并发防超卖方案,完整梳理一套可落地的库存扣减体系。内容包括完整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 1Java 调用示例:
// 文件路径: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 消费失败,则需要定时任务对账。
推荐的整体链路是:
- 请求进来,先执行 Redis Lua 扣减预库存。
- 扣减成功,创建订单,发送“库存扣减消息”。
- MQ 消费者收到消息后,在数据库事务中扣减真实库存并记录流水。
- 如果数据库扣减失败,则回补 Redis 库存,并记录失败日志。
- 定时任务比对 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 异步落库 |
如果遇到库存异常,建议排查顺序如下:
- 查库存流水,找对应
order_id下的变更记录。 - 对比流水中
before_quantity和after_quantity,确认变化量是否连续。 - 查 Redis 当前库存和数据库库存,看两者是否一致。
- 查服务日志,重点看数据库更新影响行数是否为 0。
- 如果发现流水缺失,检查事务是否回滚,或者是否绕过了库存服务直接更新数据库。
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 预扣减引入热点商品,最后根据业务体量决定是否引入异步队列。这种循序渐进的方式,更容易排查问题,也更容易保证系统稳定。
如果这篇文章对你有帮助,可以收藏备用。下次遇到库存超卖、锁库存失败或者流水对不上时,按照本文的思路一步步排查,大多数问题都能快速定位。