1行代码解决告别超卖并发难题!

68 阅读3分钟

点击上方“程序员蜗牛g”,选择“设为星标”跟蜗牛哥一起,每天进步一点点程序员蜗牛g大厂程序员一枚 跟蜗牛一起 每天进步一点点32篇原创内容**公众号

目前仓库里目前有 100 本某热门小说的库存,结果有 1000 名读者在同一时间点访问你的网站,并尝试下单购买这本书,下面接口来处理购买请求:

@Transactionalpublic 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("库存不足");  }}

图片图片图片

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 子句的查询时,数据库会锁定查询结果集中的行,阻止其他事务对这些行进行修改或锁定。

通过如下代码测试并发方法:

@Transactionalpublic 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);

我们将基于MySQL进行测试上面的超时配置。测试代码为上面的queryProduct方法。

MySQL

多个线程同时访问,控制台输出:

图片

并没有发生锁超时异常。

那么MySQL中如何处理悲观锁的超时呢?

mysql有一个配置 innodb_lock_wait_timeout ,在innodb引擎下锁等待超时配置,默认值如下:

图片

默认50s超时事件,我们只能通过修改该超时时间来在程序中捕获异常。

通过如下修改超时时间:

图片

新开窗口,再次查看是否生效:

图片

以上修改是临时的,如果MySQL重启后就失效了,如下方式永久修改(在 MySQL 配置文件(通常是 my.cnf 或 my.ini)中添加或修改以下行:):

图片

如上配置完后,我们再次运行mysql的测试:

图片

抛出了悲观锁异常。

2.4 优雅处理悲观锁异常

在处理悲观锁异常时,优雅地处理异常可以帮助提高应用程序的健壮性和用户体验。在你的代码中,当发生 PessimisticLockingFailureException 时,可以采取一些策略来应对锁竞争,例如重试、记录日志、通知用户等。

接下来,我们将通过重试机制来改造代码

首先,引入依赖

<dependency>  <groupId>org.springframework.retry</groupId>  <artifactId>spring-retry</artifactId></dependency>

其次,开启重试功能

@SpringBootApplication@EnableRetrypublic class App {}

最后,改造业务代码

@Transactionalpublic 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);}// 重试全部失败后的降级处理@Recoverpublic void recover(PessimisticLockingFailureException e, Long id, Integer quantity) {  // 记录日志、发送告警或抛出业务异常  throw new BusinessException("系统繁忙, 请稍后重试");}

接下来,我们通过2个线程进行测试,最终控制台输出如下:

图片

经过2次重试后,成功。

当重试3次后,还是失败后:

图片

如果这篇文章对您有所帮助,或者有所启发的话,求一键三连:点赞、转发、在看。

关注公众号:woniuxgg,在公众号中回复:笔记  就可以获得蜗牛为你精心准备的java实战语雀笔记,回复面试、开发手册、有超赞的粉丝福利!