文章目录

Spring Cloud 学习与实践(3):商品服务开发与并发超卖

本章目标:开发商品服务 cloud-product,实现商品查询和库存扣减接口;通过 JMeter 并发请求复现超卖问题,理解为什么普通事务无法自动解决并发竞争,并使用数据库原子条件更新修复库存扣减逻辑。


1. 本章目标

上一章已经完成了用户服务 cloud-user

Spring Boot Web
MySQL
MyBatis-Plus
Controller / Service / Mapper 分层
统一返回 Result<T>
用户 CRUD
分页故障演练
IDEA HTTP Client 接口测试

本章继续开发第二个独立业务服务:

cloud-product

主要完成以下内容:

1. 为 cloud-product 添加 Web、MyBatis-Plus、MySQL 和 Lombok 依赖
2. 创建商品表 t_product
3. 创建商品服务启动类和 application.yml
4. 创建 Product 实体、Mapper、Service 和 Controller
5. 实现商品列表和商品详情查询
6. 实现朴素版库存扣减
7. 验证库存不足和非法参数校验
8. 使用 JMeter 发起并发请求
9. 复现库存超卖问题
10. 理解为什么 @Transactional 不能自动解决并发超卖
11. 使用数据库原子条件更新修复超卖
12. 再次使用 JMeter 验证修复效果

本章暂时不接入:

Nacos
OpenFeign
Gateway
Sentinel
Redis
Redisson
RabbitMQ

这些组件将在后续章节中逐步加入。


2. 为什么商品服务要重点学习库存扣减

用户服务适合验证基础 CRUD 和 MyBatis-Plus 分页。

商品服务则更适合学习:

并发竞争
库存校验
超卖
数据库原子更新
事务边界
分布式锁
最终一致性

普通字段修改通常不会成为高并发热点,例如:

商品名称:
机械键盘
    ↓
无线机械键盘

但库存扣减可能在促销或秒杀期间同时收到大量请求:

库存只有 1 件
        ↓
20 个用户同时购买
        ↓
系统应该只允许 1 个请求成功

如果库存扣减逻辑设计不正确,就会出现:

真实库存:1 件
成功购买:多个请求

这就是超卖。

后续 Redis、Redisson、RabbitMQ 和分布式事务章节,也会继续使用库存场景。


3. cloud-product 模块结构

本章完成后的目录结构如下:

cloud-product
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com.example.cloud.product
    │   │       ├── CloudProductApplication.java
    │   │       ├── controller
    │   │       │   └── ProductController.java
    │   │       ├── entity
    │   │       │   └── Product.java
    │   │       ├── mapper
    │   │       │   └── ProductMapper.java
    │   │       └── service
    │   │           ├── ProductService.java
    │   │           └── impl
    │   │               └── ProductServiceImpl.java
    │   └── resources
    │       └── application.yml
    └── test
        └── http
            └── product.http

本章没有重复实现完整商品 CRUD。

原因是上一章已经完成过基础 CRUD 演练,本章应将重点放在新的知识点:

库存扣减
并发超卖
数据库原子条件更新

4. 修改 cloud-product 的 pom.xml

cloud-product/pom.xml 中添加依赖:

<?xml version="1.0" encoding="UTF-8"?>
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>com.example.cloud</groupId>
        <artifactId>cloud-demo</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>cloud-product</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <!--
            公共模块:
            复用统一返回 Result、统一错误码 ErrorCode、
            业务异常 BizException 和全局异常处理器。
        -->
        <dependency>
            <groupId>com.example.cloud</groupId>
            <artifactId>cloud-common</artifactId>
            <version>${project.version}</version>
        </dependency>

        <!--
            Spring Boot Web:
            提供 Spring MVC、内嵌 Tomcat、@RestController 等 Web 能力。
        -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!--
            MyBatis-Plus:
            通过 BaseMapper、IService 和 ServiceImpl 简化数据库操作。
        -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>

        <!-- MySQL 驱动 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Lombok:减少 getter、setter 等样板代码 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

</project>

当前阶段暂时不要加入:

Redis
Redisson
Nacos
OpenFeign
Sentinel
RabbitMQ

学习过程中应保持主线清晰:

先在数据库层理解库存并发问题
        ↓
再在后续章节接入微服务治理组件

5. 创建商品表

在已有数据库 cloud_demo 中执行:

DROP TABLE IF EXISTS t_product;

CREATE TABLE t_product (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '商品ID',
    name VARCHAR(128) NOT NULL COMMENT '商品名称',
    price DECIMAL(10, 2) NOT NULL COMMENT '商品价格',
    stock INT NOT NULL DEFAULT 0 COMMENT '库存数量',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1上架,0下架',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

INSERT INTO t_product (name, price, stock, status)
VALUES
('机械键盘', 299.00, 10, 1),
('无线鼠标', 129.00, 20, 1),
('显示器', 1599.00, 5, 1);

查询验证:

SELECT * FROM t_product;

预期结果:

id name price stock status
1 机械键盘 299.00 10 1
2 无线鼠标 129.00 20 1
3 显示器 1599.00 5 1

字段说明

字段 作用
id 商品主键
name 商品名称
price 商品价格
stock 当前库存
status 商品上下架状态
create_time 创建时间

为什么金额使用 DECIMAL?

金额不能使用:

FLOAT
DOUBLE

原因是浮点数存在精度误差。

例如:

0.1 + 0.2

在某些场景下并不一定能精确表示为:

0.3

因此数据库金额字段通常使用:

DECIMAL(10, 2)

Java 实体中使用:

BigDecimal

6. 配置 application.yml

创建:

cloud-product
└── src/main/resources
    └── application.yml

配置如下:

server:
  port: 9300

spring:
  application:
    name: cloud-product

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/cloud_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
    username: root
    password: your_password

mybatis-plus:
  configuration:
    # 学习阶段打印 SQL,便于观察查询和更新过程。
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

  global-config:
    db-config:
      # 主键使用数据库自增。
      id-type: auto

端口规划:

User:9200
Product:9300
Order:9400

7. 创建商品服务启动类

创建:

cloud-product
└── src/main/java
    └── com.example.cloud.product
        └── CloudProductApplication.java

代码如下:

package com.example.cloud.product;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 商品服务启动类。
 *
 * scanBasePackages 扫描 com.example.cloud 根包,
 * 不仅能扫描 cloud-product 中的 Controller、Service 和 Mapper,
 * 也能扫描 cloud-common 中的公共 Spring Bean。
 */
@SpringBootApplication(scanBasePackages = "com.example.cloud")
public class CloudProductApplication {

    public static void main(String[] args) {
        SpringApplication.run(CloudProductApplication.class, args);
    }
}

8. 创建 Product 实体类

创建:

cloud-product
└── src/main/java
    └── com.example.cloud.product.entity
        └── Product.java

代码如下:

package com.example.cloud.product.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 商品实体类。
 *
 * 对应数据库表:t_product
 */
@Data
@TableName("t_product")
public class Product {

    /**
     * 商品主键。
     *
     * AUTO 表示使用数据库自增主键。
     */
    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 商品名称。
     */
    private String name;

    /**
     * 商品价格。
     *
     * 金额字段不要使用 float 或 double,
     * 避免浮点数精度问题。
     */
    private BigDecimal price;

    /**
     * 当前库存数量。
     *
     * 这是本章最重要的字段。
     * 后续将围绕它演示库存不足、并发竞争和超卖问题。
     */
    private Integer stock;

    /**
     * 商品状态:
     * 1:上架
     * 0:下架
     */
    private Integer status;

    /**
     * 创建时间。
     *
     * MyBatis-Plus 默认支持下划线转驼峰:
     * create_time → createTime
     */
    private LocalDateTime createTime;
}

9. 创建 ProductMapper

9.1 第一阶段:基础 Mapper

最初创建:

cloud-product
└── src/main/java
    └── com.example.cloud.product.mapper
        └── ProductMapper.java

代码如下:

package com.example.cloud.product.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.cloud.product.entity.Product;
import org.apache.ibatis.annotations.Mapper;

/**
 * 商品 Mapper。
 *
 * 继承 BaseMapper<Product> 后,
 * 自动具备 selectById、selectList 和 updateById 等基础方法。
 */
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
}

这一阶段先使用 MyBatis-Plus 默认方法实现朴素版库存扣减。


10. 创建 ProductService

创建:

cloud-product
└── src/main/java
    └── com.example.cloud.product.service
        └── ProductService.java

代码如下:

package com.example.cloud.product.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.example.cloud.product.entity.Product;

/**
 * 商品业务接口。
 */
public interface ProductService extends IService<Product> {

    /**
     * 扣减商品库存。
     *
     * @param productId 商品 ID
     * @param quantity  扣减数量
     */
    void deductStock(Long productId, Integer quantity);
}

11. 创建 ProductController

创建:

cloud-product
└── src/main/java
    └── com.example.cloud.product.controller
        └── ProductController.java

代码如下:

package com.example.cloud.product.controller;

import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.common.result.Result;
import com.example.cloud.product.entity.Product;
import com.example.cloud.product.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 商品接口。
 */
@RestController
@RequestMapping("/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    /**
     * 查询商品列表。
     *
     * 本章不重复演练分页插件。
     * 分页机制已在 cloud-user 中完整学习。
     */
    @GetMapping
    public Result<List<Product>> list() {
        return Result.success(productService.list());
    }

    /**
     * 根据 ID 查询商品详情。
     */
    @GetMapping("/{id}")
    public Result<Product> getById(@PathVariable Long id) {
        Product product = productService.getById(id);

        if (product == null) {
            return Result.fail(
                    ErrorCode.NOT_FOUND,
                    "商品不存在"
            );
        }

        return Result.success(product);
    }

    /**
     * 扣减商品库存。
     *
     * 示例:
     * POST /products/1/deduct-stock?quantity=2
     */
    @PostMapping("/{id}/deduct-stock")
    public Result<Void> deductStock(
            @PathVariable Long id,
            @RequestParam Integer quantity) {

        productService.deductStock(id, quantity);

        return Result.success();
    }
}

接口列表:

请求方式 路径 作用
GET /products 查询商品列表
GET /products/{id} 根据 ID 查询商品详情
POST /products/{id}/deduct-stock 扣减库存

本章没有重复实现商品新增、修改和删除。

原因是:

商品服务重点应放在库存并发问题

12. 创建 product.http

创建:

cloud-product
└── src/test/http
    └── product.http

内容如下:

### 查询商品列表
GET http://localhost:9300/products

### 查询机械键盘
GET http://localhost:9300/products/1

### 查询不存在的商品
GET http://localhost:9300/products/999

### 正常扣减机械键盘库存:扣减 2 件
POST http://localhost:9300/products/1/deduct-stock?quantity=2

### 再次查询机械键盘,观察库存变化
GET http://localhost:9300/products/1

### 错误参数:扣减数量不能为 0
POST http://localhost:9300/products/1/deduct-stock?quantity=0

### 错误参数:扣减数量不能为负数
POST http://localhost:9300/products/1/deduct-stock?quantity=-1

### 库存不足:尝试扣减 100 件
POST http://localhost:9300/products/1/deduct-stock?quantity=100

13. 第一阶段:实现朴素版库存扣减

创建:

cloud-product
└── src/main/java
    └── com.example.cloud.product.service.impl
        └── ProductServiceImpl.java

朴素版代码如下:

package com.example.cloud.product.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.product.entity.Product;
import com.example.cloud.product.mapper.ProductMapper;
import com.example.cloud.product.service.ProductService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * 商品业务实现类。
 */
@Service
public class ProductServiceImpl
        extends ServiceImpl<ProductMapper, Product>
        implements ProductService {

    /**
     * 朴素版库存扣减。
     *
     * 当前版本采用:
     *
     * 1. 先查询库存
     * 2. 再判断库存
     * 3. 最后更新库存
     *
     * 单线程下逻辑正确。
     *
     * 但是在并发环境下,多个线程可能同时读取到相同的旧库存,
     * 然后都通过库存校验,最终产生超卖。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deductStock(Long productId, Integer quantity) {
        // 1. 参数校验:扣减数量必须为正数。
        if (quantity == null || quantity <= 0) {
            throw new BizException(
                    ErrorCode.PARAM_ERROR,
                    "扣减数量必须大于 0"
            );
        }

        // 2. 查询当前商品。
        Product product = getById(productId);

        if (product == null) {
            throw new BizException(
                    ErrorCode.NOT_FOUND,
                    "商品不存在"
            );
        }

        // 3. 下架商品不允许扣减库存。
        if (product.getStatus() == null || product.getStatus() != 1) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "商品未上架,不能扣减库存"
            );
        }

        // 4. 在 Java 内存中判断库存是否充足。
        if (product.getStock() < quantity) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "商品库存不足"
            );
        }

        // 5. 在 Java 内存中计算扣减后的库存。
        product.setStock(product.getStock() - quantity);

        // 6. 更新数据库。
        boolean success = updateById(product);

        if (!success) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "库存扣减失败"
            );
        }
    }
}

14. 单线程验证朴素版库存扣减

启动:

CloudProductApplication

访问:

GET http://localhost:9300/products/1

初始库存:

{
  "code": 0,
  "message": "success",
  "data": {
    "id": 1,
    "name": "机械键盘",
    "price": 299.00,
    "stock": 10,
    "status": 1
  }
}

执行:

POST http://localhost:9300/products/1/deduct-stock?quantity=2

再次查询:

GET http://localhost:9300/products/1

库存变为:

{
  "code": 0,
  "message": "success",
  "data": {
    "id": 1,
    "name": "机械键盘",
    "price": 299.00,
    "stock": 8,
    "status": 1
  }
}

继续验证参数校验:

POST http://localhost:9300/products/1/deduct-stock?quantity=0

返回:

扣减数量必须大于 0

库存不足测试:

POST http://localhost:9300/products/1/deduct-stock?quantity=100

返回:

商品库存不足

我们将库存故意设置为1↓

在这里插入图片描述

15. 为什么朴素版在并发环境下可能超卖

假设商品库存只剩:

stock = 1

两个线程同时购买:

线程 A:购买 1 件
线程 B:购买 1 件

执行过程可能如下:

时间点 T1:
线程 A 查询数据库,得到 stock = 1

时间点 T2:
线程 B 查询数据库,也得到 stock = 1

时间点 T3:
线程 A 判断 1 >= 1,库存充足

时间点 T4:
线程 B 判断 1 >= 1,库存也充足

时间点 T5:
线程 A 将库存更新为 0

时间点 T6:
线程 B 也将库存更新为 0

最终数据库库存:

stock = 0

但是业务上已经成功卖出:

2 件

真实库存只有:

1 件

这就是超卖。


16. 超卖不一定表现为负库存

很多人会将超卖简单理解为:

stock < 0

实际上并不准确。

朴素版代码执行的是:

product.setStock(product.getStock() - quantity);

多个线程可能都读取到:

stock = 1

然后分别计算:

1 - 1 = 0

最终多个线程都执行:

UPDATE t_product
SET stock = 0
WHERE id = 1;

所以最终库存仍然是:

0

但多个请求已经返回成功。

判断是否超卖,不能只看数据库库存是否为负数,还要比较:

业务成功请求数量
是否大于
原始库存数量

17. 为什么 @Transactional 不能自动解决超卖

朴素版方法已经添加:

@Transactional(rollbackFor = Exception.class)

但事务不等于并发安全。

事务主要保证什么?

事务保证:

同一个请求中的多个数据库操作
要么全部成功
要么全部失败

例如:

扣减库存
        ↓
写入库存日志

如果写入日志失败,可以回滚库存修改。

事务没有自动保证什么?

普通事务不会自动阻止多个线程同时读取相同库存。

当前逻辑仍然是:

SELECT 查询库存
        ↓
Java 内存判断
        ↓
UPDATE 修改库存

多个事务可以同时读取:

stock = 1

然后分别更新。

核心结论:

事务保证一致提交
不等于并发安全

18. 使用 JMeter 复现并发超卖

为了稳定复现并发问题,可以在查询库存和更新库存之间临时加入:

Thread.sleep(1000);

这个延迟仅用于扩大并发竞争窗口。

正式修复后必须删除。

临时日志版方法

package com.example.cloud.product.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.product.entity.Product;
import com.example.cloud.product.mapper.ProductMapper;
import com.example.cloud.product.service.ProductService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
public class ProductServiceImpl
        extends ServiceImpl<ProductMapper, Product>
        implements ProductService {

    /**
     * 仅用于超卖故障演练。
     *
     * 当前版本故意保留并发安全问题。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deductStock(Long productId, Integer quantity) {
        if (quantity == null || quantity <= 0) {
            throw new BizException(
                    ErrorCode.PARAM_ERROR,
                    "扣减数量必须大于 0"
            );
        }

        Product product = getById(productId);

        if (product == null) {
            throw new BizException(
                    ErrorCode.NOT_FOUND,
                    "商品不存在"
            );
        }

        if (product.getStatus() == null || product.getStatus() != 1) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "商品未上架,不能扣减库存"
            );
        }

        if (product.getStock() < quantity) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "商品库存不足"
            );
        }

        log.info(
                "线程={},读取到商品库存={},准备扣减数量={}",
                Thread.currentThread().getName(),
                product.getStock(),
                quantity
        );

        /*
         * 仅用于故障演练:
         * 故意暂停 1 秒,扩大查询和更新之间的并发窗口。
         */
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("模拟并发延迟时线程被中断", e);
        }

        product.setStock(product.getStock() - quantity);

        log.info(
                "线程={},准备将商品库存更新为={}",
                Thread.currentThread().getName(),
                product.getStock()
        );

        boolean success = updateById(product);

        if (!success) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "库存扣减失败"
            );
        }
    }
}

19. JMeter 并发测试配置

先将库存重置为:

UPDATE t_product
SET stock = 1
WHERE id = 1;

查询确认:

SELECT id, name, stock
FROM t_product
WHERE id = 1;

JMeter 线程组配置:

参数
线程数 20
Ramp-Up 时间 1 秒
循环次数 1

在这里插入图片描述

HTTP 请求配置:

参数
协议 http
服务器名称或 IP localhost
端口 9300
方法 POST
路径 /products/1/deduct-stock
quantity 1

对应请求:

POST http://localhost:9300/products/1/deduct-stock?quantity=1

在这里插入图片描述

推荐添加监听器:

查看结果树
汇总报告

20. 超卖故障复现结果

测试条件:

初始库存:1
并发请求:20
每个请求扣减:1

减库存日志如图:

在这里插入图片描述
在这里插入图片描述

实际日志中出现:

多个线程读取到 stock = 1
多个线程准备将库存更新为 0
多个 UPDATE 均执行成功
部分较晚到达的线程查询到 stock = 0
部分请求触发“商品库存不足”

本次演练中:

总请求数:20
成功扣减:10
库存不足:10
最终库存:0

正确结果应该是:

成功扣减:1
库存不足:19
最终库存:0

因此,系统多卖了:

10 - 1 = 9 件

为什么只有部分请求超卖成功?

20 个线程并不会在完全相同的时间进入方法。

受以下因素影响:

JMeter Ramp-Up
Tomcat 工作线程调度
数据库连接池
操作系统线程调度
本地数据库执行速度

一部分线程在首次库存更新提交前读取到:

stock = 1

所以全部通过校验。

另一部分线程进入稍晚,已经读取到:

stock = 0

所以被库存不足校验拦截。

21. 修复思路:数据库原子条件更新

朴素版的问题在于:

查询库存
        ↓
Java 判断
        ↓
修改库存

库存判断和扣减是多个步骤。

修复方案是:

将库存判断和库存扣减放在同一条 UPDATE SQL 中完成

核心 SQL:

UPDATE t_product
SET stock = stock - #{quantity}
WHERE id = #{productId}
  AND status = 1
  AND stock >= #{quantity};

这条 SQL 同时完成:

校验商品 ID
校验上架状态
校验库存是否足够
扣减库存

当多个线程同时执行时,数据库会对同一行更新进行并发控制。

假设:

初始库存:1

第一个线程:

stock >= 1 成立
        ↓
更新 stock = stock - 1
        ↓
stock = 0
        ↓
影响行数 = 1

第二个线程:

等待前一个更新完成
        ↓
重新判断 stock >= 1
        ↓
条件不成立
        ↓
影响行数 = 0

22. 修改 ProductMapper

ProductMapper.java 修改为:

package com.example.cloud.product.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.cloud.product.entity.Product;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

/**
 * 商品 Mapper。
 */
@Mapper
public interface ProductMapper extends BaseMapper<Product> {

    /**
     * 原子扣减库存。
     *
     * 核心思想:
     * 将“判断库存是否足够”和“扣减库存”放在同一条 SQL 中完成。
     *
     * 为什么能够避免超卖?
     *
     * UPDATE 操作会在数据库层面对目标记录进行并发控制。
     * 当多个线程同时执行该 SQL 时,
     * 数据库会依次处理针对同一行记录的更新。
     *
     * 假设库存 stock = 1:
     *
     * 第一个线程:
     * stock >= 1 成立
     * 更新 stock = stock - 1
     * 最终 stock = 0
     * 影响行数为 1
     *
     * 第二个线程:
     * 等待第一个线程更新完成后再次判断
     * stock >= 1 不成立
     * 不会更新记录
     * 影响行数为 0
     *
     * @param productId 商品 ID
     * @param quantity  扣减数量
     * @return 受影响行数:1 表示扣减成功,0 表示条件不满足
     */
    @Update("""
            UPDATE t_product
            SET stock = stock - #{quantity}
            WHERE id = #{productId}
              AND status = 1
              AND stock >= #{quantity}
            """)
    int deductStock(
            @Param("productId") Long productId,
            @Param("quantity") Integer quantity
    );
}

23. 修改 ProductServiceImpl

删除临时的:

Thread.sleep(1000);

使用数据库原子条件更新:

package com.example.cloud.product.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.product.entity.Product;
import com.example.cloud.product.mapper.ProductMapper;
import com.example.cloud.product.service.ProductService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * 商品业务实现类。
 */
@Service
public class ProductServiceImpl
        extends ServiceImpl<ProductMapper, Product>
        implements ProductService {

    /**
     * 数据库原子条件更新版本。
     *
     * 和朴素版的区别:
     *
     * 朴素版:
     * SELECT 查询库存
     *      ↓
     * Java 判断库存
     *      ↓
     * UPDATE 修改库存
     *
     * 当前版本:
     * 一条 UPDATE SQL 同时完成库存判断和扣减。
     *
     * 正确性由数据库保证,
     * 不再依赖 Java 内存中的旧库存。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deductStock(Long productId, Integer quantity) {
        // 1. 参数校验仍然在 Java 层完成。
        if (quantity == null || quantity <= 0) {
            throw new BizException(
                    ErrorCode.PARAM_ERROR,
                    "扣减数量必须大于 0"
            );
        }

        /*
         * 2. 执行数据库原子扣减。
         *
         * SQL 中已经包含:
         * id 匹配
         * status = 1
         * stock >= quantity
         *
         * 因此,不需要先查询库存再决定是否更新。
         */
        int affectedRows = baseMapper.deductStock(productId, quantity);

        // 影响行数为 1,表示成功扣减库存。
        if (affectedRows == 1) {
            return;
        }

        /*
         * 3. 如果影响行数为 0,说明某个条件没有满足。
         *
         * 此处再次查询商品,不是为了判断是否允许扣减,
         * 而是为了给调用方返回更准确的业务错误信息。
         *
         * 真正决定库存能否扣减的,是上面的原子 UPDATE SQL。
         */
        Product product = getById(productId);

        if (product == null) {
            throw new BizException(
                    ErrorCode.NOT_FOUND,
                    "商品不存在"
            );
        }

        if (product.getStatus() == null || product.getStatus() != 1) {
            throw new BizException(
                    ErrorCode.BIZ_ERROR,
                    "商品未上架,不能扣减库存"
            );
        }

        throw new BizException(
                ErrorCode.BIZ_ERROR,
                "商品库存不足"
        );
    }
}

24. 再次使用 JMeter 验证修复结果

先重置库存:

在这里插入图片描述

UPDATE t_product
SET stock = 1
WHERE id = 1;

继续使用相同 JMeter 配置:

参数
初始库存 1
线程数 20
每次扣减数量 1
Ramp-Up 时间 1 秒
循环次数 1

再次执行后,结果符合预期:

总请求数:20
业务成功:1
库存不足:19
最终库存:0

这说明数据库条件更新已经解决超卖问题。

注意:JMeter 的请求成功不等于业务成功

全局异常处理器可能会将业务异常转换为统一 JSON:

{
  "code": 业务错误码,
  "message": "商品库存不足",
  "data": null
}

HTTP 状态码仍然可能是:

200

因此,JMeter 汇总报告中的:

Error % = 0%

不一定表示全部业务请求成功。

判断库存扣减是否真正成功,应观察业务返回:

{
  "code": 0,
  "message": "success"
}

只有:

code = 0

才代表业务扣减成功。

后续做正式压测时,可以在 JMeter 中增加 JSON 断言。

25. Wrapper 能否实现相同效果?

可以。

本章主线使用 Mapper 自定义 SQL:

UPDATE t_product
SET stock = stock - #{quantity}
WHERE id = #{productId}
  AND status = 1
  AND stock >= #{quantity};

原因是 SQL 更直观,适合学习和解释原理。

也可以使用 MyBatis-Plus 的 LambdaUpdateWrapper

LambdaUpdateWrapper<Product> wrapper = new LambdaUpdateWrapper<>();

wrapper
        .eq(Product::getId, productId)
        .eq(Product::getStatus, 1)
        .ge(Product::getStock, quantity)
        .setSql("stock = stock - {0}", quantity);

boolean success = update(wrapper);

最终生成的核心 SQL 仍然类似:

UPDATE t_product
SET stock = stock - ?
WHERE id = ?
  AND status = 1
  AND stock >= ?;

两种写法都可以解决超卖问题。

关键不在于使用注解 SQL 还是 Wrapper,而在于:

库存判断和扣减必须在同一条 UPDATE SQL 中完成

本章正文保留 Mapper 自定义 SQL,便于直接理解原理。


26. 为什么不使用 synchronized?

还可以想到一种方案:

synchronized

例如:

public synchronized void deductStock(...) {
    ...
}

它在单个 JVM 进程中可以限制同一时间只有一个线程进入方法。

但后续微服务通常会部署多个实例:

cloud-product 实例 A
cloud-product 实例 B
cloud-product 实例 C

每个实例都有自己的 JVM。

实例 A 中的 synchronized 无法锁住实例 B。

因此:

synchronized 只能解决单机进程内并发
不能解决多实例分布式并发

当前章节使用数据库原子条件更新,是因为数据库是多个实例共同访问的共享资源。

后续 Redis 章节会继续学习:

Redisson 分布式锁

27. 为什么暂时不使用 Redisson 分布式锁?

分布式锁并不是所有库存扣减问题的第一选择。

本章库存扣减可以通过一条 SQL 解决:

UPDATE t_product
SET stock = stock - #{quantity}
WHERE id = #{productId}
  AND stock >= #{quantity};

优点:

逻辑简单
数据库原子更新
不需要额外中间件
故障点更少

Redisson 分布式锁更适合:

需要保护多条业务逻辑
需要跨多个步骤控制并发
需要防止重复提交
需要协调多个服务实例

因此学习顺序是:

第 3 章:
数据库原子条件更新

第 11 章:
Redis 缓存与 Redisson 分布式锁

28. 本章常见问题

28.1 为什么商品库存不应该先查询再修改?

因为查询和更新之间存在并发窗口:

SELECT
        ↓
Java 判断
        ↓
UPDATE

多个线程可以同时读取相同旧库存。


28.2 为什么库存最终没有小于 0,仍然属于超卖?

因为多个线程可能都读取:

stock = 1

然后都更新为:

stock = 0

最终库存不是负数,但成功请求数量大于真实库存数量。


28.3 为什么数据库条件更新可以解决问题?

因为它把:

库存判断
库存扣减

放在同一条 SQL 中。

数据库会对同一行记录的更新进行并发控制。


28.4 为什么更新失败后还要查询一次商品?

原子更新返回:

affectedRows = 0

只能说明某个更新条件没有满足。

可能原因包括:

商品不存在
商品已经下架
库存不足

因此再查询一次商品,用于返回更准确的错误信息。

需要注意:

这次查询只负责生成错误提示
不负责决定是否允许扣减

28.5 为什么 @Transactional 还要保留?

当前示例只有一条核心更新 SQL,即使不加事务也能完成原子扣减。

但在真实业务中,库存扣减方法可能还会包含:

写入库存流水
记录操作日志
修改库存状态

保留事务有利于保证一组本地数据库操作整体提交或整体回滚。


29. 本章故障排查总结

本章完成了一次典型并发故障演练:

现象:
商品初始库存为 1。
JMeter 使用 20 个线程并发购买。
朴素版库存扣减出现多个成功请求。
数据库最终库存仍然为 0。

排查:
多个线程同时读取到旧库存 stock=1。
多个线程都通过 Java 内存中的库存判断。
多个线程都将库存更新为 0。

原因:
SELECT 查询和 UPDATE 更新是两个独立步骤。
查询与更新之间存在并发竞争窗口。
@Transactional 不能自动解决此类并发竞争。

修复:
将库存判断和库存扣减写入同一条 UPDATE SQL。
通过 stock >= quantity 作为更新条件。
根据受影响行数判断扣减是否成功。

结果:
初始库存为 1。
20 个线程并发购买。
仅 1 个请求业务成功。
其余 19 个请求返回库存不足。
最终库存为 0。

排查思路可以复用到其他并发场景:

先复现问题
        ↓
观察成功请求数量
        ↓
查看 SQL 日志
        ↓
分析并发窗口
        ↓
将多个步骤压缩为原子操作
        ↓
使用相同压测配置再次验证

30. 本章面试总结

面试时可以这样表达:

商品库存扣减不能简单采用先查询再更新的方式。

如果先 SELECT 查询库存,在 Java 内存中判断库存是否充足,
再执行 UPDATE 修改库存,
多个并发事务可能同时读取到相同的旧库存,
然后都通过校验并更新成功,造成超卖。

即使方法添加了 @Transactional,也不能自动解决该问题。
事务主要保证同一个请求中的多个数据库操作整体提交或回滚,
但不等于并发安全。

项目中通过数据库原子条件更新解决:

UPDATE t_product
SET stock = stock - #{quantity}
WHERE id = #{productId}
  AND status = 1
  AND stock >= #{quantity};

库存判断和扣减在同一条 SQL 中完成。
数据库根据受影响行数判断是否扣减成功。
如果影响行数为 0,再查询商品状态,
区分商品不存在、商品下架和库存不足。


31. 本章结论

本章完成了第二个独立业务服务:

cloud-product

当前项目已经具备:

商品表 t_product
商品服务独立启动
商品列表查询
商品详情查询
库存扣减接口
参数校验
库存不足校验
JMeter 并发压测
超卖故障复现
数据库原子条件更新
修复后并发验证

本章最重要的结论:

1. 超卖不一定表现为负库存
2. 成功请求数大于原始库存数,也属于超卖
3. @Transactional 不等于并发安全
4. SELECT + Java 判断 + UPDATE 存在并发窗口
5. 库存判断和扣减应尽量在同一条 SQL 中完成
6. 数据库原子条件更新可以解决当前场景的超卖问题
7. synchronized 无法解决多实例分布式并发
8. 分布式锁应在适合的业务场景中使用,不应滥用

下一章将进入:

第 4 章:订单服务 cloud-order 开发

下一章会完成:

订单表设计
订单服务独立启动
订单创建
订单查询
订单本地业务逻辑
暂时不接入 OpenFeign
故意不校验用户和商品是否存在
引出后续服务间远程调用的必要性
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐