目录
四、MyBatis-Plus提供的@Version乐观锁注解
在学校里,我们曾经开发过TTMS也就是剧院票务系统。我们曾经提出了一些办法去避免超售,但是并没有使用并发工具去实测。所以本文就以Locust测试工具,来测试我们的接口。
一、安装Locust
Locust依赖于python环境,用python语言编写脚本。因此我们需要确保python版本保持在一个较新的状态,我的Python版本为3.14.3为保证安装过程流畅,先升级pip
bash
python -m pip install --upgrade pip setuptools wheel
在命令行执行后,现在开始安装Locust
bash
pip install locust
输入如下指令看到版本号即安装成功
bash
locust --version

编写一个测试脚本:
在vscode中创建一个名为"locustfile.py"的文件,然后编写如下脚本
python
from locust import HttpUser, task, between
class MyUser(HttpUser):
# 模拟用户等待时间:1-3秒
wait_time = between(1, 3)
@task
def home_page(self):
# 访问根路径
self.client.get("/")
@task
def about_page(self):
# 访问 about 路径,权重为1(默认)
self.client.get("/about")
在其所在目录命令行输入:locust即可运行。
运行后,在浏览器输入http://localhost:8089,即可访问locust的webUI.

从上到下依次是:最大用户数,每秒新增多少个用户(一直到最大用户后就保持不变),测试的目标网站地址,如果点开Advanced options里面还有一共需要测试的时间,如果不填则表示一直测试下去,直到手动停止。
这里我们,最大用户数填2,每1秒新增一名用户,host填www.baidu.com测试百度。
点击start后开始测试

测试一段时间后,点击stop

访问了两个接口/和/about,因为百度没有about所以/about一直是失败状态。这两个路径就是我们脚本代码里提前写好的。
二、建库建表
为了方便演示,我们就构建一个库存表,里面存放我们的商品库存。模拟商品秒杀的场景:
sql
CREATE TABLE `product_stock` (
`id` bigint NOT NULL AUTO_INCREMENT,
`product_code` varchar(32) NOT NULL COMMENT '商品编码',
`stock` int NOT NULL DEFAULT '0' COMMENT '剩余库存',
`version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
`gmt_modified` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_code` (`product_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 初始化一条数据:商品A,库存100
INSERT INTO `product_stock` (`product_code`, `stock`, `version`) VALUES ('SKU_001', 100, 0);

这里我们的初步思路是,业务层面用悲观锁,数据库层面用乐观锁。所以我们给表添加了一个字段version。
现在我们完成实体类和请求和响应的封装:
java
/**
* 秒杀请求
*/
@Data
public class SeckillRequest {
private String productCode;
private Integer quantity;
// 可选:用户ID(从token中获取,这里为了演示简单传参)
private Long userId;
}
java
/**
* 秒杀响应结果
*/
@Data
public class SeckillResponse {
private String productCode;
private Integer quantity;
private Integer remainStock; // 剩余库存
private Boolean success;
private String message;
}
再做一个统一结果封装:
java
/**
* 统一返回结果
*/
@Data
public class Result<T> {
private Integer code;
private String msg;
private T data;
private Long timestamp;
public Result(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
this.timestamp = System.currentTimeMillis();
}
public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data);
}
public static <T> Result<T> success(String msg, T data) {
return new Result<>(200, msg, data);
}
public static <T> Result<T> error(String msg) {
return new Result<>(500, msg, null);
}
public static <T> Result<T> error(Integer code, String msg) {
return new Result<>(code, msg, null);
}
}
三、秒杀接口与业务代码
现在我们需要编写一个接口用来模拟系统收到用户的购买请求。
java
/**
* 秒杀控制器
*/
@RestController
@RequestMapping("/api/seckill")
@Slf4j
public class SeckillController {
@Autowired
private ISeckillService seckillService;
/**
* 悲观锁
* POST /api/seckill/pessimistic
* Body: {"productCode":"SKU_001", "quantity":1, "userId":1001}
*/
@PostMapping("/pessimistic")
public Result<SeckillResponse> pessimisticLockSeckill(@RequestBody SeckillRequest request) {
log.info("收到混合锁秒杀请求: {}", request);
// 如果userId为空,生成一个随机ID(演示用)
if (request.getUserId() == null) {
request.setUserId((long) (Math.random() * 100000));
}
SeckillResponse response = seckillService.decreaseStockWithPessimisticLock(request);
return response.getSuccess() ? Result.success(response) : Result.error(response.getMessage());
}
/**
* 纯乐观锁秒杀接口(对比测试)
* POST /api/seckill/pure
*/
@PostMapping("/pure")
public Result<SeckillResponse> pureOptimisticSeckill(@RequestBody SeckillRequest request) {
log.info("收到纯乐观锁秒杀请求: {}", request);
if (request.getUserId() == null) {
request.setUserId((long) (Math.random() * 100000));
}
SeckillResponse response = seckillService.decreaseStockWithPureOptimistic(request);
return response.getSuccess() ? Result.success(response) : Result.error(response.getMessage());
}
/**
* 查询商品库存
* GET /api/seckill/stock/{productCode}
*/
@GetMapping("/stock/{productCode}")
public Result<Map<String, Object>> getStock(@PathVariable String productCode) {
ProductStock stock = seckillService.getStock(productCode);
if (stock == null) {
return Result.error("商品不存在");
}
Map<String, Object> data = new HashMap<>();
data.put("productCode", stock.getProductCode());
data.put("stock", stock.getStock());
data.put("version", stock.getVersion());
data.put("gmtModified", stock.getGmtModified());
return Result.success(data);
}
/**
* 重置库存(测试用)
* POST /api/seckill/reset?productCode=SKU_001&stock=100
*/
@PostMapping("/reset")
public Result<String> resetStock(@RequestParam String productCode,
@RequestParam(defaultValue = "100") Integer stock) {
try {
// 直接使用 MyBatis-Plus 的 update 方法
ProductStock entity = new ProductStock();
entity.setStock(stock);
entity.setVersion(0);
// 这里简化处理,实际需要根据 productCode 更新
// 建议直接在 SQL 里执行 UPDATE
log.info("重置库存: {} -> {}", productCode, stock);
return Result.success("库存已重置为 " + stock);
} catch (Exception e) {
return Result.error("重置失败: " + e.getMessage());
}
}
}
我们写了三个接口,分别是使用悲观锁、乐观锁处理业务,以及查看商品库存。
现在我们在service层重点实现这些逻辑。
1、悲观锁
java
/**
* 悲观锁
*/
@Transactional(rollbackFor = Exception.class, timeout = 3)
public SeckillResponse decreaseStockWithPessimisticLock(SeckillRequest request) {
String productCode = request.getProductCode();
Integer quantity = request.getQuantity();
Long userId = request.getUserId();
long startTime = System.currentTimeMillis();
SeckillResponse response = new SeckillResponse();
response.setProductCode(productCode);
response.setQuantity(quantity);
try {
// 1. 悲观锁查询(串行化读)
log.debug("用户 {} 开始抢购商品 {}, 使用悲观锁查询", userId, productCode);
ProductStock stock = stockMapper.selectForUpdate(productCode);
if (stock == null) {
log.warn("商品不存在: {}", productCode);
response.setSuccess(false);
response.setMessage("商品不存在");
return response;
}
// 2. 业务校验:库存是否充足
if (stock.getStock() < quantity) {
log.warn("库存不足, 商品: {}, 当前库存: {}, 需要: {}",
productCode, stock.getStock(), quantity);
response.setSuccess(false);
response.setMessage("库存不足");
response.setRemainStock(stock.getStock());
return response;
}
// 3. 更新
int affectedRows = stockMapper.updateByCode(stock.getProductCode(),quantity);
// 4. 判断更新结果
if (affectedRows == 0) {
response.setSuccess(false);
response.setMessage("系统繁忙,请重试");
// 重新查询最新库存
ProductStock latest = stockMapper.selectById(stock.getId());
response.setRemainStock(latest != null ? latest.getStock() : 0);
return response;
}
// 5. 扣减成功
log.info("秒杀成功, 用户: {}, 商品: {}, 数量: {}, 耗时: {}ms",
userId, productCode, quantity, System.currentTimeMillis() - startTime);
response.setSuccess(true);
response.setMessage("秒杀成功");
response.setRemainStock(stock.getStock() - quantity);
return response;
} catch (Exception e) {
log.error("秒杀异常, 用户: {}, 商品: {}", userId, productCode, e);
response.setSuccess(false);
response.setMessage("系统异常:" + e.getMessage());
return response;
}
}
我们把悲观锁放在一个事务中(只有这样才会生效),而且看我们的SQL.
sql
<select id="selectForUpdate" resultType="com.miao.seckill.entity.ProductStock">
SELECT id, product_code, stock, version, gmt_modified
FROM product_stock
WHERE product_code = #{productCode}
FOR UPDATE
</select>
使用了FOR UPDATE。其次我们的where条件中使用的是product_code,这是索引字段,因此不会给全表加锁。
那么在这种情况下,其它请求到来时,不能进行update等当前读操作,只能读取MVCC版本快照。
其他用户想要购买只能阻塞到这,等前面的事务结束才会被执行,我们测试一下看看正常单个用户是否能购买成功。


数据库也减少了库存。
2、悲观锁并发测试
现阶段我们的业务过于简单,所以我们手动加一点阻塞:



可以发现,最大一次请求用了427ms,最低一次为啥1ms呢,因为此时库存已经扣完了,走不到休眠逻辑。我们把库存改到999,用低配置跑一下。


平均用时为114ms
先保证数据库库存充足,最开始的配置再跑一遍

这个时候,阻塞已经形成,平均值达到了9552ms!
由此可见,悲观锁在写多读少,事务复杂的场景,例如秒杀场景是非常不合适的。
3、乐观锁
前面我们讲了乐观锁的工作原理,乐观锁用于写多读少,事务复杂的场景。不会阻塞响应,如果失败也会很快给出响应结果,我们可以把代码做如下改造:
java
/**
* 纯乐观锁方案
*/
@Transactional(rollbackFor = Exception.class)
public SeckillResponse decreaseStockWithPureOptimistic(SeckillRequest request) {
String productCode = request.getProductCode();
Integer quantity = request.getQuantity();
Long userId = request.getUserId();
SeckillResponse response = new SeckillResponse();
response.setProductCode(productCode);
response.setQuantity(quantity);
try {
// 先查询当前版本号
ProductStock stock = stockMapper.selectOneByProductCode(productCode);
if (stock == null) {
response.setSuccess(false);
response.setMessage("商品不存在");
return response;
}
if (stock.getStock() < quantity) {
response.setSuccess(false);
response.setMessage("库存不足");
response.setRemainStock(stock.getStock());
return response;
}
try {
Thread.sleep(50);
}catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 直接乐观锁更新
int affectedRows = stockMapper.decreaseStockWithVersion(
productCode, quantity, stock.getVersion()
);
if (affectedRows == 0) {
response.setSuccess(false);
response.setMessage("库存已更新,请重试");
ProductStock latest = stockMapper.selectOneByProductCode(productCode);
response.setRemainStock(latest != null ? latest.getStock() : 0);
return response;
}
response.setSuccess(true);
response.setMessage("秒杀成功");
response.setRemainStock(stock.getStock() - quantity);
return response;
} catch (Exception e) {
log.error("纯乐观锁秒杀异常", e);
response.setSuccess(false);
response.setMessage("系统异常");
return response;
}
}
版本号可以在放在用户的请求中,也可以在业务执行时查询。我们这种写法也会存在一个问题,那就是A和B可能先后拿到初始版本号,但是B却比A先买到商品,就算把版本放到请求中也会遇到这个问题。
也就是说在并发情况下,谁先买到就说不准了。
4、乐观锁并发测试
按照之前的配置低并发(10,5)测试一下:

平均耗时80ms
我们再用之前的高并发(300,50)测试一下:

可以发现,确实都没有阻塞,而且都成功了。我们想模拟出因为版本不一致,无法购买成功的逻辑。引入线程休眠并不是一个好的方案,因为我们的业务逻辑非常简单所以无法模拟出这样的现象。
所以我们就采用手动执行sql语句去执行。

现在的版本号是1842,我们先执行一次。让版本号变成1843,然后我们拿着1842去修改,看会不会成功。

可以发现受影响的行数为零行,因此我们没有修改成功,version版本还停留在1843

乐观锁下响应速度还是非常快的。
5、超卖测试

现在库存只有2000,编写好locust脚本,假设每次只能买一件商品
python
import json
import random
from locust import HttpUser, task, between
productCode = "SKU_001"
quantity = 1
userId = list(range(10001, 10005))
class MyUser(HttpUser):
# 思考时间:模拟真实用户操作间隔
wait_time = between(0.5, 1.5)
def on_start(self):
"""用户启动时分配ID"""
self.user_id = random.choice(userId)
@task
def optimistic_seckill(self):
"""
乐观锁秒杀接口压测
"""
request_data = {
"productCode": productCode,
"quantity": quantity,
"userId": self.user_id
}
with self.client.post(
"/api/seckill/pure",
json=request_data,
catch_response=True,
name="乐观锁秒杀"
) as response:
if response.status_code == 200:
try:
data = response.json()
# 先判断外层 code
if data.get("code") == 200:
# 再判断内层 data.success
inner_data = data.get("data")
if inner_data and inner_data.get("success") is True:
response.success()
else:
# success = false,业务失败(库存不足等)
msg = inner_data.get("message", "未知错误") if inner_data else "数据为空"
response.failure(f"业务失败: {msg}")
else:
# 外层 code 不是 200
response.failure(f"接口返回错误: {data.get('msg', '未知错误')}")
except json.JSONDecodeError:
response.failure("响应不是有效的JSON")
else:
response.failure(f"HTTP状态码异常: {response.status_code}")
填好测试信息






我们发现,一开始就存在失败,说明version乐观锁起到一定作用。响应速度依旧很快。
后面数据库归零后不在扣减,同时稳定后我们发现:Fiils + 2000 = Requests
我们规定的一次只买一件商品因此有这个规律,同时说明并无超售发生。
我们杜绝了超售!
四、MyBatis-Plus提供的@Version乐观锁注解
我们发现只要修改数据,version就得跟着变,如果某条SQL忘了加version校验和自增,那么将会造成严重的业务灾难。
mybatis-plus提供了一个注解,@version,使用前需要导入mybatis-plus依赖
XML
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
具体的版本还需要根据springboot版本号指定,当然也可以不指定版本(有BOM的话)
注意:为了避免依赖冲突,mybatis和mybatis-plus只能选择一个使用。

同时还需要配置拦截器
java
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 乐观锁拦截器 ← 必须加,否则 @Version 不生效
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
这种仅限于mybatis-plus自动生成的SQL,例如继承BaseMaper,自己写的SQL是不会在update时添加version版本校验的。
其次就是如果update()传入的对象里面version属性为空,那么条件and version = null也会加,但是始终不成立,查不到这样的数据,自然无法修改成功。

我们测试一下:

看到输出的日志,实际执行的SQL里多了and version = 这个条件,这样我们就不用修改数据时每次手写了。
五、总结
本节我们演示了在秒杀场景下,使用悲观锁乐观锁防止数据不一致问题:在具体场景中,两个事务都查到可以售卖但是只能卖一件商品,过了校验之后,再去修改就可能导致超售。有人也许会问:我把校验和修改都丢到一个sql里就行了啊,利用数据库的原子性保证数据一致。但是在企业里,SQL不能承担过多的功能,查数据就只查数据,修改就只修改。如果混用的话,不同的场景下SQL就没法复用了。
随着业务更加复杂,很多校验单纯靠一句SQL能做到吗?不能,必须靠业务层的校验。
因此无论是悲观锁还是乐观锁,在并发场景下都是很有必要的。
悲观锁会阻塞事务,乐观锁会大量失败。那么企业是怎么做的呢?根据两种锁的特点,采取结合的方式。把热点数据放入Redis中,先过滤掉哪些根本不可能成功的操作,这样数据库压力就减轻了。未来我们还会遇到分布式锁,解决跨库事务。
以上就是本节所有内容,如果疏漏请大家指出,作者会认真勘误。