ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

从表设计到高并发防超卖: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 作为主技术栈数据库使用 MySQLORM 使用 MyBatis Plus高并发示例使用 Redis Lua 脚本。这套组合在电商、企业后台系统中非常常见资料多容易落地。技术环境如下版本需要根据你的项目实际情况调整JDK1.8 或 11如果使用 Spring Boot 3需要 JDK 17。Spring Boot2.7.x 或 3.x示例以 2.7.x 常见写法为主。MySQL5.7 或 8.0推荐 8.0。MyBatis Plus3.5.x。Redis5.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 LambdaQueryWrapperInventory() .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 LambdaQueryWrapperInventory() .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?skuId1预期返回总库存 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 DefaultRedisScriptLong buildDeductScript() { DefaultRedisScriptLong 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 BaseMapperInventory { 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 LambdaQueryWrapperInventory() .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 BaseMapperInventoryFlow { }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 预扣减引入热点商品最后根据业务体量决定是否引入异步队列。这种循序渐进的方式更容易排查问题也更容易保证系统稳定。如果这篇文章对你有帮助可以收藏备用。下次遇到库存超卖、锁库存失败或者流水对不上时按照本文的思路一步步排查大多数问题都能快速定位。
返回列表