乐观锁和悲观锁

我们如何理解乐观锁和悲观锁?为什么要使用乐观锁和悲观锁?如何使用?

参考:微信公众号码农翻身的文章《乐观锁和悲观锁》,作者刘欣。

 

一、极端情况,没有锁会发生什么

(1)场景

旺财和小强生活在一个网上商城的系统中,是一对儿线程好基友。

星期一刚上班,旺财接到领导的电话,要把一个商品的库存减少20。旺财不敢怠慢,赶紧把库存取出来一看,哦,现在有1000个。

于此同时,小强也接到电话,要把同一商品的库存减少30。小强把库存取出来一看,哦,现在有1000个。

旺财计算出最新的库存值1000 – 20 = 980,保存!

小强计算出最新的库存值1000 – 30 = 970,保存!

旺财的数据直接被小强覆盖了!

领导一看,本来卖出去了50个商品,现在库存只扣了30个,这样持续下去整个商城业务就要完蛋了!

旺财和小强,各打20大板,涨涨记性!

(2)问题分析

回顾一下以上过程:

这是非常基础的同步问题,在java中,往往使用synchronized、Lock来解决。在数据库中,解决问题的思路也是一样的,就是使用乐观锁、悲观锁。

二、悲观锁

(1)场景

被殴打之后,小强说:“哥,要不我们想个办法吧,再这样下去要被领导打死啦!”

旺财悲催地说:“这样吧,以后我们每次访问数据库之前,都要先加锁,加了锁,就禁止别人再进入访问,只能等待持有锁的人来释放。”

星期二,领导让旺财再次把库存减少20。旺财这次超级小心,先把库存锁住,然后慢慢修改。

小强也接到了把库存减少的指令,但是旺财已经把库存锁住了,不能操作,小强只好去阻塞车间喝茶聊天,然后跑到就绪车间等待调度运行。

好不容易可以再次执行了,小强一看,这库存怎么还锁着呢!?只好再跑到阻塞车间喝茶。

领导一看就不乐意了,小强你怎么回事啊,不是在喝茶就是在聊天,还干不干活了?

小强争辩说,旺财一直锁着库存,根本没法操作,我也很无奈啊!

领导才不管这些东西,小强和旺财又被狂打20大板。

(2)问题分析

旺财想到的这种方式就是通常意义上的悲观锁了。

回顾一下以上过程:

再补充一个悲观锁在理想情况下的例子:

尝试从字面意义上去理解:我们有“被害妄想症”,对数据的“前途”非常悲观,看谁都是坏蛋,每次拿数据的时候,都觉得数据会被人更改。

所以,在每次拿数据的时候,要先给这条记录加锁,让别人无法访问(“无法访问”指的是无法修改、删除,读还是可以读到的(如果没有加for update))这条数据,直到我们把锁释放为止。

看上去是很安全了,但是悲观锁有些什么缺点呢?

如果线程A持有锁的时间太长,线程B和别的小伙伴就不得不等待一段时间,处理的效率就变低了。更严重的是,如果出现了死锁,线程A很可能永远不会释放锁,这会导致进入阻塞、就绪状态的线程越来越多,增加宕机的风险。

总之,如果使用悲观锁,就要有性能不佳的心理准备。对于并发要求高的应用,必须慎重考虑。

(3)具体实现

想要实现悲观锁,必须先对数据库的事务有所了解。这里以mysql作为例子,详情可以参考:http://www.runoob.com/mysql/mysql-transaction.html

在select的时候,只要加上一个for update,数据库就知道,你的这次查询是为了接下来的修改做准备,所以有必要为此加上一个锁。

我们首先开启一个事务,然后执行这条sql语句:

该sql会锁定表中所有符合检索条件(id = “xie4ever”)的记录。用for update把这条数据锁住后,我们可以自行更改,最后才把事务提交。在事务提交之前(事务提交时会释放事务过程中的锁),外界无法修改这些记录,这就保证了更新不会被覆盖。

这里补充一点,使用悲观锁的时,往往会遇到这样的问题:明明已经使用for update锁了行,但还是能够读取到该行数据,并没有实现读等待。

这是因为悲观锁只会把所有写了for update的语句放入同步队列,没有写for update的语句不会受到影响。

举个例子,存在这三条sql语句:

语句A/语句C执行时,语句B可以并发执行。这是因为不带for update的select不需要获取锁,也就不存在同步的问题。但是,如果语句B的目的是修改、删除,执行前就要获取锁,也就无法并发执行了。

语句A、语句C两者之间无法并发执行。这是因为他们都需要竞争符合检索条件(id = “xie4ever”)的记录的锁。

总之,如果要使用悲观锁,必须注意sql中的for update、事务提交之类的问题。

三、乐观锁

(1)场景

自从屁股又一次被打烂之后,旺财发愤图强,想到了一种新的解决方案。

旺财说:“兄弟,这一次对不住你了,处理得慢了一些。不过我又想到了一个好办法:乐观锁。”

小强说:“呷屎啦你,屁股都烂了你还乐观?”

旺财:“你听我说嘛,我们在那个库存字段的旁边,再加上一个版本(version)字段。例如刚开始的时候(库存 = 1000,版本 = 1),每次你去读的时候不仅要读出库存,还要读出版本号。等到你修改了库存,往回写的时候一定要检查一下版本号,看看是否和你读的时候一样。”

小强:“如果不一样呢?”

旺财:“那就说明在操作期间,该数据已经被改动了。那就放弃这次写的操作,重新读取库存和版本号,重新来过”。

小强:“如果一样呢?”

旺财:“那就说明在操作期间,数据没有被改动。可以放心大胆地把新的库存值写回去,把版本号也加1”。

小强:“我好像有点明白了,我们试试。不过你要想周全,我可不想再挨板子了。”

星期三,旺财奉命把库存减去30,他先读到了(库存 = 1000 ,版本 = 1)。小强也要改库存了,他要把库存减去50,于是他也读到了(库存 = 1000 ,版本 = 1)。

旺财计算出新的库存值1000 – 30 = 970,写回成功,版本号+1。现在版本变成了(库存 = 970,版本 = 2).

小强也计算出了新的库存值950,也准备写回。他战战兢兢地去看最新的版本号,已经变成版本2了,按照之前的约定,小强只好重新读取,重新操作。

小强再次读取(库存 = 970 ,版本 = 2),计算出最新缓存值970 – 50 = 920,再次视图更新。没想到又被人抢了先,现在版本号已经变成了3,最新的数据是(库存 = 900,版本 = 3)。

唉,小强只好重新来过,计算出最新的缓存值900 – 50 = 850,第三次终于更新成功了,版本号+1,最新的库存是(库存 = 850,版本 = 4)。

问题顺利得到了解决,旺财和小强终于能够愉快地合作了。

(2)问题分析

这次的实现就是通常意义上的悲观锁了。

回顾一下以上过程:

尝试从字面意义上去理解:这次我们变得很乐观了,觉得大家都是好人,一般情况下不会有太多人修改内存,所以没有加锁,放心地去操作。只有在最后更新的时候才去看看是否冲突,如果冲突了,重新做一次以上的步骤。

安全是实现了,但是乐观锁有些什么缺点呢?

有没有发现,本例中的线程B是个很苦逼的角色?线程B明明努力了这么多次,却是最后一个成功的…这又是一个“先来先”的惨案,学过操作系统的同学都知道,抢不到cpu资源的进程往往会被饿死,如果竞争再激烈一点,本例中的线程B也很有可能会被饿死。

和悲观锁不同的是,悲观锁只是线程被阻塞了,大不了继续等待下去。但是乐观锁会一直不停地尝试,每次尝试都需要耗费系统资源,可能严重消耗系统性能。

所以,如果数据的争用很激烈,使用乐观锁反而会较低性能。

(3)具体实现

乐观锁的实现和java中的自旋锁有点像,都是不成功就重复执行,直到成功为止。

大概实现一下,首先执行sql,查出库存和版本号:

更新时,需要校验版本号、更新版本号:

上面所有数据处理放在一个for循环/while循环中,像自旋锁的实现原理一样。也可以做一些特殊的处理,比如自旋多少次之后优先处理/自己实现公平模式,等待时间最长的线程优先处理(难度似乎有点大,要研究一下java并发中公平锁的源码)。

四、总结

乐观锁和悲观锁是相当重要的概念,可以引申出很多不同解决方案。有兴趣的同学可以多多尝试。

发表评论

电子邮件地址不会被公开。 必填项已用*标注