夯爆了,一文讲透基于支付成功事件实现锁库存转实扣(超详解)!

🏆本文收录于《滚雪球学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 资源售卖等系统中,库存扣减从来都不是一个“减一下数字”这么简单的问题。

初学者通常会把库存逻辑想象成下面这样:

  1. 用户下单;
  2. 数据库 stock - 1
  3. 用户支付;
  4. 订单完成。

这套思路在课堂练习里成立,但在真实业务中很快就会遇到一连串问题:

  • 用户创建订单后迟迟不支付,库存却被一直占住;
  • 同一商品被大量并发下单,超卖风险急剧上升;
  • 支付成功回调出现重复通知,库存会不会被重复扣减;
  • 支付成功了,但库存服务处理消息失败,订单和库存状态不一致怎么办;
  • 订单取消、支付超时、补偿回滚、库存释放之间如何协同;
  • 如何让业务代码既清晰又可维护,而不是全部堆在一个 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 这一代通常意味着以下技术基线:

  1. JDK 17 起步
    这意味着项目默认就能使用 Java 17 的语法和标准库能力,例如 record、增强后的 switch、更成熟的 Stream API、文本块等。

  2. Spring Framework 6
    这是 Spring 生态新一代核心框架,对 AOT、原生镜像、可观测性等方向都更友好。

  3. Jakarta EE 9+ 命名空间迁移
    大量原来的 javax.* 包迁移到了 jakarta.*。例如:

    • javax.servlet.*jakarta.servlet.*
    • javax.validation.*jakarta.validation.*
    • javax.persistence.*jakarta.persistence.*
  4. 更现代化的可观测能力
    Spring Boot 3.x 对 Micrometer、Tracing、Metrics、Observation 的整合更成熟,适合构建生产级服务。

  5. 面向云原生与生产级应用的工程体验更强
    包括启动、配置、容器化、健康检查、指标暴露等,整体上比早期版本更贴近现代微服务实践。

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 为什么要基于支付成功事件来做

因为支付系统通常是独立域,支付结果往往不是同步强耦合地由订单服务“立即知道”的,而是通过以下方式传达:

  • 支付平台回调;
  • 支付服务落库后发送消息;
  • 订单服务订阅支付成功事件;
  • 库存服务消费该事件并执行“锁库存转实扣”。

这样做的好处有三点:

  1. 职责清晰:支付归支付域,库存归库存域;
  2. 可扩展:支付成功后不只是扣库存,还可能触发积分、优惠券核销、发货、短信通知等;
  3. 更贴近真实分布式系统:一个事件可被多个下游能力复用。

四、业务全景图:订单、支付、库存的协作关系

先用一张图建立整体认知。

image

这张图说明了几个关键点:

  • 下单时做的是锁库存
  • 支付成功后靠事件驱动库存确认扣减;
  • 订单状态与库存状态分别由各自领域负责;
  • 库存服务和订单服务都可以订阅同一个支付成功事件。

如果你刚接触微服务或事件驱动,先不要急着被“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 锁库存与转实扣的状态变化

用状态变化表示会更清楚:

image

库存不是只有一个“剩余数值”,而是会随着业务流转发生状态迁移。

六、领域建模:用 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>

代码解析

这份依赖里最值得初学者注意的有三点:

  1. Spring Boot 3.x 下我们直接用 Java 17;
  2. 校验相关依赖已经是 jakarta.validation 体系;
  3. 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", "订单创建成功,库存已锁定,等待支付"
        );
    }
}

这一阶段的核心理解

当接口调用成功时,系统只完成了两件事:

  1. 创建订单;
  2. 锁定库存。

此时并没有真实卖出去。

也就是说,“锁库存成功”≠“交易成功”。这一点是初学者必须牢牢记住的。

十、第二阶段:支付成功事件如何驱动转实扣

真正的重点来了。

10.1 为什么不用同步调用库存确认接口,而要用支付成功事件

有些人会问:支付成功后,支付服务直接远程调用库存服务一个接口不就行了吗?

理论上可以,但不够优雅,主要问题包括:

  • 支付服务和库存服务强耦合;
  • 支付成功后往往不止一个后续动作;
  • 链路过长,同步失败会放大事务复杂度;
  • 系统扩展时每增加一个下游能力都要改支付服务。

而事件驱动的思路是:

  • 支付服务只负责发布“支付已成功”这一事实;
  • 库存服务、订单服务、营销服务等谁关心谁订阅;
  • 系统扩展性更强。

10.2 事件流转图

事件流转图绘制如下:

image

这张图就是本文最核心的主流程。

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:ORD20260001
  • event_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());
    }
}

代码解析:这段代码为什么很关键

这段监听器代码其实体现了事件驱动系统最重要的几个概念:

  1. 异步消费
    支付服务只负责发消息,不直接耦合库存确认逻辑。

  2. 幂等控制
    即使支付成功消息重复投递,也不会重复转实扣。

  3. 事务包裹
    转实扣和幂等记录在同一事务内完成,保证“要么都成功,要么都失败”。

  4. 业务解耦
    支付成功后库存怎么处理,是库存域自己的职责,而不是支付域的职责。

十二、订单服务也要消费支付成功事件

支付成功不仅仅影响库存,也会影响订单状态。

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 场景三:订单超时未支付,要释放锁库存

这也是“锁库存”机制必须配套的能力,否则锁了就不放,库存迟早废掉。

image

13.4 场景四:订单已取消,但又收到延迟支付成功通知

这是一种非常典型但容易忽略的问题。

例如:

  • 订单 30 分钟超时取消;
  • 库存已释放;
  • 第三方支付通知晚到了;
  • 系统又想把订单改成已支付、库存再转实扣。

这时候必须有清晰规则,比如:

  • 如果订单已取消,不允许再转已支付,直接进入人工核对;
  • 或者允许“已取消但后支付”,进入退款/逆向流程;
  • 必须由业务规则决定,不能让代码“想当然”。

换句话说,技术实现必须服从业务规则,而不是反过来。

十四、进一步升级:如何实现更可靠的最终一致性

上面的示例已经能运行、能表达核心思想,但如果你想把它推向更接近生产级的做法,还需要理解两个关键词:

  • 本地事务
  • 事件一致性

14.1 为什么“数据库提交成功 + 消息发送成功”不是天生原子

支付服务通常会做两件事:

  1. 把支付状态更新为成功;
  2. 发送支付成功事件。

问题在于:

  • 如果数据库成功、消息发送失败,会导致下游不知道支付成功;
  • 如果消息先发出、数据库回滚,会导致下游收到一个“假的支付成功”。

这就是经典的本地事务与消息发送一致性问题

14.2 Outbox 模式简介

一种经典做法是:

  1. 在本地事务里同时写入:

    • 支付表状态更新;
    • outbox 事件表一条待发送消息;
  2. 由后台投递器扫描 outbox 表,可靠发送到 MQ;

  3. 发送成功后更新 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 核心思路图

image

这部分对零基础读者来说,可以先建立认知,不一定一上来就必须自己实现得非常复杂,但你至少要知道:

只靠“代码里先 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 一定要有清晰的状态机

不论是订单、支付还是库存锁定记录,都不要随便用字符串状态乱改。

建议至少明确:

  • 订单:CREATEDWAIT_PAYPAID / CANCELLED
  • 锁定记录:LOCKEDCONFIRMED / RELEASED
  • 支付:INITSUCCESS / FAIL

如果状态很多,建议使用枚举和状态机约束。

20.2 一定要做幂等

只要涉及:

  • MQ 消费;
  • 第三方回调;
  • 补偿重试;
  • 手工重放;

就一定要做幂等。

因为真实世界里,“只会调用一次”几乎是伪命题。

20.3 日志要能串起链路

建议在日志里至少打印:

  • 订单号 orderNo
  • 支付单号 paymentNo
  • SKU 编码 skuCode
  • 业务键 businessKey

这样线上排障时才不会像在黑夜里摸索。

20.4 对账任务必不可少

再好的系统也会遇到异常,所以你需要定期对账:

  • 支付成功但订单未改已支付;
  • 订单已支付但库存未转实扣;
  • 锁定记录长期停留在 LOCKED
  • 已取消订单却还存在锁库存。

对账不是“系统不行”的表现,而是成熟系统的必备机制。

20.5 不要迷信分布式事务

很多初学者一看到一致性问题,就会问:“能不能上分布式事务?”

答案是:可以研究,但不要拿它当第一选择。

在绝大多数互联网业务中:

  • 本地事务 + 事件驱动 + 幂等 + 补偿

往往是更主流、更务实、更易扩展的方案。

二十一、从单体到微服务:这套方案如何演进

本文为了让读者容易跑通,使用了单体结构。但这不代表思路只适用于单体。

21.1 单体阶段

  • 一个应用里有订单、支付、库存模块;
  • 通过领域分包隔离职责;
  • 用事件模拟服务间协作。

21.2 微服务阶段

可以逐步拆成:

  • order-service
  • payment-service
  • inventory-service

此时:

  • 支付服务发布支付成功事件;
  • 订单服务订阅后改订单状态;
  • 库存服务订阅后转实扣;
  • 营销服务订阅后发积分;
  • 通知服务订阅后发短信/站内信。

这说明你今天学会的,并不是一个“临时 demo 技巧”,而是一种可以向更大规模系统演进的架构模式。

二十二、常见误区总结:初学者最容易踩的坑

误区 1:下单就直接扣真实库存

问题:用户不付钱,库存却没法卖给别人。
建议:先锁后扣。

误区 2:只有库存总表,没有锁定明细表

问题:无法追踪订单占用来源。
建议:总表 + 锁定记录双表建模。

误区 3:支付成功后同步调用所有下游

问题:强耦合、扩展困难。
建议:发布支付成功事件,下游各自订阅处理。

误区 4:认为 MQ 一定只会投一次

问题:重复消息会让你重复扣库存。
建议:幂等设计是标配。

误区 5:只写主流程,不写释放和补偿

问题:库存一旦异常就会长期错误。
建议:取消、超时、补偿、对账都要设计。

误区 6:把所有逻辑堆进一个 Service

问题:代码很快变成“面条工程”。
建议:实体承载规则,服务组织流程,监听器处理事件。

二十三、本文案例的完整运行主线回顾

最后,再把全文流程浓缩成一条完整主线,帮助你形成整体记忆。示意图绘制如下,仅供参考:

image

如果你现在能把这张图完整讲给别人听,说明你已经真正理解了“基于支付成功事件实现锁库存转实扣”的核心思想。

二十四、结语:你真正学到的,不只是库存扣减

表面上看,本文讲的是“支付成功后把锁库存转成实扣”。

但如果你认真读到这里,其实你已经接触到了 Spring Boot 3.x 开发里非常关键的一整套能力:

  • 用现代 Java 与 Spring Boot 3.x 构建业务服务;
  • 正确认识订单、支付、库存三个领域的边界;
  • 理解锁库存与实扣的差异;
  • 用事件驱动解耦业务协作;
  • 用幂等、补偿、释放机制处理复杂现实场景;
  • 明白从 demo 到生产系统之间真正差的是什么。

所以,真正重要的并不是“你会不会写一个扣库存接口”,而是:

你是否已经开始用业务建模、一致性设计和工程化思维,去写 Spring Boot 3.x 的代码。

这才是《零基础学 Spring Boot 3.x》这类专栏真正想带给读者的价值。

二十五、可继续拓展的学习方向

如果你准备继续深入,我建议沿着我的专栏《滚雪球学SpringBoot 3.x》中的下面几个方向扩展深入,扩大自己的知识面:

  1. 把字符串状态替换为枚举与状态机;
  2. 引入 Outbox 模式,提升消息一致性;
  3. 增加订单明细表、支付流水表;
  4. 增加死信队列与重试策略;
  5. 接入 Redis 做热点库存缓存与预扣减;
  6. 接入 Micrometer/Actuator 做库存事件监控;
  7. 进一步拆分为订单、支付、库存三个微服务。

如果你把这些都继续练下去,那么你已经不只是“会用 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+

更多高质量技术内容及成长资料,可查看这个合集入口 👉 点击查看 👈️
硬核技术号 「猿圈奇妙屋」 期待你的加入,一起进阶、一起打怪升级。

image

- End -

posted on 2026-04-22 12:11  bug菌  阅读(10)  评论(0)    收藏  举报

导航