环境:SpringBoot3.4.0
1. 简介
在高并发场景中,库存扣减是最典型的业务挑战之一。当多个线程同时争夺同一商品的库存资源时,稍有不慎就会引发超卖(库存扣减为负值)或死锁(线程相互阻塞无法推进)等严重问题。为解决超卖,开发者常采用JPA乐观锁(基于版本号机制)配合重试策略,然而这一方案在实际应用中暗藏陷阱——若事务管理与重试逻辑设计不当,反而会触发更隐蔽的死锁问题。
以一段典型的Spring Data JPA代码为例:当多个线程同时扣减库存时,乐观锁冲突会触发ObjectOptimisticLockingFailureException,而开发者试图通过递归重试恢复操作。然而,这种设计可能因以下原因导致系统崩溃:
- 事务传播机制缺陷
重试未开启独立事务,导致行锁长期未释放,线程相互阻塞。 - JPA一级缓存干扰
未清理的缓存使重试读取到脏数据,与数据库真实状态不一致。 - 无限递归风险
持续冲突引发栈溢出或资源耗尽。
本文将以代码实践为切入点,逐步分析在高并发场景下乐观锁引发的各种问题。
2. 实战案例
2.1 准备环境
准备JPA对应的实体类
@Entity
@Table(name = "c_product")
@DynamicUpdate
public class Product {
@Id
private Long id;
private String name;
private Integer stock;
@Version
private Integer version;
// getters, setters
}
c_product表准备如下数据
INSERT INTO `mall`.`c_product` (`id`, `name`, `stock`, `version`)
VALUES (1, 'Spring Boot3实战案例100例', 2, 1);
图片
准备Repository接口
public interface ProductRepository extends
JpaRepository<Product, Long> {
}
扣减库存Service初始方法
@Transactional
public void deductStock(Long productId, int quantity) {
this.productRepository.findById(productId).ifPresentOrElse(p -> {
if (p.getStock() >= quantity) {
p.setStock(p.getStock() - quantity);
productRepository.save(p);
} else {
throw new RuntimeException("库存不足");
}
}, () -> {
throw new RuntimeException("商品不存在");
});
}
在这初始扣减库存中我们还并没有加入重试的逻辑。
单元测试用例
@Test
public void testDeductStock() throws Exception {
final int MAX = 10 ;
CountDownLatch cdl = new CountDownLatch(MAX) ;
CyclicBarrier cb = new CyclicBarrier(MAX) ;
for (int i = 0; i < MAX; i++) {
new Thread(() -> {
try {
cb.await() ;
this.productService.deductStock(1L, 1) ;
} catch(Exception e) {
} finally {
cdl.countDown() ;
}
}, "T" + i).start() ;
}
cdl.await() ;
System.err.println("执行完成...") ;
}
测试用例中模拟了10个线程进行并发库存扣减操作。
2.2 初始代码测试
首先,我们测试初始代码结果如下:
图片
执行更新动作时自动加入了version版本字段;数据库中的数据如下:
图片
也就是只有一个线程扣减库存成功,其它10个线程都发生了乐观锁异常。可是控制台并没有输出异常啊,这是因为我们在单元测试中将异常吞了,修改测试用例代码在catch中输出异常,如下:
图片
控制台输出。
图片
9个线程都发生了并发修改乐观锁异常。
思考:我们在测试用例中进行了异常捕获,那么我们能否在ProductService#deductStock方法中进行捕获呢?以当前的代码来看是不行的,也就是你想通过如下方式捕获是不行的:
图片
或者是你将deductStock方法都进行try...catch也是不能捕获的。因为乐观锁是在事务提交的时候进行检查的。
接下来,我们要加入乐观锁异常后重试机制。
2.3 乐观锁重试版本1
我们将代码修改如下:
图片
首先,我将原来的save方法修改为saveAndFlush;其次,catch捕获了乐观锁异常并在其中自调用进行重试。
将方法修改为saveAndFlush会立即将update语句发送给db进行执行,这样就能捕获到乐观锁异常了。
如上代码是否能改进行正确的库存扣减呢?执行测试用例输出如下:
图片
库存不足,那么是不是数据库中已经都扣减完了?
图片
查看数据库,库存还有,但控制台确输出的库存不足,这又是为什么呢?我们在代码中加入如下输出:
图片
再次运行,输出结果。
图片
这里我们以T5线程来说:
- T5线程首次查询stock=2,versinotallow=1;
- 当update时由于其它线程已经修改了数据,版本发生了变化,所以T5线程抛出了乐观锁异常;
- 接着,再次调用deductStock方法,但是并没有执行select语句,这是由于JPA一级缓存导致(同一个线程EntityManager使用的同一个),但是输出的stock=1,versinotallow=1,这是因为update虽然没有成功,但是我们的代码中在上一步中确对stock进行了减1操作。
- 继续执行update语句,由于已经有线程执行成功将version变成了2,所以这次还是抛出了乐观锁异常(此时,缓存中的stock进行了2次减1操作,已经变成了0)。
- 最后,第三次调用的时候一级缓存中的stock已经变为0了,所以最终抛出了库存不足;这时候的库存不足是内存中没有了,可数据库真实的数据还是有的。
以上是当前版本1中出现的问题。接下来,我们将继续修改代码。
2.4 乐观锁重试版本2
通过上面的说明,我们知道了在同一个线程中查询相同的数据第二次将会从缓存中直接返回,那是不是我们将缓存清理了就可以再次从数据库中查询最新的数据呢?修改代码如下:
首先,我们注入了EntityManager对象;接着,在catch中进行了clear清理一级缓存。
测试结果如下:
图片
所有的线程无限循环(死循环了),并且通过输出日志也确实每次都执行了SQL语句,那为什么输出的stock=1,versinotallow=1呢?先来查看数据真实数据:
程序中查询的与数据库为什么不符?
原因很简单,因为我们当前事务的隔离级别是可重复读(REPEATABLE_READ),那么每次查询的数据都将是快照读(事务第一次查询到的数据)。
接下来,我们将继续修改代码进行第三个版本的尝试。
2.5 乐观锁重试版本3
既然知道了原因,那么我们是不是修改事务的隔离级别就可以了呢?代码修改如下:
图片
在事务注解上将事务的隔离级别设置为读已提交(默认是你当前数据库中默认的隔离级别)。如上修改后再次运行测试用例:
图片
这次程序正常结束并且每次都从数据库中查询了最新的数据,但是又多了一个异常,意思是:"事务已被标记为仅回滚,因此已静默回滚"。为什么?
这里我们以T3线程为例进行分析:
- 首次执行由于已经被其它线程更新,所以抛出乐观锁异常。
- 进入重试,重新查询数据得到最新的stock=1,versinotallow=2,库存大于0,进行扣减并执行update操作。
- T3线程进行方法结束开始提交事务,这个异常就是在提交事务时抛出的。当第一次发生乐观锁异常的时候JPA内部就已经将当前的事务状态设置为回滚,所以最终提交的时候当然执行回滚操作。
这时候又该如何解决呢?接下来,我们继续修改代码。
2.6 乐观锁重试版本4
既然知道了原因,那么我们是不是在每次重试的时候只要开启一个新的事务就可以了呢?修改代码如下:
图片
在上面的代码中,我们修改了下面3点:
- 将事务的传播属性设置REQUIRES_NEW,每次都开始一个新的;并且删除了事务的隔离级别,每次都是新的也就没有必要了。
- 删除了clear清理缓存的操作。
- 自己注入自己调用deductStock方法,这是为了防止事务失效。
测试结果如下:
图片
死锁了,等待默认的超时时间后结束,为什么?
这是因为当我们任何一个线程发生乐观锁异常进入重试阶段时,虽然重试时开启的是一个新的事务,但是之前的事务update操作还没有结束(已经将id为1的记录锁定了,行锁),当你再次开启一个新事务执行update操作时那必须等待前一个事务结束,前一个事务肯定结束不了,因为我们是内部自己调用自己(递归调用),所以这里就产生了死锁现象。
这该如何解决呢?接下来,我们将介绍几种解决办法。
2.7 乐观锁重试版本5
我们将代码修改如下:
public void deductStock(Long productId, int quantity) {
this.productRepository.findById(productId).ifPresentOrElse(p -> {
if (p.getStock() >= quantity) {
p.setStock(p.getStock() - quantity);
try {
this.productRepository.saveAndFlush(p) ;
} catch (ObjectOptimisticLockingFailureException e) {
System.err.println(Thread.currentThread().getName() + " - 乐观锁异常, " + e.getMessage()) ;
deductStock(productId, quantity) ;
}
} else {
throw new RuntimeException("库存不足");
}
}, () -> {
throw new RuntimeException("商品不存在");
}) ;
}
注意观察上面的代码,我们做了如下的修改:
- 方法上的事务注解删除了。
- 乐观锁异常处理中,我们直接调用了当前的方法。(因为当前的方法一级不是事务方法,不需要自己注入自己进行调用)
执行结果如下:
图片
数据库库存及版本变化:
图片
数据也正确了。
上面的代码中不管你用save还是saveAndFlush都是可以的。因为每一次的重试调用都是开启的一个新事务。