前言
MySQL 的锁可以分成三类:总体、类型、粒度。
- 总体上分成两种:乐观锁和悲观锁
- 数据库管理上分成两种:读锁(共享锁或者S锁(Shared Lock))和写锁(排他锁或者X锁(Exclusive Lock))
- 锁的粒度上可以分成五种:表锁,行锁,页锁,间隙锁,临键锁
一、悲观锁
1、悲观锁概念
悲观锁:总是假设最坏的情况,每次获取数据的时候都认为别人会修改,所以每次在获取数据的时候都会上锁,这样别人想获取这个数据就会阻塞直到它拿到锁后才可以获取(共享资源每次只给一个线程使用,其它线程阻塞,当前线程用完后再把资源转让给其它线程)。
2、悲观锁实现
- Mysql数据库里就用到了很多这种锁机制,比如行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。
- Java 中 synchronized 、 Lock 以及ReentrantLock等锁也是悲观锁思想的实现。
3、悲观锁适用场景
- 高并发且数据竞争激烈的场景:当多个事务需要同时访问和修改同一份数据时,使用悲观锁可以确保数据在任一时刻只被一个事务访问和修改,从而避免数据的不一致性和脏读。
- 数据一致性要求极高的场景:对数据的一致性要求非常高,不允许出现任何的数据不一致或脏读现象。在这些场景中,使用悲观锁可以确保数据在任一时刻只被一个事务访问和修改,从而满足数据一致性的要求。
- 写操作频繁的场景:如果系统中写操作(如更新、删除等)远多于读操作(如查询),那么使用悲观锁可以更有效地保护数据,避免在写操作时被其他事务干扰。
- 事务执行时间较长的场景:当事务的执行时间较长时,使用悲观锁可以确保在该事务执行期间,数据不会被其他事务修改,从而避免数据的不一致性和脏读。
4、悲观锁优劣
优点:
- 数据一致性高:悲观锁认为冲突一定会发生,因此在数据处理前会先加锁,这样可以确保数据在任一时刻只被一个事务访问和修改,从而避免数据的不一致性和脏读。
- 简单易用:悲观锁的实现相对简单,只需要在操作数据前获取锁即可。
缺点:
- 性能开销大:悲观锁在操作数据前需要获取锁,如果有大量的并发操作,可能会导致性能问题,因为其他事务需要等待锁释放。
- 容易造成死锁:如果多个事务相互等待对方释放锁,可能会导致死锁的发生,影响系统的稳定性和可用性。
- 可能导致资源浪费:如果获取锁后长时间不释放,可能会导致其他事务无法操作数据,从而造成资源浪费。
5、悲观锁代码示例
在第一次查询时添加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;
}
二、乐观锁
1、乐观锁概念
乐观锁:是一种基于版本控制的并发控制机制。在乐观锁的思想中,认为数据访问冲突的概率很低,因此不加锁直接进行操作,但在更新数据时会进行版本比对,以确保数据的一致性。
乐观锁的原理主要基于版本号或时间戳来实现。在每次更新数据时,先获取当前数据的版本号或时间戳,然后在更新时比对版本号或时间戳是否一致,若一致则更新成功,否则表示数据已被其他线程修改,更新失败。
2、乐观锁实现
- 在Java中,常见的是使用Atomic类,例如AtomicInteger、AtomicLong等。这些类提供了原子操作,可以确保对共享资源的更新操作是原子性的,从而避免了锁的开销和线程等待,
- **CAS(Compare-And-Swap)**是实现乐观锁的核心算法,它通过比较内存中的值是否和预期的值相等来判断是否存在冲突。如果存在,则返回失败;如果不存在,则执行更新操作
- MySQL中的MVCC多版本控制就是乐观锁的一种实现方式
3、乐观锁的适用场景
- 写操作较少:在这种场景下,多个事务或线程大部分时间都在读取数据,而写操作的频率相对较低。乐观锁能够减少锁的持有时间,允许多个事务或线程同时读取数据,而不会相互阻塞。
- 数据冲突较少:如果数据更新操作之间的冲突较少,即多个事务或线程同时更新同一份数据的概率较低,那么乐观锁能够发挥很好的性能。因为即使偶尔出现冲突,也只是在更新数据时才会被检测到,而不需要在整个数据处理过程中都锁定资源。
- 重试成本较低:乐观锁在检测到冲突时会回滚事务或提示冲突,需要客户端重新尝试更新操作。因此,如果重试的成本较低(例如,重试不会导致大量计算或I/O操作),那么使用乐观锁是合适的。
- 容忍一定程度的失败:由于乐观锁在更新数据时可能会因为版本冲突而失败,因此系统需要能够处理这种失败情况。如果系统能够容忍一定程度的失败(例如,通过重试或其他补偿机制来恢复),那么使用乐观锁是可行的。
4、乐观锁的优劣
优点:
- 高并发高吞吐:乐观锁不会阻塞其他事务的读取操作,只在提交时检查数据是否被修改,因此可以提供更好的并发性能。
- 无锁操作:乐观锁不需要显式地获取和释放锁,减少了锁竞争和上下文切换的开销。
- 无死锁风险:由于乐观锁不会阻塞其他事务的访问,因此不会出现死锁的情况。
缺点:
- 冲突处理复杂:由于乐观锁不会阻塞其他事务,因此在提交时需要检查数据是否被其他事务修改,如果发现冲突,需要回滚事务或重新尝试操作,这增加了冲突处理的复杂性。
- 数据一致性风险:乐观锁假设并发冲突较少,因此可能存在数据一致性的风险。如果多个事务同时对同一数据进行修改,可能会导致数据不一致的情况。
- 需要额外字段:为了实现乐观锁,通常需要在数据表中添加额外的版本号或时间戳字段,这增加了存储空间的需求。
- 处理不当造成死循环风险:在大多数业务中乐观锁更新失败都会进行自旋,如果没有控制好自旋退出逻辑可能会造成递归死循环问题。
- ABA问题:当变量从A修改为B再修改回A时,变量值等于期望值A,但是无法判断是否修改,CAS操作在ABA修改后依然成功。
5、乐观锁代码示例
使用乐观锁更新数据时,执行更新语句时通过判断version是否有变动来确认数据是否有过变更,如果数据库当前version值和查询出来的version值相等则代表数据没有变更可以更新,因为数据库指定ID更新某一行数据时是在数据库层面会添加行锁,确保只能有一个事务进行这行数据更新,这样就保证了数据的一致性。
PS:注意,在使用乐观锁更新数据时,事务隔离级别必须设置为READ_COMMITTED,在最后注意事项中会进行分析
@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;
}