专业编程教程与实战项目分享平台

网站首页 > 技术文章 正文

Spring Boot + 悲观锁_悲观锁使用场景

ins518 2025-09-08 23:16:46 技术文章 3 ℃ 0 评论

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

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

并没有发生锁超时异常。

多线程同时访问,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
时,可以采取一些策略来应对锁竞争,例如重试、记录日志、通知用户等。

关于重试机制的介绍和代码,下个文章再写吧

总结:

核心作用

  • 排他锁(Exclusive Lock):FOR UPDATE 会对查询到的行施加排他锁,其他事务若想对这些行执行 INSERT/UPDATE/DELETE 或也使用 FOR UPDATE 锁定,会被阻塞(等待当前事务释放锁)。
  • 避免并发冲突:在并发场景下(如秒杀、转账等),防止 “先查询再更新” 的过程中,数据被其他事务修改导致的不一致问题。

使用条件

  1. 必须在事务中:FOR UPDATE 仅在事务内有效,需要通过 START TRANSACTION 开启事务,或关闭自动提交(SET autocommit = 0)。
  2. 依赖存储引擎:仅支持事务的引擎(如 InnoDB)有效,MyISAM 等不支持事务的引擎会忽略锁(或退化为表锁)。
  3. 锁的范围:若查询条件使用 索引,通常会锁定符合条件的 行级锁(仅锁定匹配的行)。若查询条件 未使用索引,可能会升级为 表级锁(锁定整个表),影响并发性能,需特别注意。

与LOCK IN SHARE MODE的区别

  • SELECT ... LOCK IN SHARE MODE:施加 共享锁,允许其他事务读,但不允许修改(需等待共享锁释放)。
  • SELECT ... FOR UPDATE:施加 排他锁,其他事务既不能修改,也不能加共享锁(需等待排他锁释放),锁的级别更高。

注意事项

  1. 控制事务时长:锁会在事务结束后释放,过长的事务会导致其他事务长时间等待,引发性能问题。
  2. 避免全表锁:确保查询条件使用索引,防止锁升级为表锁。
  3. 死锁风险:若多个事务交叉锁定不同行,可能导致死锁(如事务 1 锁行 A 等行 B,事务 2 锁行 B 等行 A),需合理设计锁定顺序。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表