[锁]:乐观锁、悲观锁与死锁

摘要

摘要:乐观锁;悲观锁;实现方法;本地锁;分布式锁;死锁;行级锁;表级锁

1 锁的相关概念

1.1 为什么需要锁?

问题

  • ① 在多个线程访问共享资源时,会发生线程安全问题,例如:在根据订单号生成订单时,若用户第一次由于某种原因(网络连接不稳定)请求失败,则会再次发生请求,此时便会产生同一订单号生成多个订单,这显然是有问题的。

解决

  • ① 针对上述问题,我们有一个解决思想,给用户第一次的请求加锁,只有当前第一次请求拥有锁,请求线程在拥有锁时,方可执行,其他线程必须在拥有锁的线程执行完毕后,方可执行。

1.2 本地锁

问题

  • ① 目前的系统架构,大体分为两类:一类是单体架构,另一类是分布式架构(在分布式架构中为保障系统的高可用,我们又会搭集群),
  • ② 针对上述两类架构的特点,锁又分成两种不同的类别:一类是针对单体架构的锁,称之为本地锁,另一类是针对分布式架构的锁,称之为分布式锁。
  • ③ 针对本地锁,又有两类:一类是在高并发场景下,编程语言实现对自己多线程控制的本地锁,诸如:Java语言中synchronized、Lock本地锁(同时也是悲观锁),另一类是在数据库中实现的锁思想,诸如:乐观锁、悲观锁、共享锁、排它锁、记录锁、间隙锁、表锁等本地锁,其都可以称之为本地锁,保证业务数据的准确性。

解决

  • ① 本地锁:针对单体架构项目高并发特点,有两类解决方案:一类是语言自己实现的,例如Java语言的synchronized、Lock锁,另一类是在数据库中实现的锁思想,例如乐观锁与悲观锁,本文也着重于此两点说明。
  • ② 分布式锁:由于编程语言自己实现的锁,无法满足在分布式架构中多链路调用情况,因而出现分布式锁的思想他的解决主要有:Redisson、zookeeper、数据库(数据库性能低,使用场景少),详情请参阅另一篇文章:[分布式锁]:Redis与Redisson

2 乐观锁与悲观

通俗理解:乐观锁,对一件事持乐观态度,认为大概率不会发生;悲观锁,对一件事持悲观态度,认为大概率会发生。

2.1 乐观锁

2.1.1 乐观锁的概念

概念:认为大概率不会发生线程安全问题。

2.1.2 乐观锁的解决思想

解决思想:通过在数据上添加标识(如版本号或时间戳)来进行并发控制,实现线程安全的共享数据访问。

2.1.2.1 数据版本号机制思想

① 首先,给数据库添加一个字段version(int)的标记字段,
② 随后,当多个线程同时访问数据库时,都会获得version的值,
③ 然后,在提交更新时若刚才读取到的version为当前数据库中中version值时才更新,伴随着更新过后version的值也会发生变化,
④ 最后,当其他线程需要提交更新时,获取到的version值和当前数据库version值不一样,提交更新失败,从而实现对线程安全的控制。

2.1.2.1.1 数据版本号机制实现——基于mybatis

引入业务场景:假设数据库中账户有一version字段(值为1),且当前账户余额balance字段(值为100)

  • ① 操作员A此时将其读出(此时version=1),并从账户余额中扣除50(100-50),
  • ② 操作员A操作的同时,操作员B也读出此账户信息(此时version=1),并从账户余额扣除20(100-20),
  • ③ 操作员A先完成了修改工作,并且,将数据版本号(version=1)和账户扣除后余额(balance=50),提交至数据库更新,此时由于提交数据版本=当前数据库记录版本,数据被成功更新,数据版本更新为2(version=2),
  • ④ 操作员B完成操作后,也将读出到的数据版本(version=1)和账户扣除后余额(balance=80),提交至数据库请求更新,此时数据库数据版本已经被更新(version=2),不满足数据版本号相同时,才能更新数据的策略,因此操作员B请求被驳回。从而保证数据的的准确。
2.1.2.1.1.1 实体类中添加响应字段,并设定当前字段用于记录数据的版本信息
@Configuration
public class MpConfig {
    @Bean
    public MybatisPlusInterceptor mpInterceptor() {
        //1.定义Mp拦截器
        MybatisPlusInterceptor mpInterceptor = new MybatisPlusInterceptor();

        //2.添加乐观锁拦截器
        mpInterceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        
        return mpInterceptor;
    }
}

2.1.2.1.1.2 使用乐观锁前必须先获取对应数据版本号

注意:由于在使用乐观锁时需要跟数据库频繁进行交互,因而在高并发场景下,建议使用分布式锁和悲观锁(是的,乐观锁也可以作为分布式锁来使用)。

@Test
public void testUpdate() {
    /*User user = new User();
    user.setId(3L);
    user.setName("Jock666");
    user.setVersion(1);
    userDao.updateById(user);*/
    
    //1.先通过要修改的数据id将当前数据查询出来
    //User user = userDao.selectById(3L);
    //2.将要修改的属性逐一设置进去
    //user.setName("Jock888");
    //userDao.updateById(user);
    
    //1.先通过要修改的数据id将当前数据查询出来
    User user = userDao.selectById(3L);     //version=3
    User user2 = userDao.selectById(3L);    //version=3
    user2.setName("Jock aaa");
    userDao.updateById(user2);              //version=>4
    user.setName("Jock bbb");
    userDao.updateById(user);               //verion=3?条件还成立吗?
}

2.1.2.2 CAS算法思想
  • 实现思想:CAS算法基于ccompare-and-swap(比较和交互)操作,类似于Junit的断言机制,其通过比较当前值和期望值的方式,实现乐观锁的并发控制机制。
  • 使用说明:在使用时,先读取数据的原值,根据规则计算出新的期望值,随后,使用CAS操作把期望值写入数据的存储位置,若操作成功,说明没有发生冲突,更新操作可以提交,否则,操作失败,需要重新读取数据并重复以上操作。
  • 问题:一方面是数据库性能问题,另一当面是ABA问题。
  • ABA问题:当前有ABC三个线程,初始数据版本号为V1,线程AB分别查询到该数据的版本号是V1,线程A先更新数据,把版本改为A2,然后线程C也执行一次操作把版本号从V1更改成V3随后又改回V1,此时当线程B更新时发现数据版本匹配,更新操作成功,但实际数据已经被C修改,因此为避免此问题又需借助时间戳、版本号机制来解决。

2.2 悲观锁

2.2.1 悲观锁的概念

悲观锁:认为大概率会发生线程安全问题。

2.2.2 悲观锁的解决思想

悲观锁的核心思想:在操作共享数据之前对其进行加锁,保证同一时刻只有一个线程可以访问,避免数据的并发修改和读取。

2.2.3 悲观锁的实现方式

2.2.3.1 基于数据库机制

基于数据库机制,诸如:行级锁和表级锁等,通过在数据库上对共享数据进行加锁,保证数据的一致性和完整性。

2.2.3.1.1 行级锁

行级锁:是在对数据库表进行操作第,对操作进行的行加锁,即SQL语句在修改每行记录时,只会锁定该行数据,不会影响其他行,其他记录仍可被修改,行级锁可以有效提高并发性,然而由于加锁粒度小,因此在短事务(诸如简单的update、delete、insert操作)并发场景下会影响性能。

  • 如:在MySQL的InnoDB中提供的就是行级锁。
  • 注:长事务(占用锁的粒度比较大,时间长,通常会涉及多个表,多次修改,对数据库影响性能较大)
2.2.3.1.2 表级锁

表级锁:MySQL中MyISAM存储引擎使用的是表级锁,当一个事务对一个表进行操作时,MyISAM会给整个表加锁,其它事务无法对该表进行读取或修改操作,直至锁释放,然而由于加锁粒度大,因此会带来性能上的损失,不适用于并发更新操作比较多的场景。

2.2.3.2 基于应用层面的锁机制

基于应用层面的锁机制,如synchronized锁和lock锁等,通过在代码层面对共享数据进行加锁,实现数据的并发控制。

2.2.3 悲观锁缺点

2.2.3.1 性能瓶颈

性能瓶颈:需要早操作共享数据前加锁,阻塞其它线程对数据的访问,造成性能瓶颈。

2.2.3.1 死锁问题

死锁题锁:在锁定期间可能会出现死锁问题,即在加锁操作过程中出现异常或造成线程长时间未释放,就可能发生死锁问题,导致应用程序崩溃。

2.3 死锁

2.3.1 死锁的概念

死锁:两个或多个进程,因相互申请对方占用的资源而造成互相等待的现象,导致所有进程都在等待彼此释放资源而无法继续向前推荐,最终都无法完成任务。

2.3.2 死锁出现原因

死锁出现原因:由于多个线程或进程在运行过程中,因相互申请对方占用资源而造成互相等待的现象,从而,出现一种无法解决的状态。

案例:

  • 两个线程AB同时占用一些资源,现在A需要获取B占用资源,而B同时需要获取A占用资源,两个线程都不释放已占用资源,从而造成死锁状态。

2.3.3 死锁解决方法

  • ① 预防死锁:通过合理资源申请和释放策略避免死锁发生。
  • ② 检测死锁:采用算法检测死锁状态,并及时采取相应措施,诸如撤销一些进程、杀掉进程等。
  • ③ 解除死锁:采用一定策略释放资源、终止进程等以打破死锁状态。
  • ④ 避免死锁:从资源分配角度出发,在分配每一个资源时都要判断是否会出现死锁,若会出现,则不分配资源。
  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值