java中乐观锁与悲观锁区别及使用场景分析
作者:kerwin_code
一、什么是乐观锁?什么是悲观锁
个人理解一句话概括就是,在应用层面会造成线程阻塞的是悲观锁,而不会造成线程阻塞的是乐观锁,为什么这么说会在后续的内容中做详细介绍。
1.1、悲观锁
悲观锁是一种基于悲观态度的数据并发控制机制,用于防止数据冲突。它采取预防性的措施,在修改数据之前将其锁定,并在操作完成后释放锁定,以确保数据的一致性和完整性。悲观锁通常用于并发环境下的数据库系统,是数据库本身实现锁机制的一种方式。
在悲观锁的机制下,当一个使用者要修改某个数据时,首先会尝试获取该数据的锁。如果锁已经被其他使用者持有,则当前使用者会被阻塞,直到对应的锁被释放。这种悲观的态度认为数据冲突是不可避免的,因此在修改数据之前先锁定数据,以防止冲突的发生。
在Java中,常见的悲观锁实现是使用
synchronized
关键字或ReentrantLock
类。这些锁能够确保同一时刻只有一个线程可以访问被锁定的代码块或资源,其他线程必须等待锁释放后才能继续执行。
1.2、乐观锁
乐观锁是一种基于版本控制的并发控制机制。在乐观锁的思想中,认为数据访问冲突的概率很低,因此不加锁直接进行操作,但在更新数据时会进行版本比对,以确保数据的一致性。
乐观锁的原理主要基于版本号或时间戳来实现。在每次更新数据时,先获取当前数据的版本号或时间戳,然后在更新时比对版本号或时间戳是否一致,若一致则更新成功,否则表示数据已被其他线程修改,更新失败。
在Java中,常见的乐观锁实现是使用
Atomic
类,例如AtomicInteger
、AtomicLong
等。这些类提供了原子操作,可以确保对共享资源的更新操作是原子性的,从而避免了锁的开销和线程等待,另外,CAS(Compare-And-Swap)
是实现乐观锁的核心算法,它通过比较内存中的值是否和预期的值相等来判断是否存在冲突。如果存在,则返回失败;如果不存在,则执行更新操作。Java中提供了AtomicInteger
、AtomicLong
、AtomicReference
等原子类来支持CAS操作。
二、乐观锁与悲观锁分别适用于什么场景
2.1、悲观锁适用场景
- 高并发且数据竞争激烈的场景:当多个事务需要同时访问和修改同一份数据时,使用悲观锁可以确保数据在任一时刻只被一个事务访问和修改,从而避免数据的不一致性和脏读。
- 数据一致性要求极高的场景:在金融、医疗等行业中,对数据的一致性要求非常高,不允许出现任何的数据不一致或脏读现象。在这些场景中,使用悲观锁可以确保数据在任一时刻只被一个事务访问和修改,从而满足数据一致性的要求。
- 写操作频繁的场景:如果系统中写操作(如更新、删除等)远多于读操作(如查询),那么使用悲观锁可以更有效地保护数据,避免在写操作时被其他事务干扰。
- 事务执行时间较长的场景:当事务的执行时间较长时,使用悲观锁可以确保在该事务执行期间,数据不会被其他事务修改,从而避免数据的不一致性和脏读。
2.2、乐观锁适用场景
- 写操作较少:在这种场景下,多个事务或线程大部分时间都在读取数据,而写操作的频率相对较低。乐观锁能够减少锁的持有时间,允许多个事务或线程同时读取数据,而不会相互阻塞。
- 数据冲突较少:如果数据更新操作之间的冲突较少,即多个事务或线程同时更新同一份数据的概率较低,那么乐观锁能够发挥很好的性能。因为即使偶尔出现冲突,也只是在更新数据时才会被检测到,而不需要在整个数据处理过程中都锁定资源。
- 重试成本较低:乐观锁在检测到冲突时会回滚事务或提示冲突,需要客户端重新尝试更新操作。因此,如果重试的成本较低(例如,重试不会导致大量计算或I/O操作),那么使用乐观锁是合适的。
- 系统能够容忍一定程度的失败:由于乐观锁在更新数据时可能会因为版本冲突而失败,因此系统需要能够处理这种失败情况。如果系统能够容忍一定程度的失败(例如,通过重试或其他补偿机制来恢复),那么使用乐观锁是可行的。
三、乐观锁与悲观锁各自优缺点
3.1、悲观锁
优点:
- 数据一致性高:悲观锁认为冲突一定会发生,因此在数据处理前会先加锁,这样可以确保数据在任一时刻只被一个事务访问和修改,从而避免数据的不一致性和脏读。
- 简单易用:悲观锁的实现相对简单,只需要在操作数据前获取锁即可。
缺点:
- 性能开销大:悲观锁在操作数据前需要获取锁,如果有大量的并发操作,可能会导致性能问题,因为其他事务需要等待锁释放。
- 容易造成死锁:如果多个事务相互等待对方释放锁,可能会导致死锁的发生,影响系统的稳定性和可用性。
- 可能导致资源浪费:如果获取锁后长时间不释放,可能会导致其他事务无法操作数据,从而造成资源浪费。
3.2、乐观锁
优点:
- 高并发高吞吐:乐观锁不会阻塞其他事务的读取操作,只在提交时检查数据是否被修改,因此可以提供更好的并发性能。
- 无锁操作:乐观锁不需要显式地获取和释放锁,减少了锁竞争和上下文切换的开销。
- 无死锁风险:由于乐观锁不会阻塞其他事务的访问,因此不会出现死锁的情况。
缺点:
- 冲突处理复杂:由于乐观锁不会阻塞其他事务,因此在提交时需要检查数据是否被其他事务修改,如果发现冲突,需要回滚事务或重新尝试操作,这增加了冲突处理的复杂性。
- 数据一致性风险:乐观锁假设并发冲突较少,因此可能存在数据一致性的风险。如果多个事务同时对同一数据进行修改,可能会导致数据不一致的情况。
- 需要额外字段:为了实现乐观锁,通常需要在数据表中添加额外的版本号或时间戳字段,这增加了存储空间的需求。
- 处理不当造成死循环风险:在大多数业务中乐观锁更新失败都会进行自旋,如果没有控制好自旋退出逻辑可能会造成递归死循环问题。
四、乐观锁与悲观锁使用示例
这里举例会以操作数据库实现乐观锁与悲观锁示例,在实际开发中一般在操作数据库时经常会使用到乐观锁与悲观锁的实现思路来确保数据一致性问题,这里会以一个更新用户钱包举例。
用户钱包表
CREATE TABLE `customer_wallet` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `customer_id` bigint(20) DEFAULT NULL COMMENT '客户ID', `balance_amount` bigint(20) DEFAULT NULL COMMENT '剩余金额', `version` bigint(20) DEFAULT '1' COMMENT '版本锁', PRIMARY KEY (`id`) USING BTREE, KEY `idx_customer_id` (`customer_id`) ) COMMENT='客户钱包信息';
4.1、悲观锁实现更新用户钱包示例
这里提供一个使用悲观锁扣减钱包余额的示例,在第一次查询时添加for update
操作,那么其它线程进入该方法时则会阻塞等待上一个方法事务提交才能继续执行,在这整个方法中都是线程安全的,这就是常见的结合数据库实现悲观锁更新数据的示例,所有线程都必须排队串行更新数据。
@Transactional(rollbackFor = Exception.class) public boolean pessimisticLockSubAmount(Long customerId, Long happenAmount) { // 1、查询用户钱包 - 并且添加for update 锁,这里customer_id字段添加了索引最终锁定的还是索引定义行的ID,和直接使用ID区别不大 // 这段代码相当于 select * from customer_wallet where customer_id = ? for update CustomerWallet customerWallet = lambdaQuery() .eq(CustomerWallet::getCustomerId, customerId) .last("for update") .one(); if(customerWallet == null){ throw new RuntimeException("用户钱包不存在"); } // 2、校验用户余额是否足够 Long balanceAmount = customerWallet.getBalanceAmount() - happenAmount; if(balanceAmount < 0){ throw new RuntimeException("用户余额不足"); } // 3、更新钱包余额 update customer_wallet set balance_amount = ? where id = ? boolean update = lambdaUpdate() .eq(CustomerWallet::getId, customerWallet.getId()) .set(CustomerWallet::getBalanceAmount, balanceAmount) .update(); if(!update){ throw new RuntimeException("钱包更新失败"); } // 4、添加余额明细 addWalletDetail(customerWallet.getId(),2,happenAmount,balanceAmount); return update; }
4.2、乐观锁实现更新用户钱包示例1
使用乐观锁更新数据时,执行更新语句时通过判断version
是否有变动来确认数据是否有过变更,如果数据库当前version
值和查询出来的version
值相等则代表数据没有变更可以更新,因为数据库指定ID更新某一行数据时是在数据库层面会添加行锁,确保只能有一个事务进行这行数据更新,这样就保证了数据的一致性。
@Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class) public boolean subAmount(Long customerId, Long happenAmount) { // 1、获取用户钱包 CustomerWallet customerWallet = lambdaQuery().eq(CustomerWallet::getCustomerId, customerId).one(); if (customerWallet == null) { throw new RuntimeException("用户钱包不存在"); } // 2、判断用户余额是否足够 Long balanceAmount = customerWallet.getBalanceAmount() - happenAmount; if(balanceAmount < 0){ throw new RuntimeException("用户余额不足"); } // 3、进行乐观锁更新 // 这段代码相当于 update customer_wallet set balance_amount = ?, version = ? where id = ? and version = ? boolean update = lambdaUpdate() .eq(CustomerWallet::getId, customerWallet.getId()) .eq(CustomerWallet::getVersion, customerWallet.getVersion()) .set(CustomerWallet::getBalanceAmount, balanceAmount) .set(CustomerWallet::getVersion, customerWallet.getVersion() + 1) .update(); if(!update){ log.info("乐观锁更新失败,开始自旋"); return subAmount(customerId,happenAmount); } // 4、添加余额明细 addWalletDetail(customerWallet.getId(),2,happenAmount,balanceAmount); return update; }
PS:注意,在使用乐观锁更新数据时,事务隔离级别必须设置为READ_COMMITTED,在最后注意事项中会进行分析。
4.3、乐观锁实现更新用户钱包示例2
在乐观锁实现更新用户钱包示例1中使用了一个version
字段来作为乐观锁更新的标记,其实对于这种更新钱包的业务想使用乐观锁完全没有必要单独加一个version
字段,可以直接使用余额字段作为这个乐观锁的比较字段,因为我们这里拟定的是用户余额需要足够才能支付,那么在更新钱包时判断一下当前余额是否大于等于所需金额,如果满足调整则
@Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class) public boolean subAmountV2(Long customerId, Long happenAmount) { // 1、获取用户钱包 CustomerWallet customerWallet = lambdaQuery().eq(CustomerWallet::getCustomerId, customerId).one(); if (customerWallet == null) { throw new RuntimeException("用户钱包不存在"); } // 2、直接使用余额作为乐观锁更新依据进行乐观锁更新 // 这段代码相当于 update customer_wallet set balance_amount = balance_amount + ? where id = ? and balance_amount >= ? boolean update = lambdaUpdate() .eq(CustomerWallet::getId, customerWallet.getId()) .ge(CustomerWallet::getBalanceAmount, happenAmount) .setSql("balance_amount = balance_amount - "+happenAmount) .update(); if(!update){ log.info("乐观锁更新失败,用户余额不足"); throw new RuntimeException("用户余额不足"); } // 3、添加余额明细 注意这里需要从新查询一次数据 CustomerWallet customerWalletNew = lambdaQuery().eq(CustomerWallet::getCustomerId, customerId).one(); addWalletDetail(customerWallet.getId(),2,happenAmount,customerWalletNew.getBalanceAmount()); return update; }
PS:注意,在使用乐观锁更新数据时,事务隔离级别必须设置为READ_COMMITTED,在最后注意事项中会进行分析。
五、注意事项
在使用乐观锁更新数据时要注意一个事务隔离级别的问题,我这里使用的是READ COMMITTED
(读已提交),如果使用的是REPEATABLE READ
(可重复读)会存在两个问题,分别对应4.2 和 4.3中的示例。
- 在4.2示例中通过version值来实现乐观锁更新,如果这里使用的事务隔离级别为
REPEATABLE READ
(可重复读),那么在乐观锁冲突更新失败自旋时因为MVCC机制查询到的数据会是一个副本值,就算别的事务更新成功了读取到的version都是历史值,这样会导致死循环递归最后栈溢出。 - 在4.3示例中采用余额这样的字段进行判断更新,因为MySQL的更新数据采用的是当前读,这里其实无论使用
REPEATABLE READ
(可重复读)还是READ COMMITTED
(读已提交)事务隔离级别都不会存在死循环问题,但是如果锁冲突频繁在使用REPEATABLE READ
(可重复读)事务隔离级别时可能会出现锁持有时间过长问题,因为在REPEATABLE READ
事务隔离级别下,在一个事务中执行一个更新语句,就算where id=1 and balance_amount >= 100
这样的条件不成立,也会将这一行数据进行锁定,需要等待事务提交或回滚才会释放锁,也就是说在自旋时其它事务想要更新数据等待时间会变长影响系统吞吐量,而使用READ COMMITTED
事务隔离级别当where
中条件不成立更新失败时不会持有锁,也就是说事务A在更新失败自旋时事务B也是可以进行更新的,而不需要等待事务A自旋更新成功后才能进行更新,这样能提高系统吞吐量。
到此这篇关于java中乐观锁与悲观锁区别及使用场景分析的文章就介绍到这了,更多相关java 乐观锁与悲观锁内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!