网站首页 > 技术文章 正文
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 锁定,会被阻塞(等待当前事务释放锁)。
- 避免并发冲突:在并发场景下(如秒杀、转账等),防止 “先查询再更新” 的过程中,数据被其他事务修改导致的不一致问题。
使用条件
- 必须在事务中:FOR UPDATE 仅在事务内有效,需要通过 START TRANSACTION 开启事务,或关闭自动提交(SET autocommit = 0)。
- 依赖存储引擎:仅支持事务的引擎(如 InnoDB)有效,MyISAM 等不支持事务的引擎会忽略锁(或退化为表锁)。
- 锁的范围:若查询条件使用 索引,通常会锁定符合条件的 行级锁(仅锁定匹配的行)。若查询条件 未使用索引,可能会升级为 表级锁(锁定整个表),影响并发性能,需特别注意。
与LOCK IN SHARE MODE的区别
- SELECT ... LOCK IN SHARE MODE:施加 共享锁,允许其他事务读,但不允许修改(需等待共享锁释放)。
- SELECT ... FOR UPDATE:施加 排他锁,其他事务既不能修改,也不能加共享锁(需等待排他锁释放),锁的级别更高。
注意事项
- 控制事务时长:锁会在事务结束后释放,过长的事务会导致其他事务长时间等待,引发性能问题。
- 避免全表锁:确保查询条件使用索引,防止锁升级为表锁。
- 死锁风险:若多个事务交叉锁定不同行,可能导致死锁(如事务 1 锁行 A 等行 B,事务 2 锁行 B 等行 A),需合理设计锁定顺序。
猜你喜欢
- 2025-09-08 SQL 中 For update 讲解_for循环语句结构
- 2025-09-08 Dify新版1.8.0发布:新增异步工作流和多模型设置!
- 2025-09-08 无锁队列Disruptor原理解析_ringbuffer 无锁队列 c
- 2024-11-14 深入浅出MySQL之MySQL锁概述 mysql锁的实现原理
- 2024-11-14 了解SQL编程锁的概念 了解sql编程锁的概念是什么
- 2024-11-14 面试必问的Mysql事务和锁,你真的了解吗?
- 2024-11-14 数据库常用的锁有哪些? 数据库锁种类
- 2024-11-14 MySQL事务和锁的使用 mysql事务锁机制
- 2024-11-14 数据库融入DevOps基因后,运维再也不用做背锅侠了
- 2024-11-14 Mysql锁-01锁相关的一些概念 mysql锁的实现原理
你 发表评论:
欢迎- 最近发表
- 标签列表
-
- 前端设计模式 (75)
- 前端性能优化 (51)
- 前端模板 (66)
- 前端跨域 (52)
- 前端缓存 (63)
- 前端aes加密 (58)
- 前端脚手架 (56)
- 前端md5加密 (54)
- 前端路由 (61)
- 前端数组 (73)
- 前端js面试题 (50)
- 前端定时器 (59)
- Oracle RAC (76)
- oracle恢复 (77)
- oracle 删除表 (52)
- oracle 用户名 (80)
- oracle 工具 (55)
- oracle 内存 (55)
- oracle 导出表 (62)
- oracle约束 (54)
- oracle 中文 (51)
- oracle链接 (54)
- oracle的函数 (58)
- oracle面试 (55)
- 前端调试 (52)
本文暂时没有评论,来添加一个吧(●'◡'●)