通过互斥性质,来保证线程对分布式系统中共享资源的有序访问
说人话:一把锁,挨个进
V-1.0:
SETNX:Redis提供了SETNE(SET if Not eXists)命令,表示当Key不存在时,才能设置Value,否则设置失败(获取锁失败)
DEL KEY:第一步获取锁成功,对共享资源操作完后,释放锁
问题:如果业务代码出现异常,阻塞或者报错了,那么该线程就一直持有锁,不释放,其他线程也永远获取不到
V-2.0:
SETNX+EXPIRE:给锁上过期时间,假如持有锁线程崩溃了,达到设置的过期时间后,会自动释放锁,避免后续线程获取不到锁!
问题:仍旧会死锁!SETNX和EXPIRE是两条命令,Redis单命令是原子操作,但多条命令为非原子操作!SETNX执行成功,EXPIRE失败时就会发生死锁
v-3.0:
SET(NX+EX)(2.6.12版本之后):获取锁,并设置锁过期时间(原子操作)
如此,可以说是彻底解决了死锁问题!
那么还问存在其他问题吗?
分析分布式锁的特征:互斥、死锁、原子等特性,我们都算是解决了!
但还未考虑隔离性的问题!
private long lockWatchdogTimeout = 30 * 1000;public RedissonLock(CommandAsyncExecutor commandExecutor, String name) { super(commandExecutor, name); this.commandExecutor = commandExecutor; //会获取看门狗设置的时间,默认为10s检查一次,锁过快过期,且业务代码还没执行完,就会给锁续上这个时间,默认30s this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(); this.pubSub = commandExecutor.getConnectionManager().getSubscribeService().getLockPubSub();}private RFuture<Boolean> tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { //如果锁是永不过期,那么就按常规方式索取锁 if (leaseTime != -1) { return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN); } //否则,会在获取锁之后,加一个定时任务,在锁执行完业务代码自行释放之前,不断的给所续上过期时间(默认10s检查一次,每次给锁续期30s) RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN); ttlRemainingFuture.onComplete((ttlRemaining, e) -> { if (e != null) { return; } // lock acquired if (ttlRemaining) { scheduleExpirationRenewal(threadId); } }); return ttlRemainingFuture;}
实现具体细节,参见Redission源码
考虑到获取锁判断后,再删除锁,这两个操作必须是原子性的,那么就需要查看一下Redis的API有没有提供这两个操作的原子性操作了
结果发现,没有!那么叫考虑第二种方案,在Redis中除了单条命令是原子性的,还有执行Lua脚本也是原子性操作!
//如果是自己的锁,则进行删除,否则返回if redis.call("GET",KEY[1]) == ARGV[1] then return redis.call("DEL",KEY[1])else return 0end
经过了这几波优化之后,基于Redis的分布式锁(Redis单实例),可算是安全放心大胆的使用了!
作为技术宅男,要有极客精神(,有心的人,可能会发现,以上分析的分布式锁适合单节点的Redis实例,如果遇到主从+哨兵的模式基本凉凉!
遇到这种情况是不是就完了!?
1. 主节点至少5个实例多主部署 2. 由于在需要从节点和哨兵
1. 加锁线程带着Expire时间进入,在加锁前记录一个开始加锁时间T1 2. 轮流用相同的key和value在不同的节点上进行加锁操作,并且必须保证大多数(N/2+1)节点加锁成功,才算成功 3. 最少(N/2+1)个节点加锁成功后,记录当前时间T2 4. 如果T2-T1 < Expire,则加锁成功,反之失败 5. 释放锁时,要向所有节点(不管是否在该节点加锁成功)发送解锁请求! 6. 此时,锁的Key真正有效时间为:Expire - (T2-T1) 7. 部署的节点数最好是奇数,以更好的满足过半原则
为什么是N/2+1个节点加锁?加锁成功后,计算加锁耗时的意义?为什么释放锁时,要给所有节点(包括没有加锁成功的节点)发送解锁请求?
N/2+1公式为过半原则,这里的本质时为了容错,CAP中的P说到,当分布式系统中,如果存在部分故障节点,但大多数节点仍旧正常时,可以认为整个系统仍旧可用假如T2-T1 > Expire 就意味着一定会存在,最早加锁的节点过期自动解锁的情况,那么此时的加锁节点计数就不再正确!那么此次加锁就毫无意义了!(T2-T1为加锁时间,Expire为过期时间)假设某节点加锁成功了,但是后续因为其他原因(网络)导致无法从该节点上获取响应结果,而被判断为未成功加锁,如果只给加锁成功的节点发起解锁请求,那么此时该节点是收不到解锁请求的,就会一直持有,影响后续无法使用
其实,Redis作者研究出来的RedLock,在一些极端的情况下是存在风险的,比如:
加锁时,所有线程均在相同的目录下创建一个文件,谁先创建成功,就代表获得锁,否则就代表失败,只能等待下次当获取得锁的线程操作完业务代码后,会将该文件删除,同时通知其余客户端再次进入竞争在一个路径下只能创建一个唯一的文件(文件名唯一),但容易引起“惊群”效应
所有线程刚开始都会在ZK中创建自己的临时节点,由ZK去保证这些节点的顺序加锁时,线程会判断ZK下的第一个节点是不是自己创建的,如果是,则加锁成功,如果不是,加锁失败,同时,给自己的上一个节点加一个****节点监听器当节点监听器被通知上一个节点被删除时,当前节点会重新判断ZK下第一个节点是否是自己创建的,循环2的判断操作用完锁后,每个线程只能删除自己创建的临时节点