🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
# 什么是锁? 如果从日常生活中理解什么是锁,很好理解,每个人家门上都有锁,用来防止他人窃取自家财产。但是在计算机中,锁的概念稍有不同,在计算机中只有涉及到资源竞争的时候,才会用到锁。 比如在单线程中,不需要用到锁,资源都是顺序化被持有,不存在竞争。但是在多线程中,同时会有多个请求需要同一个资源,这个时候,就需要进行加锁操作,一个线程获取到锁之后,其他的线程只有等待资源被释放才能接着执行。 # 锁的作用 锁本质是为了保证串行,比如在购买订单的时候,同时涌入大量的请求,如何保证商品不多卖以及不少卖,保证数据的准确性,这个时候就要用锁来控制并发,让本来并行执行的问题转换为串行执行。 # 锁的分类 说到锁的分类,在各种文章以及研究中都提供了不同的分类,比较繁杂,但是如果从思想上来说,总体来说分为两类,一类是悲观锁,一类是乐观锁。 # 乐观锁 乐观锁是相对于悲观锁而言,乐观锁机制采用了更加宽松的加锁机制。乐观锁字如其名,就是持有比较乐观的态度。就是假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则给用户返回错误信息,让用户决定如何去做。 乐观锁的实现也比较简单,就是使用数据版本version记录机制实现。接下来,我们利用mysql数据库来实现一下乐观锁,但是这里要特别注意,第一点,mysql本身并没有提供实现乐观锁,而且也没有乐观锁这个概念,mysql的锁都是悲观锁,那么我们就懂了,乐观锁是一种思想,使用其他的所有方式都可以实现,只要实现了这个思想的,都叫乐观锁。比如可以使用文件,使用redis都可以实现。 我们首先建立一张表叫good\_num货物表。 创建表的语句如下所示 ~~~ CREATE TABLE `good_nums` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) DEFAULT NULL, `nums` int(11) DEFAULT NULL, `version` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ~~~ | 字段 | 描述 | | --- | --- | | id | 自增ID | | name | 名字 | | nums | 数量 | | version | 版本 | 接下来我们给表增加一条记录。 ~~~ INSERT INTO good_amount (name,nums,version) VALUES('张三',100,1) ~~~ 目前记录如下所示: | id | name | nums | version | | --- | --- | --- | --- | | 1 | 张三 | 100 | 1 | OK,基本情况我们已经构建完成。 那么现在我们假定有这么一个需求,用户每买一笔,就要把数量减少1个。理想情况下,如果用户一个一个来去购买的话,我们的数量会按照情况去一个一个减少。但是这仅仅是理想情况,实际情况是,用户会同时涌入来购买,在这种情况下,数量的减少根本无法保障。 我们看一下在未使用锁的情况下,是如何实现程序的,这也是初级程序员最常写的代码如下所示: ~~~ <?php //连接数据库 $conn = new mysqli_connect(xxxxx); //查询当前的数量 $sql = "SELECT * from good_nums where id =1"; $source = $conn->query($sql); $row = $source->fetch_assoc(); if($row['nums'] <= 0){ echo "很遗憾,商品被抢光了"; exit; } //将当前的数量减少1 $nums = $row['nums'] - 1; $sql1 = "update good_nums set nums=".$nums." where id=1"; $source1 = $conn->query($sql1); ~~~ 通过上面的代码可以很明显的理解,当大量的请求同时涌来的时候,程序在同一时间可能同时读到当前数量为100,那么每个用户可能都抢到了物品,但是数据库记录数到最后居然是99,然而此时可能已经卖了1千个了。此时就发生了超卖的问题。 那么如果此时使用乐观锁,就很容解决这个问题了。代码如下所示: ~~~ // 连接数据库 $conn = new mysqli(xxxx); //查询当前的数量 $sql = "SELECT * from good_nums where id =1"; $source = $conn->query($sql); $row = $source->fetch_assoc(); if($row['nums'] <= 0){ echo "很遗憾,商品被抢光了"; exit; } //给当前的数量减少1 $nums = $row['nums'] - 1; //当前的版本 $version = $row['version']; //更新数据库的数量 $sql1 = "update good_nums set nums=".$nums.",version=version+1 where id=1 and version=".$version; $source1 = $conn->query($sql1); if(!$source1){ echo "您未抢到本商品,请继续努力"; } $conn->close(); ~~~ 可以对比现在的程序,比之前的程序多增加了一个version的条件控制,对的,这就是乐观锁的精髓所在。 我们现在来模拟一下用户的请求。 当第一个用户涌进来,他拿到的货物数量为100,此时拿到的version为1。 当第二个用户涌进来,他此时拿到的货物数量也为100,此时的version为1。 还有第三个,第四个,都是同样的情况。 此时,第一个用户的请求去更新数据库,他更新的条件,是version必须等于刚刚拿到1,此时数据库的version还未更新,于是他将这条记录更新成功,nums数量成功减少为99,并且于此同时,将version更新为2。 那么第二个用户也去执行这个条件,数据库此时去检查发现version居然不是1了,于是这个条件不成立,此时这条sql就会执行失败,然后告诉用户你没有抢到。 一直第三个,第四个用户都是如此。 只有第n个用户可能拿到新的version,并且可能成功更新。 上面就是乐观锁的完整实现,当前了,上面我们写的程序还有问题,下面是一个正确的程序。 ~~~ // 连接数据库 $conn = new mysqli(xxxx); //查询当前的数量 $sql = "SELECT * from good_nums where id =1"; $source = $conn->query($sql); $row = $source->fetch_assoc(); if($row['nums'] <= 0){ echo "很遗憾,商品被抢光了"; exit; } //当前的版本 $version = $row['version']; //更新数据库的数量 $sql1 = "update good_nums set nums=nums-1,version=version+1 where id=1 and version=".$version; $source1 = $conn->query($sql1); if(!$source1){ echo "您未抢到本商品,请继续努力"; } $conn->close(); ~~~ 仔细对比就会发现,之前我们是用程序去减少的数量1,但是现在的代码是让数据库去自动减少1。这个也是比较关键的一点,在高并发的情况下,请记住一定要如此写,不然可能会发生不可想像的错误。 在很多时候,如果面试官继续深究,他就会问你,如果大量的用户同时涌入,上面的程序只能保证少数人能拿到商品啊,可能100个用户同时涌入,到最后,只卖了10个,但是公司又想这100都卖掉,这个时候咋办? 这个时候我们就引入了自旋锁的概念。这个概念如果第一次听说,就会有点蒙,啥是自旋,怎么自旋,好,带着这个问题,我们回到刚刚100个人来抢,可能只卖了10个问题。 我们想,既然用户会发生更新失败的问题,我们为啥不如让用户等待一下,重新获取一遍新的值,然后让用户抢到呢?对的,这就是自旋锁了。 程序如下: ~~~ // 连接数据库 $conn = new mysqli(xxxx); $tips = true; while($tips){ //查询当前的数量 $sql = "SELECT * from good_nums where id =1"; $source = $conn->query($sql); $row = $source->fetch_assoc(); if($row['nums'] <= 0){ echo "很遗憾,商品被抢光了"; $tips = false; exit; } //当前的版本 $version = $row['version']; //更新数据库的数量 $sql1 = "update good_nums set nums=nums-1,version=version+1 where id=1 and version=".$version; $source1 = $conn->query($sql1); //当数量更新失败了 if($source1 == true){ $tips = fasle; } } echo "恭喜您抢到了商品"; $conn->close(); ~~~ 我们看到,首先我们会给一个标识位为true,就是只有不满足条件的时候才退出,也就是只有用户抢到了,才结束掉本次请求。如果没有抢到,程序会不断的请求,让当前的用户去抢,这就是自旋锁,实际上,就是用了一个循环,不端的去请求,当然了,也可以用递归,但是本质都是相同的,都是循环。 但是上面的程序有个问题,如果用户一直没有抢到,程序就会一直执行,如果请求数量巨大,就会发生大量的timeout,我们能不能像个办法,提前结束掉循环呢?程序如下: ~~~ // 连接数据库 $conn = new mysqli(xxxx); $tips = true; $count = 5; while($tips){ //查询当前的数量 $sql = "SELECT * from good_nums where id =1"; $source = $conn->query($sql); $row = $source->fetch_assoc(); if($row['nums'] <= 0){ echo "很遗憾,商品被抢光了"; $tips = false; exit; } //当前的版本 $version = $row['version']; //更新数据库的数量 $sql1 = "update good_nums set nums=nums-1,version=version+1 where id=1 and version=".$version; $source1 = $conn->query($sql1); //当数量更新失败了 if($source1 == true){ $tips = fasle; } $count--; if($count <= 0){ $tips = false; } } echo "恭喜您抢到了商品"; $conn->close(); ~~~ 上面的程序,我们给程序增加了一个count值,当程序执行超过了限定,我们就会释放掉本次循环,需要说明的是,这不是自旋锁的概念,只是为了优化请求,才这么写的,为的是保证程序不超时。 自旋锁如果按照简拼来说,通常被叫做CAS。面试的时候,如果被问题到CAS是什么,一定要知道,指代的是自旋锁。 但是上面的自旋锁还是有缺点的,就是在未加count的控制的时候,程序会不断地循环,会给CPU造成多余的计算能力,为了解决这个问题,对于自旋锁又提出了其他的实现方法,如果有兴趣可以自行百度。一般来说,自旋锁的概念多在Java 面试中提及,因为Java本身支持锁操作,也支持多线程,可以利用多线程对自旋锁进行优化,但是本质都是一样的,即循环抢占锁。 # 悲观锁 悲观锁,顾名思义,就是很悲观,在每次操作的时候,都认为别人已经进行了修改,所以,每次去拿数据的时候都会先进行加锁操作,防止其他人抢占资源。 接下来,将使用给文件加锁的形式实现悲观锁。代码如下: ~~~ <?php $file = "/home/work/abc.txt"; //给文件加锁 if(flock($file,LOCK_EX|LOCK_NB)){ //这里表示抢占到了锁,可以执行业务逻辑了 //todo ..... //执行完成之后,记得要释放掉锁 flock($file, LOCK_UN); }else{ echo "很不好意思,您没有抢到锁"; } ~~~ 通过上面的代码,我们就很容易理解悲观锁,当请求过来的时候,我们先将文件锁定,抢占到这个资源,如果于此同时再有其他到请求过来,他们没有抢占到锁,其他的任务都会失败。只有等待抢占锁的任务成功释放掉锁之后,其他的任务才可以继续抢占锁从而继续任务。 当然了,我这里写的代码是直接告诉用户 # 乐观锁和悲观锁的对比 通过上面乐观锁和悲观锁的例子,我们就很容易理解了。 乐观锁保持乐观的态度,不会一进来就认为别人动了自己的资源,只有更新的时候,才会检查一下,如果此时发现被别人更改过了,那么就直接返回失败,乐观锁适用于读多写少的场景,我们想,如果一个任务需要大量的更新,使用了乐观锁,那么大部分任务不都将失败了吗?所以要考虑好场景再使用。 悲观锁是保持悲观的态度,一进来就先将资源占有起来,只有自己的任务全部完成之后,才释放资源。 当然了,最后一点,最重要的是理解悲观锁和乐观锁是一种思想,也就是不局限于任何形式的实现。 原文:https://www.kancloud.cn/missyou/interview/2234864