当前位置: 首页 > 科技观察

一段使用Redis实现分布式锁的血泪史

时间:2023-03-12 00:11:07 科技观察

有人问:使用Redis分布式锁的详细解决方案是什么?一个非常简单的答案是使用Redission客户端。Redission中的锁方案是一个完善的Redis分布式锁的详细方案。那么,为什么Redission中的锁方案是完美的呢?恰好我对Redis作为分布式锁的使用心得颇多。在实际工作中,我也探索了很多使用Redis作为分布式锁的方法。经过无数血泪的教训。所以,在说Redission锁为什么完美之前,先给大家展示一下我在使用Redis做分布式锁时遇到的问题。以前用Redis做分布式锁解决一个用户抢券的问题。该业务需求为:用户收到优惠券后,优惠券的数量必须相应减1。优惠券售完后,用户不得再次抢购。实现时,先从数据库中读取优惠券的张数进行判断。当优惠券大于0时,允许领取优惠券。然后,将优惠券数量减一后,写回数据库。当时由于请求量大,我们用了三台服务器进行分发。这时候就会出现一个问题:如果其中一台服务器上的A应用获取了优惠券数量,由于相关业务逻辑的处理,导致数据库中的优惠券数量没有及时更新;当A应用程序处理业务逻辑时,服务器上的另一个应用程序B更新优惠券数量。然后,当A应用程序更新数据库中的优惠券数量时,它会覆盖B应用程序更新的优惠券数量。看到这里,可能有人会疑惑为什么这里不直接使用SQL:updatecoupontablesetcouponquantity=couponquantity-1wherecouponid=xxx原因是在没有分布式锁协同的情况下,折扣券的张数可能会直接出现负数。因为当优惠券数量为1时,如果两个用户同时通过两个服务器发起抢优惠券的请求,都满足优惠券大于0的条件,然后都执行这条SQL语句,结果就是优惠券数量直接变成-1了。有人说可以使用乐观锁,比如使用如下SQL:updatecoupontablesetcouponquantity=couponquantity-1wherecouponid=xxxandversion=xx这种方法有一定的概率,很可能出现数据一直无法更新,导致重试时间过长。因此综合考虑后,我们采用了Redis分布式锁,通过互斥来防止多个客户端同时更新优惠券数量。当时我们首先想到的是使用Redis的setnx命令。setnx命令其实就是setifnotexists的缩写。当key的值设置成功时,返回1,否则返回0。因此这里的setnx设置成功可以表示为获取了锁。如果失败,说明已经有锁了,可以认为是获取锁失败。setnxlocktrue如果要释放锁,执行del命令删除key。dellock利用了这个特性,我们可以让系统在执行优惠券逻辑之前先在Redis中执行setnx命令。然后根据指令的执行结果,判断是否已经获取到锁。如果获取到,则继续执行该业务,执行完后使用del命令释放锁。如果没有获取到,则等待一定时间,然后再获取锁。乍一看,这一切似乎没什么问题,使用setnx指令确实达到了想要的互斥效果。但是,这是建立在所有运行环境都正常的基础上的。一旦运行环境出现异常,问题就来了。想一想,如果持有锁的应用突然崩溃,或者所在的服务器宕机了,会发生什么?这会造成死锁——持有锁的应用程序无法释放锁,其他应用程序也没有机会获取它再次加锁。这会造成巨大的在线事故,我们需要改进解决方案来解决这个问题。如何解决?我们可以看到,死锁的根本原因是持有锁的应用程序一旦出现问题,锁就不会释放了。从这个方向思考,可以在Redis上给key一个过期时间。在这种情况下,即使出现问题,密钥也会在一段时间后被释放。这能解决问题吗?事实上,每个人都这样做。但是由于setnx命令本身不能设置超时时间,所以一般有两种方法来设置:1.使用lua脚本,然后在使用setnx命令后使用expire命令为key设置过期时间。ifredis.call("SETNX","lock","true")==1thenlocalexpireResult=redis.call("expire","lock","10")ifexpireResult==1thenreturn"success"否则返回“过期失败”endelse返回“setnx不为空”end2。直接使用set(key,value,NX,EX,timeout)命令同时设置锁和超时。redis.call("SET","lock","true","NX","PX","10000")以上两种方法都可以。两种方式释放锁的脚本是一样的,直接调用Redis的del命令即可。至此,我们的锁不仅起到了互斥的作用,也不会因为一些锁持有系统的问题而导致死锁。这是完美的吗?假设有这样一种情况,如果持有锁的应用超过了我们设置的超时时间怎么办?会出现两种情况:发现系统在Redis中设置的key仍然存在,发现系统设置的key在Redis中不存在。第一种情况比较正常。因为你毕竟超时了,正常清除key也是顺理成章的。但是最可怕的是第二种情况,发现设置的key还存在。这是什么意思?这意味着当前存在的密钥是由另一个应用程序设置的。这时如果持有锁超时的应用程序调用del命令删除锁,就会误删除别人设置的锁,直接导致系统业务出现问题。所以,为了解决这个问题,我们需要继续对Redis的脚本进行修改。。。毁了它,累了。。。首先,我们需要让应用在获取锁。通过这个唯一值,当系统释放锁时,可以识别锁是否是自己设置的。如果是自己设置的,则释放锁,即删除key;如果没有,什么也不做。脚本如下:ifredis.call("SETNX","lock",ARGV[1])==1thenlocalexpireResult=redis.call("expire","lock","10")ifexpireResult==1thenreturn"success"elsereturn"expirefailed"endelsereturn"setnxnotnull"endorredis.call("SET","lock",ARGV[1],"NX","PX","10000")这里ARGV[1]是一个可以传入的参数变量,可以传入一个唯一的值,比如一个只有自己知道的UUID值,或者通过滚雪球算法生成一个只有自己拥有的唯一ID.释放锁的脚本改成这样:ifredis.call("get","lock")==ARGV[1]thenreturnredis.call("del","lock")elsereturn0end即可从业务的角度来看,无论如何,我们的分布式锁已经可以满足真正的业务需求。可以互斥,不会死锁,不会误删别人的锁。只有你拥有的锁才能被你自己释放。一切都是那么美好!!!不幸的是,还有一个隐患,我们没有排除。隐患就是Redis本身。要知道,lua脚本是用在Redis单例上的。一旦Redis本身出现问题,我们的分布式锁就不能用了,分布式锁也不能用了,会对业务的正常运行造成重大影响,这是我们不能接受的。因此,我们需要让Redis高可用。一般来说,主从集群是用来解决Redis的高可用问题的。但是搞主从集群会引入新的问题。主要问题是Redis的主从数据同步有延迟。这个延迟会产生一个边界条件:当宿主机上的Redis已经被加锁,但是锁数据还没有同步到slave,宿主机就宕机了。后来,奴隶被提升为主人。这个时候之前的master在slave上没有设置锁数据——锁丢了……丢了……到这里,终于可以介绍一下Redission(开源Redis客户端)了,来看看怎么弄的它实现了Redis分布式锁。Redission实现分布式锁的思路很简单。无论是主从集群还是RedisCluster集群,都会对集群中的每一个Redis执行一个一个设置Redis锁的脚本,即集群中的每一个Redis都会包含设置好的锁数据。下面通过一个例子来介绍一下。假设Redis集群有5台机器,根据评估,设置锁超时时间为10秒比较合适。第一步:我们先计算一下集群的总等待时间。集群总等待时间为5秒(锁超时时间为10秒/2)。第二步:用5秒除以5台机器的个数,结果为1秒。这1秒是每个Redis连接可接受的等待时间。第三步:依次连接5台Redis,执行lua脚本设置锁,然后判断:如果5秒内,有5台机器有执行结果,且超过一半(即3台)的机器设置加锁成功,则认为加锁成功;如果少于一半的机器设置锁成功,则认为失败。如果超过5秒,无论多少台机器加锁成功,都认为加锁失败。比如前4台机器设置成功总共用了3秒,但是最后一台机器用了2秒没有结果。总等待时间已超过5秒。就算成功了一半以上,这也算是失败了。还有一点,在很多业务逻辑中,其实是不需要锁超时的。比如凌晨批量处理的任务,可能需要分布式锁来保证任务不会被重复执行。在这一点上,执行任务需要多长时间是不明确的。如果这里设置分布式锁的超时时间,意义不大。但是如果不设置超时时间,就会造成死锁问题。所以,一般解决这个问题的方法是,每个持有锁的客户端启动一个后台线程,通过执行特定的lua脚本,不断刷新Redis中的key超时时间,这样在任务执行完成之前,key不会被清除。脚本如下:ifredis.call("get","lock")==ARGV[1]thenreturnredis.call("expire","lock","10")elsereturn0end其中,ARGV[1]是一个可以传入的参数变量,表示持有锁的系统的唯一值,即只有持有锁的客户端才能刷新key的超时时间。至此,一个完整的分布式锁就实现了。实现方案总结如下:使用set命令设置锁标记,必须有一个超时时间,这样客户端才会崩溃,锁也可以释放;对于不需要超时时间的,需要实现一个可以不断刷新锁超时时间的线程;每次获取锁的client,在Redis中设置的值必须是唯一的,才能识别是哪个client设置了锁;在分布式集群中,直接为每台机器设置相同的超时时间和锁标记;为了保证集群设置的锁不会导致一些已经设置好的锁因为网络问题而超时,必须合理设置网络等待时间和锁超时时间。这个分布式锁满足以下四个条件:任何时候只有一个客户端可以持有锁;不会发生死锁,如果一个客户端在持有锁的情况下解锁失败,也可以保证其他客户端继续继续持有锁;加锁和解锁必须是同一个客户端,客户端加的锁只能自己解决;只要大部分Redis节点正常,客户端就可以正常使用锁。当然,Redission中的脚本为了保证锁的重入性,对lua脚本做了一些修改,现将完整的lua脚本贴在下面。获取锁的Lua脚本:if(redis.call('exists',KEYS[1])==0)thenredis.call('hincrby',KEYS[1],ARGV[2],1);redis.call('pexpire',KEYS[1],ARGV[1]);returnnil;end;if(redis.call('hexists',KEYS[1],ARGV[2])==1)然后redis.call('hincrby',KEYS[1],ARGV[2],1);redis.call('pexpire',KEYS[1],ARGV[1]);返回零;结束;返回redis.call('pttl',KEYS[1]);刷新锁超时时间对应的脚本:if(redis.call('hexists',KEYS[1],ARGV[2])==1)thenredis.call('pexpire',KEYS[1],ARGV[1]);返回1;结尾;返回0;对应的解锁脚本:if(redis.call('hexists',KEYS[1],ARGV[3])==0)thenreturnnil;end;localcounter=redis.call('hincrby',KEYS[1],ARGV[3],-1);if(counter>0)thenredis.call('pexpire',KEYS[1],ARGV[2]);返回0;elseredis.call('del',KEYS[1]);redis.call('publish',KEYS[2],ARGV[1]);return1;end;returnnil;至此,使用Redis作为分布式锁的详细方案就写完了。我不仅把一步一步的粗略经历写了出来,还把各种问题和解决方法的细节写了出来。希望大家看完后都能有所收获。最后提醒大家,使用Redis集群作为分布式锁是有些争议的,需要大家在实际使用的时候根据实际情况做出更好的选择和权衡。