7.1说说分布式锁的使用场景

image.png
在分布式情况下,如果有多个server并发修改同一资源,为了避免竞争就需要使用分布式锁。如果使用java自带的锁,因为我们有可能会把单个微服务部署一个集群来对外提供高可靠的服务,而java自带的锁是面向线程设计的,当部署多个微服务后,这些微服务都部署在不同的服务器节点上,属于不同的进程,所以使用java自带的锁是完全失效的。
image.png
【以秒杀场景为例减库存为例】
(情况1)不加锁
image.png
如果不使用锁,多个线程进来判断库存是否大于0,如果库存大于0就减1,然后再设置到Redis中。这是肯定会出现并发问题的,打个比方,A B C三个线程同时进来判断,库存是100,然后执行库存-1的逻辑,都把Redis中的库存-1后设置成99。这不就乱套了么。
(情况2)加分布式锁,但是不设置过期时间
image.png
如果使用分布式锁,先setnx设置分布式锁,如果成功就可以执行业务逻辑,失败的线程就自旋重试,获得到锁的线程在执行完成业务逻辑之后需要把锁释放掉,这样能够保证共享资源的线程安全。但是还存在一个问题:如果获得锁的线程在执行业务逻辑时,突然宕机了。那这个锁一直没有释放掉,其他的线程又在一直自旋重试,这就会带来一个死锁的局面。
(情况三)加分布式锁,并设置过期时间
7.分布式锁 - 图5
image.png
使用这种方式加锁并设置过期时间看起来好像能行,但是还是会有一种特殊情况就是,A线程来获得锁之后立马宕机,设置锁的过期时间的逻辑还没有执行,那锁就一直无法释放也不会自动过期,这样还是会带来死锁问题。
(情况四)加锁与设置过期时间原子化
image.png
image.png
看起来已经很完美了,但实际上还有隐患。打个比方,如果这个过期时间设置得特别短的话或者是因为进程A在执行过程中卡顿,进程A还没执行完任务这个锁就已经过期了,其他进程经过自旋重试后就可以进来。这时候如果进程A执行完业务逻辑,把锁给删掉,但实际上它加的锁已经因为过期了而被自动删除,导致它把别人的锁给删除掉了。
(情况五)uuid,解决删除别人锁的问题
image.png
uuid与线程绑定,在执行删除锁逻辑时会先判断当前这个lock锁的value值是不是等于自己线程的uuid,如果是的话,说明这个锁还是自己加的;如果不是的话就不操作了,不能把别人的锁给删除了。
问题:image.png
如果A执行完判断的逻辑后是true,这时候正好过期时间已到了,恰好B又进来把锁给加上了。然后A又把锁给删掉,这就会导致B加的锁被A给删除掉了。
(情况六)lua脚本
image.png
image.png
将获取锁的value值与判断的逻辑和释放锁的逻辑合并成一个原子操作,就不会有并发问题了。

7.2说说分布式场景下,如何保证锁的高可用和唯一性

在分布式场景下,我们上述的分布式锁的实现方式,都是建立在单个主节点以上的。它的潜在问题是,如果进程A在主节点上加锁成功,但Redis的主从复制是异步完成的,比如clientA获取锁后,主节点复制数据给从节点的过程中主节点宕机,从节点晋升为主节点,但新的主节点上并没有这个锁,这时候进程B再进来就会导致进程A的锁逻辑互斥失败。
最低保证分布式锁的有效性及安全性如下:
(1)互斥:任何时刻只能有一个client获取到锁;
(2)释放死锁:锁定资源的服务器崩溃或者分区,仍然能释放锁;
(3)容错性:只要Redis多数节点一半以上成功,则认为这个client加锁成功。
Redis官方给出的建议是采用RedLock算法的实现方案。该算法建立在多个Redis节点,它的逻辑是:
(1)这些节点相互独立,不存在主从复制或者集群协调机制
(2)加锁:以相同的key向N个实例加锁,只要超过一半节点成功,就认为加锁成;
(3)解锁:向所有的实例发送DEL命令,进行解锁;
以上逻辑可以使用Redission框架。
image.png
【Redission】
Redission是Redis官方推荐的客户端,提供了一个RLock的锁,RLock继承自Juc的Lock接口,提供了中断,超时,尝试获取锁等操作,支持可重入,互斥等特性。
(1)基本原理:RLock底层使用Redis的Hash作为存储结构,其中Hash的Key是用于存储锁的名字,Hash的field用于存储客户端的id,value对应的是线程的重入次数。
(2)客户端id:客户端id是区分每个加锁的线程的,由两部分组成:RedissionLock的成员变量id+当前线程id Thread.currentThread().getId()。其中id是一个UUID,在实例化Redission对象实例的时候会生成一个UUID用来标识该Redission实例,但每个线程的id都是不同的,所以不同Redission实例的id不同,这样可以很好的对不同服务中的线程进行区分。
(3)加锁:为了实现加锁的原子性,Redission使用Lua脚本的形式进行加锁,代码是RedissionLock类的tryLockInnerAsync()。
(4)互斥性实现:每次进行加锁时会返回锁的TTL,如果TTL为null说明加锁成功,直接返回即可,否则说明已有其他线程持有该锁。后续该线程会进行循环去尝试获取锁直到加锁成功。如果使用tryLock则可以在超时时间结束后直接返回。
(5)Lease续约:如果锁设置了持有锁的超时时间,在超时后会进行锁的释放。如果不指定锁的超时时间,那默认锁30s后超时,为了防止任务还没有执行完就释放锁,Redission使用一个看门狗任务定时刷新,每10s续约到30s,直到线程自己释放完锁,也就是说只要这个锁被获取了,看门狗会力保这个锁一直不超时,除非获取锁的线程主动释放。但由于获取锁的线程和看门狗守护线程是在同一个进程中的,如果获取锁的整个进程挂掉了,意味着刷新续约的线程也停止执行,就不会再刷新锁的超时时间。
(6)解锁:同样适用Lua脚本执行,代码是RedissionLock类的unLockInnerAsync()方法。首先会判断锁是否存在,如果不存在直接返回nil;如果该线程持有锁,则对当前的重入数-1,如果计算完后大于0,重新通知看门狗进行续约,并返回0;如果=0,删除这个Hash,并且进行广播,通知看门狗停止进行刷新,并且返回1。