前言关于分布式锁的问题我也查了很多资料。感觉很多方法都不够完善,或者不知道为什么,所以整理了这篇文章,希望对大家有用,如果有不对的地方,欢迎大家留言指正。首先说一下什么是分布式锁,它解决什么问题?直接看代码$stock=$this->getStockFromDb();//查询剩余库存if($stock>0){$this->ReduceStockInDb();//减少数据库中的库存echo"successful";}else{echo"存货不足";}一个很简单的场景,当用户下单的时候,我们检查商品的库存是否足够。如果不够,会直接返回类似缺货的错误信息。如果存货够了,就直接把-1存入数据库,然后返回成功,这段代码从业务逻辑上来说是没有问题的。但是,这段代码存在严重的问题。如果库存只有1,而且并发比较高的情况下,比如两个请求同时执行这段代码,发现库存同时为1,然后都去数据库执行stock-1的操作,这样库存就会变成-1,然后就会造成超卖现象。刚才说的是两个请求同时执行。如果同时进来几千个请求,可见造成的损失是非常大的。于是有些聪明人想到了一个办法,办法如下。大家都知道redis有个setnx命令。不知道也没关系。我已经帮你查过了。让我们优化上面的代码。版本1$lock_key="lock_key";$res=$redis->setNx($lock_key,1);如果(!$res){返回“error_code”;}$stock=$this->getStockFromDb();//查询剩余库存if($stock>0){$this->ReduceStockInDb();//在数据库中执行减库存操作echo"successful";}else{echo"库存不足";}$redis->delete($lock_key);第一个请求会去setNx,结果当然是返回true,因为lock_key不存在,然后后面的业务逻辑正常进行。任务执行完后,删除lock_key,让下一个请求进来,重复上面的逻辑。第二个请求也会执行setNx,结果返回false,因为lock_key已经存在,然后直接返回错误信息(这就是为什么你在双11抢购闪杀产品时系统返回给你的原因很忙),可能有同学会有疑问,不执行减1库存的操作,不是说高并发吗?如果两个请求同时setNx,得到的结果不会全部为真,业务逻辑也会同时执行。问题还没有解决吗?但是大家要明白,redis是单线程的,具有原子性。不同请求的setnx的执行是顺序执行的,这个不用担心。看似问题解决了,其实不然。这里的伪代码很简单,就是查库存,然后减1。但是真实生产环境的情况非常复杂。在某些极端情况下,程序可能会报错或崩溃。如果第一次执行加锁后,程序报错,那么锁会一直存在,下一次请求永远不会进来,所以我们继续优化version-2try{//新加delete$lock_key="lock_key";trycatch处理,这样程序报错就会释放锁$expire_time=5;//新增过期时间,让锁不会一直占用$res=$redis->setNx($lock_key,1,$expire_time);如果(!$res){返回“error_code”;}$stock=$this->getStockFromDb();//查询剩余库存if($stock>0){$this->ReduceStockInDb();//减少数据库中的库存echo"successful";}else{echo"库存不足";}}finally{$redis->delete($lock_key);}在settingnx的时候加上过期时间,这样至少锁不会一直存在而成为死锁。尝试catch处理,万一程序抛出异常,删除锁,也是为了解决死锁问题。这次死锁问题解决了,但是问题依然存在。大家可以想想还有什么问题再进行。看。存在的问题如下。我们的过期时间是5秒。如果请求执行了6秒怎么办?多秒和无锁有什么区别?其实,不仅如此,还有一个更严重的问题。比如第二个请求也执行了6秒,那么当第二个请求在超过1秒的时候进来,第一个请求执行完毕,当然第二个请求加的锁也会被删除。如果很大,加锁和不加没有区别。针对以上问题,最直接的办法就是延长过期时间,但这并不是最终解决问题的方法。时间设置过长也会引起新的问题。比如机器因为各种原因死机,需要重启。然后你设置了一年的锁定时间,同时没有删除。难道机器重启还要再等一年?另外这种设置固定值的方案在电脑上是不允许的。过去的“千年虫”问题也是类似的原因造成的。在添加超时时间的时候,一定要注意一次性添加,保证其原子性。不要先在setnx之后设置expire_time。在这种情况下,如果系统在setnx之后的那个时刻挂掉了,这个锁还是会变成永久死锁。其实造成上述问题的主要原因是请求1会删除请求2的锁,所以锁需要保证唯一性。我们来优化version-3try{//新增trycatch处理,这样如果程序报错,就会删除锁$lock_key="lock_key";$expire_time=5;//新增一个过期时间,这样锁就不会永远拥有$client_id=session_create_id();//为每个请求生成唯一的id$res=$redis->setNx($lock_key,$client_id,$expire_time);如果(!$res){返回“error_code”;}$stock=$this->getStockFromDb();//查询剩余库存if($stock>0){$this->ReduceStockInDb();//减少数据库中的库存echo"successful";}else{echo"库存不足";}}finally{if($redis->get($lock_key)==$client_id){//这里添加判断,保证每次删除的锁都是本次请求添加的锁,避免误删锁由其他请求添加$redis->delete($lock_key);我们为每个请求生成一个唯一的client_id,并将这个值写入到lock_key中,最后删除锁的时候我们会先判断这个lock_key是不是请求生成的?如果没有,它将不会被删除。但是上述方案仍然存在问题。看一下,最后redis是先判断get操作,然后删除。它是一个两步操作,不保证其原子性,redis的多步操作可以使用lua脚本保证原子性。其实大家看到lua大可不必觉得太陌生。它只是一种语言。这里的作用是将多个redis操作打包成一个命令执行。只有version-4才保证原子性$expire_time=5;//新增过期时间,这样锁就不会一直占用$client_id=session_create_id();//为每个请求生成唯一的id$res=$redis->setNx($lock_key,$client_id,$expire_time);如果(!$res){返回“error_code”;}$stock=$this->getStockFromDb();//查询剩余库存if($stock>0){$this->ReduceStockInDb();//减少数据库中的库存echo"successful";}else{echo"库存不足";}}finally{$script='//这里使用lua脚本实现get比较后删除两??步操作的原子性ifredis.call("GET",KEYS[1])==ARGV[1]然后返回redis.call("DEL",KEYS[1])elsereturn0end';返回$instance->eval($script,[$lock_key,$client_id],1);}这样打包之后,分布式锁应该就更完美了当然,我们还可以进一步优化用户体验。现在,比如一个请求进来后,如果请求被锁定,它会立即返回给用户请求失败。请再试一次。我们可以适当延长这个时间,不要马上返回给用户请求。失败了,所以体验会更好。具体方法是请求用户进来,如果遇到锁,可以等一段时间再试。如果在重试过程中释放了锁,则请求成功。版本5$retry_times=3;//重试次数$usleep_times=5000;//重试间隔时间$expire_time=5;//新增过期时间,让锁不会一直被占用while($retry_times>0){$client_id=session_create_id();//为每个请求生成唯一的id$res=$redis->setNx($lock_key,$client_id,$expire_time);如果($res){中断;}echo"尝试重新获取锁";$retry_times--;usleep($usleep_times);}if($res){//3次重试后如果没有获取到锁,则返回错误信息给用户return"error_code";}$stock=$this->getStockFromDb();//查询剩余库存if($stock>0){$this->ReduceStockInDb();//在数据库中执行减库存操作echo"successful";}else{echo"库存不足";}}finally{$script='//这里使用lua脚本执行get比较后删除的两步操作Atomicifredis.call("GET",KEYS[1])==ARGV[1]thenreturnredis.call("DEL",KEYS[1])elsereturn0end';返回$instance->eval($script,[$lock_key,$client_id],1);}当然上面的分布式锁还是不够完美。比如redis主从同步延迟就会出现问题。java中实现redission的思路很好。有兴趣的可以看看源码。今天的聊天就到这里。有兴趣的朋友可以留言一起讨论
