悲观锁与乐观锁的实现 (详情图解)
一、前言
在了解悲观锁和乐观锁之前,我们先了解一下什么是锁,为什么要用到锁?
技术来源于生活,锁不仅在程序中存在,在现实中我们也随处可见,例如我们上下班打卡的指纹锁,保险柜上的密码锁,以及我们我们登录的用户名和密码也是一种锁,生活中用到锁可以保护我们人身安全(指纹锁)、财产安全(保险柜密码锁)、信息安全(用户名密码锁),让我们更放心的去使用和生活,因为有锁,我们不用去担心个人的财产和信息泄露。
而程序中的锁,则是用来保证我们数据安全的机制和手段,例如当我们有多个线程去访问修改共享变量的时候,我们可以给修改操作加锁(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、悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
五、总结
这篇文章讲解了悲观锁与乐观锁的区别,以及实现场景,不管是悲观锁还是乐观锁都是人们定义出来的概念,是一种思想,如何有有疑问或者问题的小伙伴可以在下面进行留言,看到了会第一时间回复大家,谢谢,大家加油~
推荐阅读
阿里架构师【柏羲】带你揭秘架构项目实战与源码解读:微博+B站架构设计、JUC核心、Mybatis源码
看完三件事
如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:
点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。
关注公众号 『 Java 斗帝 』,不定期分享原创知识。
同时可以期待后续文章 ing🚀
评论