悲观锁与乐观锁的实现(详情图解)含面试专题及答案

一、前言

在了解悲观锁和乐观锁之前,我们先了解一下什么是锁,为什么要用到锁?

技术来源于生活,锁不仅在程序中存在,在现实中我们也随处可见,例如我们上下班打卡的指纹锁,保险柜上的密码锁,以及我们我们登录的用户名和密码也是一种锁,生活中用到锁可以保护我们人身安全(指纹锁)、财产安全(保险柜密码锁)、信息安全(用户名密码锁),让我们更放心的去使用和生活,因为有锁,我们不用去担心个人的财产和信息泄露。

而程序中的锁,则是用来保证我们数据安全的机制和手段,例如当我们有多个线程去访问修改共享变量的时候,我们可以给修改操作加锁(syncronized)。当多个用户修改表中同一数据时,我们可以给该行数据上锁(行锁)。因此,当程序中可能出现并发的情况时,我们就需要通过一定的手段来保证在并发情况下数据的准确性,通过这种手段保证了当前用户和其他用户一起操作时,所得到的结果和他单独操作时的结果是一样的

没有做好并发控制,就可能导致脏读、幻读和不可重复读等问题,如下图所示:

由于并发操作,如果没有加锁进行并发控制,数据库的最终的一条数据可能为3也有可能为5,导致数值不准确

二、悲观锁和乐观锁

首先我们需要清楚的一点就是无论是悲观锁还是乐观锁,都是人们定义出来的概念,可以认为是一种思想。

2.1、悲观锁

悲观锁(Pessimistic Lock): 就是很悲观,每次去拿数据的时候都认为别人会修改。所以每次在拿数据的时候都会上锁。这样别人想拿数据就被挡住,直到悲观锁被释放,悲观锁中的共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

但是在效率方面,处理加锁的机制会产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,如果已经锁定了一个线程A,其他线程就必须等待该线程A处理完才可以处理

数据库中的行锁,表锁,读锁(共享锁),写锁(排他锁),以及syncronized实现的锁均为悲观锁

悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证,

2.2、乐观锁

乐观锁(Optimistic Lock): 就是很乐观,每次去拿数据的时候都认为别人不会修改。所以不会上锁,但是如果想要更新数据,则会在更新前检查在读取至更新这段时间别人有没有修改过这个数据。如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功(当然也允许更新失败的线程放弃操作),乐观锁适用于多读的应用类型,这样可以提高吞吐量

相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本(version)或者是时间戳来实现,不过使用版本记录是最常用的

乐观控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。

三、锁的实现

悲观锁阻塞事务、乐观锁回滚重试:它们各有优缺点,不要认为一种一定好于另一种。像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行重试,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。

3.1 悲观锁的实现方式

场景:

有用户A和用户B,在同一家店铺去购买同一个商品,但是商品的可购买数量只有一个

下面是这个店铺的商品表t_goods结构和表中的数据:

在不加锁的情况下,如果用户A和用户B同时下单,就会报错。

悲观锁的实现,往往依靠数据库提供的锁机制,在数据库中,我们如何用悲观锁去解决这个事情呢?

加入当用户A对下单购买商品(臭豆腐)的时候,先去尝试对该数据(臭豆腐)加上悲观锁

加锁失败:说明商品(臭豆腐)正在被其他事务进行修改,当前查询需要等待或者抛出异常,具体返回的方式需要由开发者根据具体情况去定义

加锁成功:对商品(臭豆腐)进行修改,也就是只有用户A能买,用户B想买(臭豆腐)就必须一直等待。当用户A买好后,用户B再想去买(臭豆腐)的时候会发现数量已经为0,那么B看到后就会放弃购买

在此期间如果有其他对该数据(臭豆腐)做修改或加锁的操作,都会等待我们解锁后或者直接抛出异常


那么如何加上悲观锁呢?我们可以通过以下语句给id=2的这行数据加上悲观锁,首先关闭MySQL数据库的自动提交属性。因为MySQL默认使用autocommit模式,也就是说,当我们执行一个更新操作后,MySQL会立刻将结果进行提交,(sql语句:set autocommit=0)

悲观锁加锁sql语句: select num from t_goods where id = 2 for update

我们通过开启mysql的两个会话,也就是两个命令行来演示:

事务A:

我们可以看到数据是立刻马上就可以查询出来,num=1

事务B:
我们是可以看到,事务B会一直等待事务A释放锁。如果事务A长期不释放锁,那么最终事务B将会报错,报错如下:
Lock wait timeout exceeded; try restarting transaction,表示语句已被锁住


现在我们让事务A执行命令去修改数据,让臭豆腐的数量减一,然后查看修改后的数据,最后commit,结束事务

我们可以看到当我们事务A执行完成之后,臭豆腐的库存只有0个了,这个时候我们用户B再来购买这个臭豆腐的时候就会发现,最后一个臭豆腐已经被用户A购买完了,那么用户B只能放弃购买臭豆腐了。


通过悲观锁我们可以解决因为商品库存不足,导致的商品超出库存的售卖。

3.1 乐观锁的实现方式

对于上面的应用场景,我们应该怎么用乐观锁去解决呢?在上面的乐观锁中,我们有提到使用版本号(version)来解决,所以我们需要在t_goods加上版本号,调整后的sql表结构如下:

具体操作步骤如下:

1、首先用户A和用户B同时将臭豆腐(id=2)的数据查出来

2、然后用户A先买,用户A将(id=1和version=0)作为条件进行数据更新,将数量-1,并且将版本号+1。此时版本号变为1。用户A此时就完成了商品的购买

3、 用户B开始买,用户B也将(id=1和version=0)作为条件进行数据更新

4、更新完后,发现更新的数据行数为0,此时就说明已经有人改动过数据,此时就应该提示用户B重新查看最新数据购买

1、首先我们开启两个会话窗口,输入查询语句:select num from t_goods where id = 2

事务A:

事务B:

这个时候事务A和事务B同时获取相同的数据

2、此时事务A进行更新数据的操作,然后在查询更新后的数据

这个时候我们可以看到事务A更新成功,并且库存-1 版本号+1成功

2、此时事务B进行更新数据的操作,然后在查询更新后的数据

可以看到最终修改的时候失败,数据没有改变。此时就需要我们告知用户B重新处理

3.1.1 CAS

说到乐观锁,就必须提到一个概念:CAS

什么是CAS呢?Compare-and-Swap,即比较并替换,也有叫做Compare-and-Set的,比较并设置。

1、比较:读取到了一个值A,在将其更新为B之前,检查原值是否仍为A(未被其他线程改动)。

2、设置:如果是,将A更新为B,结束。[1]如果不是,则什么都不做。

上面的两步操作是原子性的,可以简单地理解为瞬间完成,在CPU看来就是一步操作。

有了CAS,就可以实现一个乐观锁,允许多个线程同时读取(因为根本没有加锁操作),但是只有一个线程可以成功更新数据,并导致其他要更新数据的线程回滚重试。 CAS利用CPU指令,从硬件层面保证了操作的原子性,以达到类似于锁的效果。

Java中真正的CAS操作调用的native方法

因为整个过程中并没有“加锁”和“解锁”操作,因此乐观锁策略也被称为无锁编程。换句话说,乐观锁其实不是“锁”,它仅仅是一个循环重试CAS的算法而已,但是CAS有一个问题那就是会产生ABA问题,什么是ABA问题,以及如何解决呢?

ABA 问题:

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。

ABA 问题解决:

我们需要加上一个版本号(Version),在每次提交的时候将版本号+1操作,那么下个线程去提交修改的时候,会带上版本号去判断,如果版本修改了,那么线程重试或者提示错误信息~

四、如何选择

悲观锁阻塞事务,乐观锁回滚重试,它们各有优缺点,不要认为一种一定好于另一种。像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去锁的开销,加大了系统的整个吞吐量。

但如果经常产生冲突,上层应用会不断的进行重试,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。

注意点:

1、乐观锁并未真正加锁,所以效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。

2、悲观锁依赖数据库锁,效率低。更新失败的概率比较低。

面试题及答案

(1)乐观锁和悲观锁概念介绍

介绍乐观锁和悲观锁,可以先理解一下,乐观、悲观两个概念。

乐观锁对应于生活中乐观的人总是想着事情往好的方向发展。

悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。

乐观锁和悲观锁是两种思想,不局限在某种编程语言或者数据库。

乐观锁概念

乐观锁在操作数据的时候,非常乐观,都认为别人不会同时修改数据,所以乐观锁在更新数据之前,都不会对数据进行加锁,只有当执行更新数据操作时候,再去判断数据是否被修改,如果数据被修改了,就放弃被当前修改操作。

悲观锁概念

悲观锁在操作数据时候,比较悲观,都认为别人会和自己同时修改数据,所以悲观锁操作数据时候,会直接给数据上锁,不让别人操作,只有自己操作完成后,才释放锁。

(2)乐观锁和悲观锁使用场景

乐观锁使用场景

乐观锁适用于读多写少的情况下,即:读数据多余写数据的时候,可以考虑使用乐观锁。

注意:乐观锁本身是不加锁的,只是会在更新数据时候判断数据是否变化。

悲观锁使用场景

悲观锁适用于写多读少的情况下,即:需要频繁的写数据时候,可以考虑使用悲观锁。

(3)乐观锁和悲观锁实现方式

乐观锁实现方式

乐观锁有两种实现方式:

CAS机制

版本号机制

《1》CAS机制

CAS有三个操作数:

1) 需要读写的内存位置(V)

2) 进行比较的预期值(A)

3) 拟写入的新值(B)

如果内存位置V的值等于预期的A值,则将该位置更新为新值B,否则不进行任何操作。

许多的CAS是自旋的,也就说如果一次不成功,它会一直循环去判断,直到成功为止。

自旋CAS就会占用系统资源,比较耗费CPU的开销。

CAS会导致ABA问题。

ABA问题是指:

线程1修改数据后值等于A,线程2修改数据后值变成B,线程2再次修改数据后值变成A,这样,CAS检查时候,发现修改前后的值是一样的,就认为没有修改,所以CAS操作成功。

《2》版本号机制

版本号机制,是在数据库中添加一个version字段,用于标识哪个版本。每当修改一次数据时候,会将版本号加1。

当查询数据时候,将数据中的版本号一起查询出来。

接着修改数据,准备提交更新后的数据到数据库时候,再次查询数据库中版本号。

判断当前查询的版本号是否和第一次查询的一样,如果一样,则进行操作,否则重试。

悲观锁实现方式

悲观锁实现,就是通过加锁。

可以对代码块加锁,也可以对数据加锁。

Java中可以使用synchronized同步代码块。

数据库中可以使用排它锁。

总结

****************************************************************************************************


#Java开发##笔试题目##实习##offer比较##笔经##Java##MySQL##学习路径#
全部评论
感谢楼主分享的悲观锁与乐观锁的实现,知识点很多。
点赞
送花
回复
分享
发布于 2022-04-09 15:20

相关推荐

点赞 6 评论
分享
牛客网
牛客企业服务