夯爆了,一文讲透基于支付成功事件实现锁库存转实扣(超详解)!
🏆本文收录于《滚雪球学SpringBoot 3.x》,专门攻坚指数提升,本年度国内最系统+最专业+最详细(永久更新)。
该专栏致力打造最硬核 SpringBoot3 从零基础到进阶系列学习内容,🚀均为全网独家首发,打造精品专栏,专栏持续更新中…欢迎大家订阅持续学习。 如果想快速定位学习,可以看这篇【SpringBoot3教程导航帖】,你想学习的都被收集在内,快速投入学习!!两不误。
若还想学习更多,可直接订阅 《Spring Boot实战合集》,一次订阅,持续学习,后续更新内容无需重复付费,适合长期收藏与系统进阶。
演示环境说明:
- 开发工具:IDEA 2021.3
- JDK版本: JDK 17(推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 系列要求 Java 17,Spring Boot 3.5.4 基于 Spring Framework 6.x 和 Jakarta EE 9,它们都要求至少 JDK 17。)
- Spring Boot版本:3.5.4(于25年7月24日发布)
- Maven版本:3.8.2 (或更高)
- Gradle:(如果使用 Gradle 构建工具的话):推荐使用 Gradle 7.5 或更高版本,确保与 JDK 17 兼容。
- 操作系统:Windows 11
一、写在前面:为什么这个主题值得单独写一篇长文?
在电商、零售、本地生活、票务、会员订阅甚至 SaaS 资源售卖等系统中,库存扣减从来都不是一个“减一下数字”这么简单的问题。
初学者通常会把库存逻辑想象成下面这样:
- 用户下单;
- 数据库
stock - 1; - 用户支付;
- 订单完成。
这套思路在课堂练习里成立,但在真实业务中很快就会遇到一连串问题:
- 用户创建订单后迟迟不支付,库存却被一直占住;
- 同一商品被大量并发下单,超卖风险急剧上升;
- 支付成功回调出现重复通知,库存会不会被重复扣减;
- 支付成功了,但库存服务处理消息失败,订单和库存状态不一致怎么办;
- 订单取消、支付超时、补偿回滚、库存释放之间如何协同;
- 如何让业务代码既清晰又可维护,而不是全部堆在一个
OrderService里。
因此,线上系统里更常见的并不是“直接扣减库存”,而是采用两阶段思路:
-
第一阶段:锁库存(预占库存)
用户提交订单后,先把可售库存转为“锁定库存”,表示这部分库存暂时不能再卖给别人。 -
第二阶段:转实扣(确认扣减)
当支付成功事件到来时,再把“锁定库存”正式转为“已扣减库存”或减少总可用数,完成最终消耗。
这就是本文要讲的核心:基于支付成功事件实现锁库存转实扣。
它背后看似只是一个业务动作,但实际上串起了 Spring Boot 3.x 中非常多的核心知识点:
- Spring Boot 3.x 的工程搭建与分层设计;
- Java 17 语言特性在业务建模中的合理运用;
- 领域对象、状态机与业务约束设计;
- 数据库事务边界与消息一致性;
- 事件驱动架构在订单、支付、库存中的落地;
- 幂等控制、重复消费防护、补偿机制;
- 可观测性、日志、监控、压测与生产部署建议。
所以这篇文章不是一篇“只教你写几个接口”的水文,而是会把这个场景从业务认知、建模、代码、运行流程、异常处理、架构演进完整讲透。
二、先打基础:Spring Boot 3.x 到底意味着什么?
很多读者会问:为什么专栏叫《零基础学 Spring Boot 3.x》,文章就一定要强调 3.x?
原因很简单:Spring Boot 3.x 不是 2.x 的简单小升级,而是一次技术代际切换。
2.1 Spring Boot 3.x 的几个关键基础变化
Spring Boot 3.x 这一代通常意味着以下技术基线:
-
JDK 17 起步
这意味着项目默认就能使用 Java 17 的语法和标准库能力,例如record、增强后的switch、更成熟的StreamAPI、文本块等。 -
Spring Framework 6
这是 Spring 生态新一代核心框架,对 AOT、原生镜像、可观测性等方向都更友好。 -
Jakarta EE 9+ 命名空间迁移
大量原来的javax.*包迁移到了jakarta.*。例如:javax.servlet.*→jakarta.servlet.*javax.validation.*→jakarta.validation.*javax.persistence.*→jakarta.persistence.*
-
更现代化的可观测能力
Spring Boot 3.x 对 Micrometer、Tracing、Metrics、Observation 的整合更成熟,适合构建生产级服务。 -
面向云原生与生产级应用的工程体验更强
包括启动、配置、容器化、健康检查、指标暴露等,整体上比早期版本更贴近现代微服务实践。
2.2 对初学者来说,3.x 学习重点是什么
很多人一看到新版本就担心:“是不是很难?”
其实恰恰相反。对于零基础读者来说,直接学习 Spring Boot 3.x 反而更好,因为你可以一步到位建立现代 Java 后端的认知,不必先学一套旧的,再花大量时间迁移。
你需要抓住这几个学习重点:
- 不是记注解,而是理解业务分层;
- 不是会写 CRUD,而是理解事务与一致性;
- 不是只会调接口,而是理解事件驱动的边界;
- 不是写个 demo 就结束,而是知道线上会出什么问题。
而本文的“锁库存转实扣”场景,正好可以把这些点串起来。
三、先理解业务:什么叫锁库存,什么叫转实扣
3.1 直接扣库存为什么不优雅
假设某商品库存 100 件,两个用户同时下单。用户 A 创建订单后没有支付,用户 B 却已经完成支付。如果系统在创建订单瞬间就直接扣库存,那么:
- 订单未支付也占用了真实库存;
- 如果大量人“拍下不付款”,库存被白白吃掉;
- 商家会看到库存不足,但真实已支付订单并不多;
- 需要额外任务去恢复库存,系统很容易混乱。
这就是为什么多数交易系统会引入“锁库存”概念。
3.2 锁库存的本质
锁库存可以理解为一种预占资源行为。
例如某 SKU 当前数据如下:
- 总库存:100
- 已锁定:0
- 已售出:0
- 可售库存:100
当用户创建订单并提交 3 件商品时,不立刻算“已售出 +3”,而是:
- 已锁定:0 → 3
- 可售库存:100 → 97
- 已售出:仍为 0
这表示这 3 件商品暂时被这个订单占住,别人不能再购买,但还没有真正成交。
3.3 转实扣的本质
当支付成功时,说明交易达成,那么这时要把锁定库存转为真实扣减。
例如:
- 已锁定:3 → 0
- 已售出:0 → 3
- 可售库存:保持 97(因为早在锁库存时就减少了)
或者在另一种库存模型里:
- 总库存:100 → 97
- 锁定库存:3 → 0
不同系统的数据字段可能不同,但含义一致:支付成功后,把“暂时占住”变成“最终消耗”。
3.4 为什么要基于支付成功事件来做
因为支付系统通常是独立域,支付结果往往不是同步强耦合地由订单服务“立即知道”的,而是通过以下方式传达:
- 支付平台回调;
- 支付服务落库后发送消息;
- 订单服务订阅支付成功事件;
- 库存服务消费该事件并执行“锁库存转实扣”。
这样做的好处有三点:
- 职责清晰:支付归支付域,库存归库存域;
- 可扩展:支付成功后不只是扣库存,还可能触发积分、优惠券核销、发货、短信通知等;
- 更贴近真实分布式系统:一个事件可被多个下游能力复用。
四、业务全景图:订单、支付、库存的协作关系
先用一张图建立整体认知。

这张图说明了几个关键点:
- 下单时做的是锁库存;
- 支付成功后靠事件驱动库存确认扣减;
- 订单状态与库存状态分别由各自领域负责;
- 库存服务和订单服务都可以订阅同一个支付成功事件。
如果你刚接触微服务或事件驱动,先不要急着被“MQ”“最终一致性”这些词吓到。你只需要先记住一句话:
谁产生业务结果,谁发布事件;谁关心这个结果,谁订阅事件并做自己的业务动作。
五、设计库存模型:不要一上来只想着 stock - 1
5.1 推荐的库存字段设计
我们先设计一个适合初学者理解、也适合工程实践的库存表。
CREATE TABLE t_inventory (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_code VARCHAR(64) NOT NULL UNIQUE,
total_stock INT NOT NULL,
locked_stock INT NOT NULL DEFAULT 0,
sold_stock INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
字段说明:
total_stock:总库存;locked_stock:已锁定但未支付完成的库存;sold_stock:已真实成交的库存;version:乐观锁版本号;- 可售库存可由公式计算:
available = total_stock - locked_stock - sold_stock。
5.2 为什么不用单字段库存
有些简单系统只保留一个 stock 字段,这样写起来最省事,但问题也最大:
- 无法区分“锁定中”与“已售出”;
- 无法精确排查订单占用情况;
- 出错后难以补偿和追踪;
- 数据分析和对账会非常难。
因此,哪怕是 demo,也建议你从一开始就把库存拆成不同语义字段。这是一种非常重要的工程思维。
5.3 锁库存与转实扣的状态变化
用状态变化表示会更清楚:

库存不是只有一个“剩余数值”,而是会随着业务流转发生状态迁移。
六、领域建模:用 Spring Boot 3.x 写清楚业务对象
这一节开始进入代码。为了保证可运行、可理解、可拓展,本文示例采用如下工程结构:
mall-demo
├─ order-domain
├─ payment-domain
└─ inventory-domain
为了降低上手门槛,本文把示例收敛成单体模块、领域分包的形式,便于本地直接运行;但代码结构会刻意保留演进成微服务的边界感。
6.1 Maven 依赖
下面给出一个可运行的 pom.xml 核心依赖示例。
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>inventory-confirm-demo</artifactId>
<version>1.0.0</version>
<name>inventory-confirm-demo</name>
<description>基于支付成功事件实现锁库存转实扣</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web 开发依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- AMQP,用于 RabbitMQ 消息驱动 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok,可选,用于减少样板代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
代码解析
这份依赖里最值得初学者注意的有三点:
- Spring Boot 3.x 下我们直接用 Java 17;
- 校验相关依赖已经是
jakarta.validation体系; - MQ 不是必须,但本文主题是“基于支付成功事件”,因此我们需要一个消息驱动中介。
6.2 启动类
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 应用启动类
*/
@SpringBootApplication
public class InventoryConfirmApplication {
public static void main(String[] args) {
SpringApplication.run(InventoryConfirmApplication.class, args);
}
}
七、核心实体设计:库存、库存锁定记录、订单、支付事件
7.1 库存实体 Inventory
package com.example.demo.inventory.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 库存实体
*/
@Getter
@Setter
@Entity
@Table(name = "t_inventory")
public class Inventory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 商品 SKU 编码
*/
@Column(name = "sku_code", nullable = false, unique = true, length = 64)
private String skuCode;
/**
* 总库存
*/
@Column(name = "total_stock", nullable = false)
private Integer totalStock;
/**
* 锁定库存
*/
@Column(name = "locked_stock", nullable = false)
private Integer lockedStock;
/**
* 已售库存
*/
@Column(name = "sold_stock", nullable = false)
private Integer soldStock;
/**
* 乐观锁版本号
*/
@Version
@Column(name = "version", nullable = false)
private Long version;
@Column(name = "create_time", nullable = false)
private LocalDateTime createTime;
@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;
/**
* 获取可售库存
*/
public int getAvailableStock() {
return totalStock - lockedStock - soldStock;
}
/**
* 锁库存
*/
public void lock(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("锁定数量必须大于 0");
}
if (getAvailableStock() < quantity) {
throw new IllegalStateException("可售库存不足,无法锁定");
}
this.lockedStock += quantity;
this.updateTime = LocalDateTime.now();
}
/**
* 支付成功后,将锁定库存转为实扣
*/
public void confirmDeduct(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("确认扣减数量必须大于 0");
}
if (this.lockedStock < quantity) {
throw new IllegalStateException("锁定库存不足,无法转实扣");
}
this.lockedStock -= quantity;
this.soldStock += quantity;
this.updateTime = LocalDateTime.now();
}
/**
* 释放锁定库存
*/
public void release(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("释放数量必须大于 0");
}
if (this.lockedStock < quantity) {
throw new IllegalStateException("锁定库存不足,无法释放");
}
this.lockedStock -= quantity;
this.updateTime = LocalDateTime.now();
}
}
代码解析
这段代码体现了一个很重要的思想:不要把业务规则散落在 Service 里,能放进领域对象的方法,尽量放进对象内部。
例如:
- 锁库存时要校验可售库存;
- 转实扣时要校验锁定库存是否足够;
- 释放时也要做边界判断。
这样做的好处是:
- 业务语义集中;
- 代码更容易复用;
- 不容易出现“某个服务忘记校验”的问题。
7.2 库存锁定记录 InventoryReservation
仅有库存总表还不够,我们还需要记录“哪一个订单锁定了哪一个 SKU 多少数量”。
package com.example.demo.inventory.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 库存锁定记录实体
*/
@Getter
@Setter
@Entity
@Table(name = "t_inventory_reservation",
uniqueConstraints = @UniqueConstraint(name = "uk_order_sku", columnNames = {"order_no", "sku_code"}))
public class InventoryReservation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 订单号
*/
@Column(name = "order_no", nullable = false, length = 64)
private String orderNo;
/**
* SKU 编码
*/
@Column(name = "sku_code", nullable = false, length = 64)
private String skuCode;
/**
* 锁定数量
*/
@Column(name = "quantity", nullable = false)
private Integer quantity;
/**
* 锁定状态:LOCKED-已锁定,CONFIRMED-已转实扣,RELEASED-已释放
*/
@Column(name = "status", nullable = false, length = 32)
private String status;
@Column(name = "create_time", nullable = false)
private LocalDateTime createTime;
@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;
public void confirm() {
if (!"LOCKED".equals(this.status)) {
throw new IllegalStateException("当前锁定记录状态不是 LOCKED,不能转实扣");
}
this.status = "CONFIRMED";
this.updateTime = LocalDateTime.now();
}
public void release() {
if (!"LOCKED".equals(this.status)) {
throw new IllegalStateException("当前锁定记录状态不是 LOCKED,不能释放");
}
this.status = "RELEASED";
this.updateTime = LocalDateTime.now();
}
}
为什么必须有这张表
很多人做库存只改总表,不建锁定记录表,这是线上事故高发点。原因在于:
- 你不知道哪笔订单占了多少库存;
- 支付成功时,不知道该转哪一笔锁定;
- 订单取消时,无法精确释放;
- 对账时无法追踪业务链路;
- 幂等时缺少最重要的“历史依据”。
库存总表解决“量”的问题,锁定记录表解决“来源”和“归属”的问题。
7.3 订单实体 Order
package com.example.demo.order.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 订单实体
*/
@Getter
@Setter
@Entity
@Table(name = "t_order")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 订单号
*/
@Column(name = "order_no", nullable = false, unique = true, length = 64)
private String orderNo;
/**
* 订单状态:CREATED、WAIT_PAY、PAID、CANCELLED
*/
@Column(name = "status", nullable = false, length = 32)
private String status;
/**
* 订单金额
*/
@Column(name = "amount", nullable = false, precision = 12, scale = 2)
private BigDecimal amount;
@Column(name = "create_time", nullable = false)
private LocalDateTime createTime;
@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;
public void markWaitPay() {
this.status = "WAIT_PAY";
this.updateTime = LocalDateTime.now();
}
public void markPaid() {
if (!"WAIT_PAY".equals(this.status)) {
throw new IllegalStateException("当前订单状态不允许修改为已支付");
}
this.status = "PAID";
this.updateTime = LocalDateTime.now();
}
public void cancel() {
if (!"WAIT_PAY".equals(this.status)) {
throw new IllegalStateException("当前订单状态不允许取消");
}
this.status = "CANCELLED";
this.updateTime = LocalDateTime.now();
}
}
7.4 支付成功事件对象 PaymentSuccessEvent
package com.example.demo.payment.event;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 支付成功事件
* 该对象既可以作为领域事件对象,也可以作为消息体对象使用
*/
public record PaymentSuccessEvent(
String paymentNo,
String orderNo,
BigDecimal paidAmount,
LocalDateTime paidTime
) {
}
代码解析
这里我们使用了 Java 17 的 record,非常适合这种只承载数据、不需要复杂行为的事件对象。
这也是 Spring Boot 3.x 学习过程中很值得掌握的风格:
- 实体类有状态变更行为,用
class; - 请求对象、响应对象、事件对象这种偏 DTO 的结构,用
record很舒服。
八、Repository 层:让数据访问清晰且可维护
8.1 InventoryRepository
package com.example.demo.inventory.repository;
import com.example.demo.inventory.entity.Inventory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* 库存仓储接口
*/
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
/**
* 根据 SKU 查询库存
*/
Optional<Inventory> findBySkuCode(String skuCode);
}
8.2 InventoryReservationRepository
package com.example.demo.inventory.repository;
import com.example.demo.inventory.entity.InventoryReservation;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 库存锁定记录仓储接口
*/
public interface InventoryReservationRepository extends JpaRepository<InventoryReservation, Long> {
/**
* 查询某个订单下的所有锁定记录
*/
List<InventoryReservation> findByOrderNo(String orderNo);
/**
* 查询某订单某 SKU 的锁定记录
*/
Optional<InventoryReservation> findByOrderNoAndSkuCode(String orderNo, String skuCode);
}
8.3 OrderRepository
package com.example.demo.order.repository;
import com.example.demo.order.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* 订单仓储接口
*/
public interface OrderRepository extends JpaRepository<Order, Long> {
/**
* 根据订单号查询订单
*/
Optional<Order> findByOrderNo(String orderNo);
}
九、第一阶段:下单时如何锁库存
9.1 先定义请求对象
package com.example.demo.order.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
/**
* 下单商品项
*/
public record CreateOrderItemRequest(
@NotBlank(message = "SKU 编码不能为空")
String skuCode,
@Min(value = 1, message = "购买数量必须大于等于 1")
Integer quantity
) {
}
package com.example.demo.order.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* 创建订单请求
*/
public record CreateOrderRequest(
@Valid
@NotEmpty(message = "订单商品不能为空")
List<CreateOrderItemRequest> items
) {
}
代码解析
这里用到的 jakarta.validation 正是 Spring Boot 3.x 体系下的新命名空间,这也是 3.x 与老项目最明显的差异之一。
9.2 库存服务:锁库存
package com.example.demo.inventory.service;
import com.example.demo.inventory.entity.Inventory;
import com.example.demo.inventory.entity.InventoryReservation;
import com.example.demo.inventory.repository.InventoryRepository;
import com.example.demo.inventory.repository.InventoryReservationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 库存领域服务
*/
@Service
@RequiredArgsConstructor
public class InventoryService {
private final InventoryRepository inventoryRepository;
private final InventoryReservationRepository reservationRepository;
/**
* 锁定库存
*
* @param orderNo 订单号
* @param skuCode SKU 编码
* @param quantity 锁定数量
*/
@Transactional
public void lockStock(String orderNo, String skuCode, int quantity) {
Inventory inventory = inventoryRepository.findBySkuCode(skuCode)
.orElseThrow(() -> new IllegalArgumentException("库存不存在,skuCode=" + skuCode));
// 调用领域对象方法锁库存
inventory.lock(quantity);
inventoryRepository.save(inventory);
InventoryReservation reservation = new InventoryReservation();
reservation.setOrderNo(orderNo);
reservation.setSkuCode(skuCode);
reservation.setQuantity(quantity);
reservation.setStatus("LOCKED");
reservation.setCreateTime(LocalDateTime.now());
reservation.setUpdateTime(LocalDateTime.now());
reservationRepository.save(reservation);
}
/**
* 查询订单的库存锁定记录
*/
public List<InventoryReservation> findReservationsByOrderNo(String orderNo) {
return reservationRepository.findByOrderNo(orderNo);
}
}
9.3 订单服务:创建订单并锁库存
package com.example.demo.order.service;
import com.example.demo.order.dto.CreateOrderItemRequest;
import com.example.demo.order.dto.CreateOrderRequest;
import com.example.demo.order.entity.Order;
import com.example.demo.order.repository.OrderRepository;
import com.example.demo.inventory.service.InventoryService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 订单服务
*/
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
/**
* 创建订单并锁库存
*/
@Transactional
public String createOrder(CreateOrderRequest request) {
String orderNo = "ORD" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
Order order = new Order();
order.setOrderNo(orderNo);
order.setStatus("CREATED");
order.setAmount(BigDecimal.ZERO);
order.setCreateTime(LocalDateTime.now());
order.setUpdateTime(LocalDateTime.now());
order.markWaitPay();
orderRepository.save(order);
for (CreateOrderItemRequest item : request.items()) {
inventoryService.lockStock(orderNo, item.skuCode(), item.quantity());
}
return orderNo;
}
}
9.4 下单接口
package com.example.demo.order.controller;
import com.example.demo.order.dto.CreateOrderRequest;
import com.example.demo.order.service.OrderService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 订单控制器
*/
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
/**
* 创建订单
*/
@PostMapping
public Map<String, Object> createOrder(@Valid @RequestBody CreateOrderRequest request) {
String orderNo = orderService.createOrder(request);
return Map.of(
"success", true,
"orderNo", orderNo,
"message", "订单创建成功,库存已锁定,等待支付"
);
}
}
这一阶段的核心理解
当接口调用成功时,系统只完成了两件事:
- 创建订单;
- 锁定库存。
此时并没有真实卖出去。
也就是说,“锁库存成功”≠“交易成功”。这一点是初学者必须牢牢记住的。
十、第二阶段:支付成功事件如何驱动转实扣
真正的重点来了。
10.1 为什么不用同步调用库存确认接口,而要用支付成功事件
有些人会问:支付成功后,支付服务直接远程调用库存服务一个接口不就行了吗?
理论上可以,但不够优雅,主要问题包括:
- 支付服务和库存服务强耦合;
- 支付成功后往往不止一个后续动作;
- 链路过长,同步失败会放大事务复杂度;
- 系统扩展时每增加一个下游能力都要改支付服务。
而事件驱动的思路是:
- 支付服务只负责发布“支付已成功”这一事实;
- 库存服务、订单服务、营销服务等谁关心谁订阅;
- 系统扩展性更强。
10.2 事件流转图
事件流转图绘制如下:

这张图就是本文最核心的主流程。
10.3 MQ 配置类
package com.example.demo.payment.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RabbitMQ 配置
*/
@Configuration
public class PaymentEventRabbitConfig {
public static final String PAYMENT_SUCCESS_EXCHANGE = "payment.success.exchange";
public static final String PAYMENT_SUCCESS_QUEUE = "inventory.payment.success.queue";
public static final String PAYMENT_SUCCESS_ROUTING_KEY = "payment.success";
@Bean
public DirectExchange paymentSuccessExchange() {
return new DirectExchange(PAYMENT_SUCCESS_EXCHANGE, true, false);
}
@Bean
public Queue paymentSuccessQueue() {
return new Queue(PAYMENT_SUCCESS_QUEUE, true);
}
@Bean
public Binding paymentSuccessBinding() {
return BindingBuilder.bind(paymentSuccessQueue())
.to(paymentSuccessExchange())
.with(PAYMENT_SUCCESS_ROUTING_KEY);
}
}
10.4 支付服务:模拟支付成功并发布事件
package com.example.demo.payment.service;
import com.example.demo.payment.config.PaymentEventRabbitConfig;
import com.example.demo.payment.event.PaymentSuccessEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 支付服务
*/
@Service
@RequiredArgsConstructor
public class PaymentService {
private final RabbitTemplate rabbitTemplate;
/**
* 模拟支付成功,并发送支付成功事件
*/
public String paySuccess(String orderNo, BigDecimal paidAmount) {
String paymentNo = "PAY" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
PaymentSuccessEvent event = new PaymentSuccessEvent(
paymentNo,
orderNo,
paidAmount,
LocalDateTime.now()
);
rabbitTemplate.convertAndSend(
PaymentEventRabbitConfig.PAYMENT_SUCCESS_EXCHANGE,
PaymentEventRabbitConfig.PAYMENT_SUCCESS_ROUTING_KEY,
event
);
return paymentNo;
}
}
10.5 支付接口
package com.example.demo.payment.controller;
import com.example.demo.payment.service.PaymentService;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Map;
/**
* 支付控制器
*/
@RestController
@RequestMapping("/api/payments")
@RequiredArgsConstructor
@Validated
public class PaymentController {
private final PaymentService paymentService;
/**
* 模拟支付成功
*/
@PostMapping("/success")
public Map<String, Object> mockPaySuccess(
@RequestParam @NotBlank(message = "订单号不能为空") String orderNo,
@RequestParam @DecimalMin(value = "0.01", message = "支付金额必须大于 0") BigDecimal amount) {
String paymentNo = paymentService.paySuccess(orderNo, amount);
return Map.of(
"success", true,
"paymentNo", paymentNo,
"message", "支付成功事件已发送"
);
}
}
十一、库存消费支付成功事件:锁库存转实扣的核心代码
11.1 先补一个幂等表
支付成功回调和消息消费最大的现实问题之一,就是重复通知与重复消费。
所以我们先设计一张消费幂等表:
CREATE TABLE t_event_consume_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
business_key VARCHAR(128) NOT NULL UNIQUE,
event_type VARCHAR(64) NOT NULL,
consume_status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
例如:
business_key = payment_success:ORD20260001event_type = PAYMENT_SUCCESS
只要消费过一次,后续重复消息就直接忽略。
11.2 幂等实体与仓储
package com.example.demo.common.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 事件消费记录实体
*/
@Getter
@Setter
@Entity
@Table(name = "t_event_consume_record")
public class EventConsumeRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 业务唯一键,例如 payment_success:订单号
*/
@Column(name = "business_key", nullable = false, unique = true, length = 128)
private String businessKey;
/**
* 事件类型
*/
@Column(name = "event_type", nullable = false, length = 64)
private String eventType;
/**
* 消费状态:SUCCESS
*/
@Column(name = "consume_status", nullable = false, length = 32)
private String consumeStatus;
@Column(name = "create_time", nullable = false)
private LocalDateTime createTime;
@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;
}
package com.example.demo.common.repository;
import com.example.demo.common.entity.EventConsumeRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* 事件消费记录仓储接口
*/
public interface EventConsumeRecordRepository extends JpaRepository<EventConsumeRecord, Long> {
/**
* 根据业务唯一键查询
*/
Optional<EventConsumeRecord> findByBusinessKey(String businessKey);
}
11.3 库存服务新增:确认库存扣减
package com.example.demo.inventory.service;
import com.example.demo.inventory.entity.Inventory;
import com.example.demo.inventory.entity.InventoryReservation;
import com.example.demo.inventory.repository.InventoryRepository;
import com.example.demo.inventory.repository.InventoryReservationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 库存领域服务
*/
@Service
@RequiredArgsConstructor
public class InventoryService {
private final InventoryRepository inventoryRepository;
private final InventoryReservationRepository reservationRepository;
@Transactional
public void lockStock(String orderNo, String skuCode, int quantity) {
Inventory inventory = inventoryRepository.findBySkuCode(skuCode)
.orElseThrow(() -> new IllegalArgumentException("库存不存在,skuCode=" + skuCode));
inventory.lock(quantity);
inventoryRepository.save(inventory);
InventoryReservation reservation = new InventoryReservation();
reservation.setOrderNo(orderNo);
reservation.setSkuCode(skuCode);
reservation.setQuantity(quantity);
reservation.setStatus("LOCKED");
reservation.setCreateTime(LocalDateTime.now());
reservation.setUpdateTime(LocalDateTime.now());
reservationRepository.save(reservation);
}
public List<InventoryReservation> findReservationsByOrderNo(String orderNo) {
return reservationRepository.findByOrderNo(orderNo);
}
/**
* 根据订单号将锁定库存转为实扣
*/
@Transactional
public void confirmStockByOrderNo(String orderNo) {
List<InventoryReservation> reservations = reservationRepository.findByOrderNo(orderNo);
if (reservations.isEmpty()) {
throw new IllegalStateException("未找到库存锁定记录,orderNo=" + orderNo);
}
for (InventoryReservation reservation : reservations) {
// 只有 LOCKED 状态才允许转实扣
if (!"LOCKED".equals(reservation.getStatus())) {
continue;
}
Inventory inventory = inventoryRepository.findBySkuCode(reservation.getSkuCode())
.orElseThrow(() -> new IllegalArgumentException("库存不存在,skuCode=" + reservation.getSkuCode()));
inventory.confirmDeduct(reservation.getQuantity());
reservation.confirm();
inventoryRepository.save(inventory);
reservationRepository.save(reservation);
}
}
/**
* 根据订单号释放锁库存
*/
@Transactional
public void releaseStockByOrderNo(String orderNo) {
List<InventoryReservation> reservations = reservationRepository.findByOrderNo(orderNo);
for (InventoryReservation reservation : reservations) {
if (!"LOCKED".equals(reservation.getStatus())) {
continue;
}
Inventory inventory = inventoryRepository.findBySkuCode(reservation.getSkuCode())
.orElseThrow(() -> new IllegalArgumentException("库存不存在,skuCode=" + reservation.getSkuCode()));
inventory.release(reservation.getQuantity());
reservation.release();
inventoryRepository.save(inventory);
reservationRepository.save(reservation);
}
}
}
11.4 消费支付成功事件
package com.example.demo.inventory.listener;
import com.example.demo.common.entity.EventConsumeRecord;
import com.example.demo.common.repository.EventConsumeRecordRepository;
import com.example.demo.inventory.service.InventoryService;
import com.example.demo.payment.config.PaymentEventRabbitConfig;
import com.example.demo.payment.event.PaymentSuccessEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 支付成功事件监听器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentSuccessInventoryListener {
private final InventoryService inventoryService;
private final EventConsumeRecordRepository eventConsumeRecordRepository;
/**
* 监听支付成功事件,完成锁库存转实扣
*/
@RabbitListener(queues = PaymentEventRabbitConfig.PAYMENT_SUCCESS_QUEUE)
@Transactional
public void onPaymentSuccess(PaymentSuccessEvent event) {
String businessKey = "payment_success:" + event.orderNo();
// 幂等校验:已经消费成功则直接返回
if (eventConsumeRecordRepository.findByBusinessKey(businessKey).isPresent()) {
log.info("支付成功事件重复消费,直接忽略,businessKey={}", businessKey);
return;
}
// 执行锁库存转实扣
inventoryService.confirmStockByOrderNo(event.orderNo());
// 记录幂等消费结果
EventConsumeRecord record = new EventConsumeRecord();
record.setBusinessKey(businessKey);
record.setEventType("PAYMENT_SUCCESS");
record.setConsumeStatus("SUCCESS");
record.setCreateTime(LocalDateTime.now());
record.setUpdateTime(LocalDateTime.now());
eventConsumeRecordRepository.save(record);
log.info("支付成功事件处理完成,已将锁库存转为实扣,orderNo={}", event.orderNo());
}
}
代码解析:这段代码为什么很关键
这段监听器代码其实体现了事件驱动系统最重要的几个概念:
-
异步消费
支付服务只负责发消息,不直接耦合库存确认逻辑。 -
幂等控制
即使支付成功消息重复投递,也不会重复转实扣。 -
事务包裹
转实扣和幂等记录在同一事务内完成,保证“要么都成功,要么都失败”。 -
业务解耦
支付成功后库存怎么处理,是库存域自己的职责,而不是支付域的职责。
十二、订单服务也要消费支付成功事件
支付成功不仅仅影响库存,也会影响订单状态。
12.1 订单监听器
package com.example.demo.order.listener;
import com.example.demo.order.entity.Order;
import com.example.demo.order.repository.OrderRepository;
import com.example.demo.payment.config.PaymentEventRabbitConfig;
import com.example.demo.payment.event.PaymentSuccessEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 订单支付成功事件监听器
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentSuccessOrderListener {
private final OrderRepository orderRepository;
@RabbitListener(queues = PaymentEventRabbitConfig.PAYMENT_SUCCESS_QUEUE)
@Transactional
public void onPaymentSuccess(PaymentSuccessEvent event) {
Order order = orderRepository.findByOrderNo(event.orderNo())
.orElseThrow(() -> new IllegalArgumentException("订单不存在,orderNo=" + event.orderNo()));
if ("PAID".equals(order.getStatus())) {
log.info("订单已经是已支付状态,忽略重复处理,orderNo={}", event.orderNo());
return;
}
order.markPaid();
orderRepository.save(order);
log.info("订单状态更新为已支付,orderNo={}", event.orderNo());
}
}
说明:真实项目中,订单服务和库存服务通常会消费不同的队列,而不是共同监听同一个队列;这里为了帮助初学者理解主题,示意写法做了适度简化。生产环境中应为不同业务消费者配置独立队列并绑定到同一交换机。
这是非常重要的架构知识点:
- 交换机相同:表示它们都关心“支付成功”这件事;
- 队列不同:表示各自独立处理自己的业务,互不影响。
十三、异常场景一定要讲透:否则文章就不算完整
很多技术文章只写主流程,完全不谈异常,这种文章很难真正帮助读者解决实际问题。下面我们系统梳理线上高频问题。
13.1 场景一:支付成功消息重复投递
原因可能包括:
- MQ 至少一次投递语义;
- 消费者处理后 ack 丢失;
- 支付平台回调重试;
- 手工补发事件。
解决方式:
- 幂等表;
- 唯一业务键;
- 业务状态二次校验。
13.2 场景二:支付成功,但库存消费失败
例如:
- 数据库临时抖动;
- 乐观锁冲突;
- 代码 bug;
- 下游依赖异常。
处理方式:
- 消息重试;
- 死信队列;
- 补偿任务扫描;
- 人工干预台账。
13.3 场景三:订单超时未支付,要释放锁库存
这也是“锁库存”机制必须配套的能力,否则锁了就不放,库存迟早废掉。

13.4 场景四:订单已取消,但又收到延迟支付成功通知
这是一种非常典型但容易忽略的问题。
例如:
- 订单 30 分钟超时取消;
- 库存已释放;
- 第三方支付通知晚到了;
- 系统又想把订单改成已支付、库存再转实扣。
这时候必须有清晰规则,比如:
- 如果订单已取消,不允许再转已支付,直接进入人工核对;
- 或者允许“已取消但后支付”,进入退款/逆向流程;
- 必须由业务规则决定,不能让代码“想当然”。
换句话说,技术实现必须服从业务规则,而不是反过来。
十四、进一步升级:如何实现更可靠的最终一致性
上面的示例已经能运行、能表达核心思想,但如果你想把它推向更接近生产级的做法,还需要理解两个关键词:
- 本地事务
- 事件一致性
14.1 为什么“数据库提交成功 + 消息发送成功”不是天生原子
支付服务通常会做两件事:
- 把支付状态更新为成功;
- 发送支付成功事件。
问题在于:
- 如果数据库成功、消息发送失败,会导致下游不知道支付成功;
- 如果消息先发出、数据库回滚,会导致下游收到一个“假的支付成功”。
这就是经典的本地事务与消息发送一致性问题。
14.2 Outbox 模式简介
一种经典做法是:
-
在本地事务里同时写入:
- 支付表状态更新;
- outbox 事件表一条待发送消息;
-
由后台投递器扫描 outbox 表,可靠发送到 MQ;
-
发送成功后更新 outbox 状态。
这样能显著提高一致性可靠度。
14.3 Outbox 表结构示例
CREATE TABLE t_outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_type VARCHAR(64) NOT NULL,
business_key VARCHAR(128) NOT NULL,
event_body TEXT NOT NULL,
send_status VARCHAR(32) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
14.4 Outbox 核心思路图

这部分对零基础读者来说,可以先建立认知,不一定一上来就必须自己实现得非常复杂,但你至少要知道:
只靠“代码里先 update 再 send”并不能天然保证一致性。
十五、库存扣减到底用乐观锁还是悲观锁
这是做库存系统时经常被问到的问题。
15.1 乐观锁适合什么场景
本文示例在实体上加入了 @Version 字段,这就是乐观锁做法。
乐观锁适合:
- 冲突不是极端高;
- 更重视吞吐;
- 可接受少量重试;
- 业务层可以做失败重试。
优点:
- 并发性能通常更好;
- 不容易长时间持有数据库锁;
- 适合常规互联网业务。
15.2 悲观锁适合什么场景
如果是极端热点商品、秒杀、票务抢购等高冲突场景,往往会配合:
select ... for update- Redis 预扣减
- 分段库存
- 队列削峰
- Lua 原子脚本
也就是说,库存方案从来不是“一招鲜吃遍天”,而是要看业务模型、并发规模、可接受复杂度。
15.3 对初学者的建议
如果你是从零开始:
- 先掌握本文这种“数据库库存 + 锁定记录 + 支付成功事件 + 幂等”的通用解法;
- 再逐步研究高并发优化;
- 不要还没学会走路就先研究火箭推进器。
十六、把案例补完整:订单取消释放库存
为了形成业务闭环,我们再补一个取消订单释放库存的示例。
16.1 订单取消服务
package com.example.demo.order.service;
import com.example.demo.inventory.service.InventoryService;
import com.example.demo.order.entity.Order;
import com.example.demo.order.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 订单取消服务
*/
@Service
@RequiredArgsConstructor
public class OrderCancelService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
/**
* 取消订单并释放锁库存
*/
@Transactional
public void cancelOrder(String orderNo) {
Order order = orderRepository.findByOrderNo(orderNo)
.orElseThrow(() -> new IllegalArgumentException("订单不存在,orderNo=" + orderNo));
if ("PAID".equals(order.getStatus())) {
throw new IllegalStateException("订单已支付,不允许取消");
}
if ("CANCELLED".equals(order.getStatus())) {
return;
}
order.cancel();
orderRepository.save(order);
inventoryService.releaseStockByOrderNo(orderNo);
}
}
16.2 取消订单接口
package com.example.demo.order.controller;
import com.example.demo.order.service.OrderCancelService;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 订单取消控制器
*/
@RestController
@RequestMapping("/api/order-cancel")
@RequiredArgsConstructor
@Validated
public class OrderCancelController {
private final OrderCancelService orderCancelService;
/**
* 取消订单
*/
@PostMapping
public Map<String, Object> cancel(@RequestParam @NotBlank(message = "订单号不能为空") String orderNo) {
orderCancelService.cancelOrder(orderNo);
return Map.of(
"success", true,
"message", "订单取消成功,锁库存已释放"
);
}
}
代码解析
这段代码虽然不复杂,但意义非常大:
- 它说明“锁库存”一定不是孤立操作,而是必须有释放机制;
- 它体现了订单和库存两个领域之间的协作边界;
- 它让整个案例形成闭环,而不是只写一半。
十七、配置文件示例:application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/demo_inventory?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
logging:
level:
org.hibernate.SQL: debug
com.example.demo: info
代码解析
学习阶段可以使用:
ddl-auto: update快速建表;show-sql: true观察 SQL;- 本地 MySQL + RabbitMQ 即可跑通。
但生产环境不要简单照搬:
- 表结构应使用 Flyway/Liquibase 维护;
- SQL 日志不能长期开太细;
- 账号密码要走密钥管理;
- RabbitMQ 参数要配连接池、确认机制、重试策略。
十八、测试场景演示:你应该怎样验证整套流程
技术文章不是给读者“看懂就结束”,而是要让读者知道如何验证。
18.1 准备初始库存数据
INSERT INTO t_inventory(sku_code, total_stock, locked_stock, sold_stock, version, create_time, update_time)
VALUES ('SKU-1001', 100, 0, 0, 0, NOW(), NOW());
18.2 调用创建订单接口
请求:
POST /api/orders
Content-Type: application/json
{
"items": [
{
"skuCode": "SKU-1001",
"quantity": 3
}
]
}
预期结果:
- 订单创建成功;
t_inventory.locked_stock = 3;t_inventory.sold_stock = 0;t_inventory_reservation新增一条LOCKED记录。
18.3 调用支付成功接口
POST /api/payments/success?orderNo=ORDxxxxxxxx&amount=99.00
预期结果:
- 发送支付成功事件;
- 库存服务消费事件;
locked_stock = 0;sold_stock = 3;- 锁定记录状态变为
CONFIRMED; - 幂等表新增消费记录。
18.4 重复调用支付成功接口
预期结果:
- 系统识别为重复消费;
- 不会重复转实扣;
- 库存数据保持正确。
18.5 创建订单后取消
预期结果:
- 订单状态变更为
CANCELLED; locked_stock回退;- 锁定记录状态变更为
RELEASED。
十九、自动化测试示例:不要只靠手工点接口
为了让文章更完整,我们再补一段集成测试示例。
package com.example.demo;
import com.example.demo.inventory.entity.Inventory;
import com.example.demo.inventory.repository.InventoryRepository;
import com.example.demo.inventory.service.InventoryService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 库存服务测试
*/
@SpringBootTest
class InventoryServiceTest {
@Autowired
private InventoryRepository inventoryRepository;
@Autowired
private InventoryService inventoryService;
@BeforeEach
void init() {
inventoryRepository.findBySkuCode("SKU-TEST-1").ifPresent(inventoryRepository::delete);
Inventory inventory = new Inventory();
inventory.setSkuCode("SKU-TEST-1");
inventory.setTotalStock(10);
inventory.setLockedStock(0);
inventory.setSoldStock(0);
inventory.setCreateTime(LocalDateTime.now());
inventory.setUpdateTime(LocalDateTime.now());
inventoryRepository.save(inventory);
}
@Test
@Transactional
void should_lock_and_confirm_stock_successfully() {
String orderNo = "ORD-TEST-1001";
// 先锁定 2 件库存
inventoryService.lockStock(orderNo, "SKU-TEST-1", 2);
Inventory lockedInventory = inventoryRepository.findBySkuCode("SKU-TEST-1").orElseThrow();
Assertions.assertEquals(2, lockedInventory.getLockedStock());
Assertions.assertEquals(0, lockedInventory.getSoldStock());
// 再执行转实扣
inventoryService.confirmStockByOrderNo(orderNo);
Inventory confirmedInventory = inventoryRepository.findBySkuCode("SKU-TEST-1").orElseThrow();
Assertions.assertEquals(0, confirmedInventory.getLockedStock());
Assertions.assertEquals(2, confirmedInventory.getSoldStock());
}
}
测试解析
这一段测试虽然简单,但已经验证了本文最核心的链路:
- 锁库存成功;
- 支付成功后转实扣成功;
- 库存字段变化符合预期。
任何看起来“很简单”的业务,只要你把测试写出来,就会发现认知清晰度会立刻上一个台阶。
二十、生产实践中的几个关键建议
这一节非常重要,因为它决定你是“会写 demo”,还是“开始具备工程意识”。
20.1 一定要有清晰的状态机
不论是订单、支付还是库存锁定记录,都不要随便用字符串状态乱改。
建议至少明确:
- 订单:
CREATED→WAIT_PAY→PAID/CANCELLED - 锁定记录:
LOCKED→CONFIRMED/RELEASED - 支付:
INIT→SUCCESS/FAIL
如果状态很多,建议使用枚举和状态机约束。
20.2 一定要做幂等
只要涉及:
- MQ 消费;
- 第三方回调;
- 补偿重试;
- 手工重放;
就一定要做幂等。
因为真实世界里,“只会调用一次”几乎是伪命题。
20.3 日志要能串起链路
建议在日志里至少打印:
- 订单号
orderNo - 支付单号
paymentNo - SKU 编码
skuCode - 业务键
businessKey
这样线上排障时才不会像在黑夜里摸索。
20.4 对账任务必不可少
再好的系统也会遇到异常,所以你需要定期对账:
- 支付成功但订单未改已支付;
- 订单已支付但库存未转实扣;
- 锁定记录长期停留在
LOCKED; - 已取消订单却还存在锁库存。
对账不是“系统不行”的表现,而是成熟系统的必备机制。
20.5 不要迷信分布式事务
很多初学者一看到一致性问题,就会问:“能不能上分布式事务?”
答案是:可以研究,但不要拿它当第一选择。
在绝大多数互联网业务中:
- 本地事务 + 事件驱动 + 幂等 + 补偿
往往是更主流、更务实、更易扩展的方案。
二十一、从单体到微服务:这套方案如何演进
本文为了让读者容易跑通,使用了单体结构。但这不代表思路只适用于单体。
21.1 单体阶段
- 一个应用里有订单、支付、库存模块;
- 通过领域分包隔离职责;
- 用事件模拟服务间协作。
21.2 微服务阶段
可以逐步拆成:
order-servicepayment-serviceinventory-service
此时:
- 支付服务发布支付成功事件;
- 订单服务订阅后改订单状态;
- 库存服务订阅后转实扣;
- 营销服务订阅后发积分;
- 通知服务订阅后发短信/站内信。
这说明你今天学会的,并不是一个“临时 demo 技巧”,而是一种可以向更大规模系统演进的架构模式。
二十二、常见误区总结:初学者最容易踩的坑
误区 1:下单就直接扣真实库存
问题:用户不付钱,库存却没法卖给别人。
建议:先锁后扣。
误区 2:只有库存总表,没有锁定明细表
问题:无法追踪订单占用来源。
建议:总表 + 锁定记录双表建模。
误区 3:支付成功后同步调用所有下游
问题:强耦合、扩展困难。
建议:发布支付成功事件,下游各自订阅处理。
误区 4:认为 MQ 一定只会投一次
问题:重复消息会让你重复扣库存。
建议:幂等设计是标配。
误区 5:只写主流程,不写释放和补偿
问题:库存一旦异常就会长期错误。
建议:取消、超时、补偿、对账都要设计。
误区 6:把所有逻辑堆进一个 Service
问题:代码很快变成“面条工程”。
建议:实体承载规则,服务组织流程,监听器处理事件。
二十三、本文案例的完整运行主线回顾
最后,再把全文流程浓缩成一条完整主线,帮助你形成整体记忆。示意图绘制如下,仅供参考:

如果你现在能把这张图完整讲给别人听,说明你已经真正理解了“基于支付成功事件实现锁库存转实扣”的核心思想。
二十四、结语:你真正学到的,不只是库存扣减
表面上看,本文讲的是“支付成功后把锁库存转成实扣”。
但如果你认真读到这里,其实你已经接触到了 Spring Boot 3.x 开发里非常关键的一整套能力:
- 用现代 Java 与 Spring Boot 3.x 构建业务服务;
- 正确认识订单、支付、库存三个领域的边界;
- 理解锁库存与实扣的差异;
- 用事件驱动解耦业务协作;
- 用幂等、补偿、释放机制处理复杂现实场景;
- 明白从 demo 到生产系统之间真正差的是什么。
所以,真正重要的并不是“你会不会写一个扣库存接口”,而是:
你是否已经开始用业务建模、一致性设计和工程化思维,去写 Spring Boot 3.x 的代码。
这才是《零基础学 Spring Boot 3.x》这类专栏真正想带给读者的价值。
二十五、可继续拓展的学习方向
如果你准备继续深入,我建议沿着我的专栏《滚雪球学SpringBoot 3.x》中的下面几个方向扩展深入,扩大自己的知识面:
- 把字符串状态替换为枚举与状态机;
- 引入 Outbox 模式,提升消息一致性;
- 增加订单明细表、支付流水表;
- 增加死信队列与重试策略;
- 接入 Redis 做热点库存缓存与预扣减;
- 接入 Micrometer/Actuator 做库存事件监控;
- 进一步拆分为订单、支付、库存三个微服务。
如果你把这些都继续练下去,那么你已经不只是“会用 Spring Boot”,而是在逐渐接近真正的中高级后端开发者。
附录 A:建议的数据表 DDL 汇总
CREATE TABLE t_inventory (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku_code VARCHAR(64) NOT NULL UNIQUE,
total_stock INT NOT NULL,
locked_stock INT NOT NULL DEFAULT 0,
sold_stock INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
CREATE TABLE t_inventory_reservation (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
sku_code VARCHAR(64) NOT NULL,
quantity INT NOT NULL,
status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_order_sku(order_no, sku_code)
);
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(32) NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
CREATE TABLE t_event_consume_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
business_key VARCHAR(128) NOT NULL UNIQUE,
event_type VARCHAR(64) NOT NULL,
consume_status VARCHAR(32) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
CREATE TABLE t_outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_type VARCHAR(64) NOT NULL,
business_key VARCHAR(128) NOT NULL,
event_body TEXT NOT NULL,
send_status VARCHAR(32) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
附录 B:文章小结
本文围绕“基于支付成功事件实现锁库存转实扣”这一主题,从 Spring Boot 3.x 基础、领域建模、库存设计、支付事件、幂等、补偿、取消释放、工程实践、微服务演进等多个角度,构建了一套既适合初学者理解、又能逐步贴近生产实践的完整案例。
...
ok,同学们,本节课就上到这儿,下课~
🧧 学习福利 · 限时开放 🧧
无论你是计算机专业在读学生,还是对编程充满兴趣的入门者,都强烈建议系统学习专栏:👉 「滚雪球学 Spring Boot」;
本专栏以“循序渐进 + 实战驱动”为核心理念,从基础到进阶逐层展开,帮助你快速建立完整的 Spring Boot 技术体系。
📌 学习承诺:
通过该专栏,你将能够:
- 快速掌握 Spring Boot 核心开发能力
- 构建完整的后端项目认知体系
- 实现从“入门”到“独立开发”的跃迁
就像“滚雪球”一样,知识不断积累、能力持续放大,实现指数级成长 🚀
最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。
同时欢迎大家关注技术号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G PDF编程电子书、简历模板、技术文章Markdown文档等海量资料。
ps:本文涉及所有源代码,均已上传至Gitee开源,供同学们直接对照学习 Gitee传送门,同时,原创开源不易,欢迎给个star🌟,想体验下被🌟的感jio,非常感谢❗
🫵 Who am I?
我是 bug菌:
- 热活跃于 CSDN | 掘金 | InfoQ | 51CTO | 华为云 | 阿里云 | 腾讯云 等技术社区;
- CSDN 博客之星 Top30、华为云多年度十佳博主&卓越贡献奖、掘金多年度人气作者 Top40;
- 掘金、InfoQ、51CTO 等平台签约及优质作者;
- 全网粉丝累计 30w+。
更多高质量技术内容及成长资料,可查看这个合集入口 👉 点击查看 👈️
硬核技术号 「猿圈奇妙屋」 期待你的加入,一起进阶、一起打怪升级。

- End -
浙公网安备 33010602011771号