环境:SpringBoot3.4.2
1. 简介
假设你有一个在线书店系统。比如说:
-
你书店的仓库里目前有 200 本某热门小说的库存。
-
你的网站上线后,恰逢该小说的作者举办线上签售活动,结果有 1500 名读者在同一时间点访问你的网站,并尝试下单购买这本书。
-
而你正在使用类似如下的 REST 接口来处理购买请求:
@Transactional
public void buy(Long id, Integer quantity) {
Product product = productRepository.findById(id).get() ;
if (product.getQuantity() >= quantity) {
product.setQuantity(product.getQuantity() - quantity);
productRepository.save(product) ;
} else {
throw new RuntimeException("库存不足");
}
}
问题分析:
上面代码对初学者来说看起来应该是正确的,但如果有两个或更多的用户可能会在同一时间读取产品数量,看到库存充足,然后同时减少库存数量。这就会导致超订或超卖的情况发生,尽管你在下单前已经检查过库存是否充足。
这种情况之所以会发生,是因为在并发读写操作之间没有进行同步处理——这就导致了一个竞态条件(race condition)的出现。
为什么乐观锁可能不够用
你可能会尝试使用带有 @Version 的乐观锁,它在大多数情况下确实能正常工作。但是:
在高并发系统中,乐观锁会导致频繁的重试和失败。
如果在 2 秒内有 500 个用户同时访问服务器,几乎所有用户都会遇到 OptimisticLockException,迫使系统频繁重试或经常失败。
解决方案:悲观锁
什么是悲观锁?
悲观锁是一种策略,它假设在并发系统中冲突发生的可能性非常高。因此,当一个线程读取数据以进行更新时,它会立即锁定数据库中的那一行,从而阻止其他线程甚至读取该行数据。
悲观锁在哪些场景下有用?
-
在数据一致性至关重要的系统中(例如,涉及金钱、门票、库存的系统)。
-
在高并发环境下,乐观锁的重试机制经常失败的情况。
-
在你无法容忍任何竞态条件出现的情况下,即使只是偶尔出现也不行。
为什么不使用 synchronized 或 Java 锁(JUC锁)?
因为这些锁机制仅在单个 JVM(Java 虚拟机)内部有效,当你的应用被扩展到多个实例或容器时,它们就无法发挥作用了。你需要的是数据库级别的锁机制,而这正是悲观锁所能提供的。
接下来,我们将基于悲观锁一步一步解决并发超卖问题。
2.实战案例
2.1 定义实体
@Entity
@Table(name = "t_product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name ;
private BigDecimal price ;
private int quantity ;
// getters, setters
}
2.2 Repository接口使用@Lock
public interface ProductRepository extends JpaRepository<Product, Long> {
@Transactional
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = ?1")
Product findByIdWithLock(Long id);
}
注意:这里必须使用 @Transactional 注解,开启事务(当然,你也可以在Service中调用的方法上开启)。
当我们调用上面的findByIdWithLock方法,执行的sql如下:
自动在SQL上添加了 for update;当一个事务执行带有 FOR UPDATE 子句的查询时,数据库会锁定查询结果集中的行,阻止其他事务对这些行进行修改或锁定。
通过如下代码测试并发方法:
@Transactional
public Product queryProduct(Long id) {
System.err.printf("[%d] %s - start...%n", System.currentTimeMillis(), Thread.currentThread().getName()) ;
Product product = this.productRepository.findByIdWithLock(id);
try {
System.err.printf("[%d] %s - 锁定数据【%d】成功...%n", System.currentTimeMillis(), Thread.currentThread().getName(), id) ;
TimeUnit.SECONDS.sleep(10) ;
}
System.err.printf("[%d] %s - end...%n", System.currentTimeMillis(), Thread.currentThread().getName()) ;
return product ;
}
当我有2个线程同时此接口时,输出如下:
当有线程锁定了给定ID的数据后,其它的线程必须等待锁释放以后才能锁定数据。
2.3 锁超时
悲观锁可能会导致死锁或长时间的等待。我们可以通过如下的配置设置悲观锁超时时间:
@Transactional
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
@QueryHint(
name = "jakarta.persistence.lock.timeout",
value = "3000") // 3秒超时
})
@Query("SELECT p FROM Product p WHERE p.id = ?1")
Product findByIdWithLock(Long id);
我们将基于Oracle与MySQL进行测试上面的超时配置。
测试代码为上面的queryProduct方法。
MySQL
多个线程同时访问,控制台输出:
并没有发生锁超时异常。
Oracle
多线程同时访问,3s后抛出了异常,并且观察sql语句 for update wait 3,锁等待3s。
总结:使用 @QueryHints 注解配置 jakarta.persistence.lock.timeout 锁超时时间,MySQL不生效,Oracle中生效。
那么MySQL中如何处理悲观锁的超时呢?
mysql有一个配置 innodb_lock_wait_timeout ,在innodb引擎下锁等待超时配置,默认值如下:
mysql> SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50 |
+--------------------------+-------+
默认50s超时事件,我们只能通过修改该超时时间来在程序中捕获异常。
通过如下修改超时时间:
mysql> SET GLOBAL innodb_lock_wait_timeout = 3;
Query OK, 0 rows affected (0.00 sec)
新开窗口,再次查看是否生效:
mysql> SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 3 |
+--------------------------+-------+
1 row in set, 1 warning (0.00 sec)
以上修改是临时的,如果MySQL重启后就失效了,如下方式永久修改(在 MySQL 配置文件(通常是 my.cnf 或 my.ini)中添加或修改以下行:):
[mysqld]
innodb_lock_wait_timeout = 3
如上配置完后,我们再次运行mysql的测试:
抛出了悲观锁异常。
2.4 优雅处理悲观锁异常
在处理悲观锁异常时,优雅地处理异常可以帮助提高应用程序的健壮性和用户体验。在你的代码中,当发生 PessimisticLockingFailureException 时,可以采取一些策略来应对锁竞争,例如重试、记录日志、通知用户等。
接下来,我们将通过重试机制来改造代码
首先,引入依赖
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
其次,开启重试功能
@SpringBootApplication
@EnableRetry
public class App {}
最后,改造业务代码
@Transactional
public void buyProcess(Long id, Integer quantity) {
Product product = productRepository.findByIdWithLock(id)
.orElseThrow(() -> new ProductNotFoundException("商品不存在"));
try {
System.err.printf("[%d] %s - 锁定数据【%d】成功...%n", System.currentTimeMillis(), Thread.currentThread().getName(), id) ;
TimeUnit.SECONDS.sleep(13) ;
}
if (product.getQuantity() < quantity) {
throw new RuntimeException("库存不足");
}
product.setQuantity(product.getQuantity() - quantity);
productRepository.save(product);
}
// 重试全部失败后的降级处理
@Recover
public void recover(PessimisticLockingFailureException e, Long id, Integer quantity) {
// 记录日志、发送告警或抛出业务异常
throw new BusinessException("系统繁忙, 请稍后重试");
}
接下来,我们通过2个线程进行测试,最终控制台输出如下:
经过2次重试后,成功。
当重试3次后,还是失败后: