Spring Boot + 悲观锁:1行代码解决并发难题

环境: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次后,还是失败后:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值