Spring Boot并发更新还在掉坑?这5种解决方案让你稳如泰山!

环境:SpringBoot3.4.2

1. 简介

并发数据库更新是指多个用户或进程同时或在快速连续的时间内尝试修改同一数据库记录或数据的情况。在多用户或多线程环境中,当同时访问并修改同一数据时,就可能发生并发更新问题。并发更新可能导致如下问题:

数据不一致性:当多个事务同时修改相关数据而没有适当的同步机制时,数据库可能进入一个违反业务规则或完整性约束的状态更新丢失:当两个事务同时读取同一数据项,各自修改后写回,后提交的事务会覆盖先提交事务的修改,导致先提交的修改“丢失”脏读:一个事务读取了另一个尚未提交的事务所修改的数据。如果那个修改中的事务最终回滚,那么第一个事务读到的就是“脏”数据不可重复读:一个事务可能多次读取同一数据,但由于其他事务正在进行更新,每次读取的结果却不同

在开发中,要避免并发更新带来的上述问题,我们可以采取如下的6种方案进行解决。

数据库锁定:用行/表级锁,Spring Boot 结合@Transactional保证单事务更新乐观锁定:加版本号,更新时添加版本条件不对则失败,防并发修改悲观锁定:更新前用特定语句显式锁定记录或表隔离级别:调高隔离级别可防并发更新,但会降低性能应用层锁定:用 Java 同步机制控制代码关键部分访问(JVM锁或分布式锁)

接下来,我们将详细的介绍这5种方案的实现。

2.实战案例

环境准备

实体对象
复制
@Entity @Table(name = "t_product") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id ; private String name ; private BigDecimal price ; private Integer quantity ; }1.2.3.4.5.6.7.8.9.10.
数据库操作Repository
复制
public interface ProductRepository extends JpaRepository<Product, Long> { @Modifying @Query("update Product p set quantity = ?2 where id = ?1") int updateQuantity(Long id, Integer quantity) ; }1.2.3.4.5.
准备数据

图片

2.1 数据库锁

数据的更新保证在一个事务中,确保相同的数据在更新的时候必须排队。

复制
@Transactional public void productQuantity(Long id, Integer quantity) { // 只要有事务在更新当前id的数据,那么其它线程必须等待(数据库锁) this.productRepository.updateQuantity(id, quantity) ; // 模型耗时操作 if (quantity == 20000) { System.err.printf("%s - 进入等待状态...%n", Thread.currentThread().getName()) ; try {TimeUnit.SECONDS.sleep(200);} catch (InterruptedException e) {} System.err.printf("%s - 等待状态结束...%n", Thread.currentThread().getName()) ; } System.out.printf("%s - 更新完成...%n", Thread.currentThread().getName()) ; }1.2.3.4.5.6.7.8.9.10.11.12.

接下来,我们启动2个线程执行上面的操作

复制
// 线程1 this.productService.productQuantity(1L, 20000); // 线程2 this.productService.productQuantity(1L, 30000);1.2.3.4.
测试结果

线程1:

图片

线程2:

图片

线程2一直等待中,差不多1分钟后,超时错误:

图片

2.2 使用乐观锁

首先,我们在Product实体类中加入version字段同时使用 @Version 注解:

复制
public class Product { @Version private Integer version ; }1.2.3.4.

数据库中数据

图片

修改,更新方法如下:

复制
public void productQuantity(Long id, Integer quantity) { this.productRepository.findById(id).ifPresent(product -> { product.setQuantity(quantity) ; // 模拟耗时 if (quantity == 20000) { System.err.printf("%s - 进入等待%n", Thread.currentThread().getName()) ; try {TimeUnit.SECONDS.sleep(10);} catch (InterruptedException e) {e.printStackTrace();} } try { this.productRepository.save(product) ; } catch(Exception e) { e.printStackTrace() ; } }) ; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.

启动两个线程执行上面的操作

复制
// 线程1 this.productService.productQuantity(1L, 20000); // 线程2 this.productService.productQuantity(1L, 30000);1.2.3.4.

线程2会很快执行完;当线程1等待执行结束后执行save方法时,抛出如下异常:

图片

发生了乐观锁异常。

接下来,我们结合重试机制进行重试操作,尽可能的完成此种异常情况下的操作。

引入依赖
复制
<dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> </dependency>1.2.3.4.
开启重试机制
复制
@Configuration @EnableRetry public class AppConfig {}1.2.3.
修改更新方法
复制
@Retryable(maxAttempts = 3, retryFor = OptimisticLockingFailureException.class) public void productQuantity(Long id, Integer quantity) {}1.2.

设置了重试3次,包括了首次执行,并且是针对乐观锁异常进行重试。

测试结果如下:

图片

2.3 使用悲观锁

悲观锁语法,用于事务中。执行 SELECT ... FOR UPDATE 会锁住查询出的行记录,其他事务若想修改这些行会被阻塞,直到当前事务提交或回滚释放锁,可有效避免并发更新导致的数据不一致问题。

在Repository中添加如下方法:

复制
// 重写父类的方法,加入乐观锁for update @Lock(LockModeType.PESSIMISTIC_WRITE) Optional<Product> findById(Long id) ;1.2.3.

修改Service方法:

复制
@Transactional public void productQuantity(Long id, Integer quantity) { this.productRepository.findById(id).ifPresent(product -> { product.setQuantity(quantity); if (quantity == 20000) { System.err.printf("%s - 进入等待%n", Thread.currentThread().getName()); try {TimeUnit.SECONDS.sleep(10);} catch (InterruptedException e) {e.printStackTrace();} } this.productRepository.save(product); }); }1.2.3.4.5.6.7.8.9.10.11.

注意,你必须在一个事务当中,否则启动报错。

复制
// 线程1 this.productService.productQuantity(1L, 20000); // 线程2 this.productService.productQuantity(1L, 30000);1.2.3.4.
线程1先执行

线程2进入了等待状态

2.4 设置事务隔离级别

我们可以在事务的方法上设置事务的隔离级别,通过设置串行化(Serializable)隔离级别。

复制
@Transactional(isolation = Isolation.SERIALIZABLE) public void productQuantity(Long id, Integer quantity) { System.err.printf("%s - 准备执行%n", Thread.currentThread().getName()) ; this.productRepository.updateQuantity(id, quantity) ; if (quantity == 20000) { System.err.printf("%s - 进入等待状态...%n", Thread.currentThread().getName()) ; try {TimeUnit.SECONDS.sleep(10);} catch (InterruptedException e) {} } System.out.printf("%s - 更新完成...%n", Thread.currentThread().getName()) ; }1.2.3.4.5.6.7.8.9.10.

还是按照上面的方式启动2个线程,执行必须排队

线程1:

图片

线程2:

图片

2.5 应用程序锁

通过 synchronized 或 Lock 以及分布式锁实现关键代码每次只有一个线程执行。

复制
private Object lock = new Object() ; public void productQuantity(Long id, Integer quantity) { synchronized (lock) { this.productRepository.updateQuantity(id, quantity) ; } }1.2.3.4.5.6.

通过锁机制,确保了每次只有一个线程执行这里的更新操作。

注意,如果你写的如下代码可就是在写bug了:

复制
@Transactional public synchronized void productQuantity(Long id, Integer quantity) { this.productRepository.updateQuantity(id, quantity) ; }1.2.3.4.

该代码执行时很可能出现,锁释放了,但是事务还没有提交的场景。

阅读剩余
THE END