Spring Cloud 学习与实践(3):商品服务开发与并发超卖
文章目录
- Spring Cloud 学习与实践(3):商品服务开发与并发超卖
-
- 1. 本章目标
- 2. 为什么商品服务要重点学习库存扣减
- 3. cloud-product 模块结构
- 4. 修改 cloud-product 的 pom.xml
- 5. 创建商品表
- 6. 配置 application.yml
- 7. 创建商品服务启动类
- 8. 创建 Product 实体类
- 9. 创建 ProductMapper
- 10. 创建 ProductService
- 11. 创建 ProductController
- 12. 创建 product.http
- 13. 第一阶段:实现朴素版库存扣减
- 14. 单线程验证朴素版库存扣减
- 15. 为什么朴素版在并发环境下可能超卖
- 16. 超卖不一定表现为负库存
- 17. 为什么 @Transactional 不能自动解决超卖
- 18. 使用 JMeter 复现并发超卖
- 19. JMeter 并发测试配置
- 20. 超卖故障复现结果
- 21. 修复思路:数据库原子条件更新
- 22. 修改 ProductMapper
- 23. 修改 ProductServiceImpl
- 24. 再次使用 JMeter 验证修复结果
- 25. Wrapper 能否实现相同效果?
- 26. 为什么不使用 synchronized?
- 27. 为什么暂时不使用 Redisson 分布式锁?
- 28. 本章常见问题
- 29. 本章故障排查总结
- 30. 本章面试总结
- 31. 本章结论
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
故意不校验用户和商品是否存在
引出后续服务间远程调用的必要性
更多推荐

所有评论(0)