Redis分布式锁实现Redisson 15问
大家好,我是三友。
在一个分布式系统中,由于涉及到多个实例同时对同一个资源加锁的问题,像传统的synchronized、ReentrantLock等单进程情况加锁的api就不再适用,需要使用分布式锁来保证多服务实例之间加锁的安全性。常见的分布式锁的实现方式有zookeeper和redis等。而由于redis分布式锁相对于比较简单,在实际的项目中,redis分布式锁被用于很多实际的业务场景中。
redis分布式锁的实现中又以Redisson比较出名,所以本文来着重看一下Redisson是如何实现分布式锁的,以及Redisson提供了哪些其它的功能。
一、如何保证加锁的原子性
说到redis的分布式锁,可能第一时间就想到了setNx命令,这个命令保证一个key同时只能有一个线程设置成功,这样就能实现加锁的互斥性。但是Redisson并没有通过setNx命令来实现加锁,而是自己实现了一套完成的加锁的逻辑。
Redisson的加锁使用代码如下,接下来会有几节着重分析一下这段代码逻辑背后实现的原理。
先通过RedissonClient,传入锁的名称,拿到一个RLock,然后通过RLock实现加锁和释放锁。
getLock获得的RLock接口的实现是RedissonLock,所以我们看一下RedissonLock对lock()方法的实现。
lock方法会调用重载的lock方法,传入的leaseTime为-1,调用到这个lock方法,之后会调用tryAcquire实现加锁的逻辑。
tryAcquire最后会调到tryAcquireAsync方法,传入了leaseTime和当前加锁线程的id。tryAcquire和tryAcquireAsync的区别就是tryAcquireAsync是异步执行,而tryAcquire是同步等待tryAcquireAsync的结果,也就是异步转同步的过程。
tryAcquireAsync方法会根据leaseTime是不是-1来判断使用哪个分支加锁,其实不论走哪个分支,最后都是调用tryLockInnerAsync方法来实现加锁,只不过是参数不同罢了。但是我们这里的leaseTime其实就是-1,所以会走下面的分支,尽管传入到tryAcquireAsync的leaseTime是-1,但是在调用tryLockInnerAsync方法传入的leaseTime参数是internalLockLeaseTime,默认是30s。
tryLockInnerAsync方法。
通过tryLockInnerAsync方法的实现可以看出,最终加锁是通过一段lua脚本来实现加锁的,redis在执行lua脚本的时候是可以保证加锁的原子性的,所以Redisson实现加锁的原子性是依赖lua脚本来实现的。其实对于RedissonLock这个实现来说,最终实现加锁的逻辑都是通过tryLockInnerAsync来实现的。
来一张图总结一下lock方法加锁的调用逻辑。
二、如何通过lua脚本实现加锁
通过上面分析可以看出,redis是通过执行lua脚本来实现加锁,保证加锁的原子性。那么接下来分析一下这段lua脚本干了什么。
其中这段脚本中的lua脚本中的参数的意思:
- KEYS[1]:就是锁的名称,对于我们的demo来说,就是myLock
- ARGV[1]:就是锁的过期时间,不指定的话默认是30s
- ARGV[2]:代表了加锁的唯一标识,由UUID和线程id组成。一个Redisson客户端一个UUID,UUID代表了一个唯一的客户端。所以由UUID和线程id组成了加锁的唯一标识,可以理解为某个客户端的某个线程加锁。
那么这些参数是怎么传过去的呢,其实是在这里。
- getName:方法就是获取锁的名称
- leaseTime:就是传入的锁的过期时间,如果指定超时时间就是指定的时间,没指定默认是30s
- getLockName:就是获取加锁的客户端线程的唯一标识。
分析一下这段lua的加锁的逻辑。
1)先调用redis的exists命令判断加锁的key存不存在,如果不存在的话,那么就进入if。不存在的意思就是还没有某个客户端的某个线程来加锁,第一次加锁肯定没有人来加锁,于是第一次if条件成立。
2)然后调用redis的hincrby的命令,设置加锁的key和加锁的某个客户端的某个线程,加锁次数设置为1,加锁次数很关键,是实现可重入锁特性的一个关键数据。用hash数据结构保存。hincrby命令完成后就形成如下的数据结构。
myLock:{
“b983c153-7421-469a-addb-44fb92259a1b:1”:1
}
3)最后调用redis的pexpire的命令,将加锁的key过期时间设置为30s。
从这里可以看出,第一次有某个客户端的某个线程来加锁的逻辑还是挺简单的,就是判断有没有人加过锁,没有的话就自己去加锁,设置加锁的key,再存一下加锁的线程和加锁次数,设置一下锁的过期时间为30s。
画一张图来看一下lua脚本加锁的逻辑干了什么。
至于第二段if是干什么的,我们后面再说。
三、为什么需要设置加锁key的过期时间
通过上面的加锁逻辑可以知道,虽然我们没有手动设置锁的过期时间,但是Redisson默认会设置一个30s的过期时间,为什么需要过期时间呢?
主要原因是为了防止死锁。当某个客户端获取到锁,还没来得及主动释放锁,那么此时假如客户端宕机了,又或者是释放锁失败了,那么如果没有设置过期时间,那么这个锁key会一直在,那么其它线程来加锁的时候会发现key已经被加锁了,那么其它线程一直会加锁失败,就会产生死锁的问题。
四、如何自动延长加锁时间
通过上面的分析我们都知道,在加锁的时候,就算没有指定锁的过期时间,Redisson默认也会给锁设置30s的过期时间,主要是用来防止死锁。
虽然设置了默认过期时间能够防止死锁,但是这也有一个问题,如果在30s内,任务没有结束,但是锁已经被释放了,失效了,一旦有其它线程加锁成功,那么就完全有可能出现线程安全数据错乱的问题。
所以Redisson对于这种未指定超时时间的加锁,就实现了一个叫watchdog机制,也就是看门狗机制来自动延长加锁的时间。
在客户端通过tryLockInnerAsync方法加锁成功之后,如果你没有指定锁过期的时间,那么客户端会起一个定时任务,来定时延长加锁时间,默认每10s执行一次。所以watchdog的本质其实就是一个定时任务。
最后会定期执行如下的一段lua脚本来实现加锁时间的延长。
解释一下这段lua脚本中参数的意思,其实是跟加锁的参数的意思是一样
- KEYS[1]:就是锁的名称,对于我们的demo来说,就是myLock
- ARGV[1]:就是锁的过期时间
- ARGV[2]:代表了加锁的唯一标识,b983c153-7421-469a-addb-44fb92259a1b:1。
这段lua脚本的意思就是判断来续约的线程跟加锁的线程是同一个,如果是同一个,那么将锁的过期时间延长到30s,然后返回1,代表续约成功,不是的话就返回0,代表续约失败,下一次定时任务也就不会执行了。
注意:因为有了看门狗机制,所以说如果你没有设置过期时间(超时自动释放锁的逻辑后面会说)并且没有主动去释放锁,那么这个锁就永远不会被释放,因为定时任务会不断的去延长锁的过期时间,造成死锁的问题。但是如果发生宕机了,是不会造成死锁的,因为宕机了,服务都没了,那么看门狗的这个定时任务就没了,也自然不会去续约,等锁自动过期了也就自动释放锁了,跟上述说的为什么需要设置过期时间是一样的。
五、如何实现可重入加锁
可重入加锁的意思就是同一个客户端同一个线程也能多次对同一个锁进行加锁。
也就是同时可以执行多次 lock方法,流程都是一样的,最后也会调用到lua脚本,所以可重入加锁的逻辑最后也是通过加锁的lua脚本来实现的。
上面加锁逻辑的lua脚本的前段我上面已经说过,下半部分也就是可重入加锁的逻辑。
下面这段if的意思就是,判断当前已经加锁的key对应的加锁线程跟要来加锁的线程是不是同一个,如果是的话,就将这个线程对应的加锁次数加1,也就实现了可重入加锁,同时返回nil回去。
可重入加锁成功之后,加锁key和对应的值可能是这样。
myLock:{
“b983c153-7421-469a-addb-44fb92259a1b:1”:2
}
所以加锁lua脚本的第二段if的逻辑其实是实现可重入加锁的逻辑。
六、如何主动释放锁和避免其它线程释放了自己加的锁
当业务执行完成之后,肯定需要主动释放锁,那么为什么需要主动释放锁呢?
第一,假设你任务执行完,没有手动释放锁,如果没有指定锁的超时时间,那么因为有看门狗机制,势必会导致这个锁无法释放,那么就可能造成死锁的问题。
第二,如果你指定了锁超时时间(锁超时自动释放逻辑后面会说),虽然并不会造成死锁的问题,但是会造成资源浪费的问题。假设你设置的过期时间是30s,但是你的任务2s就完成了,那么这个锁还会白白被占有28s的时间,这28s内其它线程都无法成功加锁。
所以任务完成之后,一定需要主动释放锁。
那么Redisson是如何主动释放锁和避免其它线程释放了自己加的锁?
主动释放锁是通过unlock方法来完成的,接下来就分析一下unlock方法的实现。unlock会调用unlockAsync,传入当然释放线程的id,代表了当前线程来释放锁,unlock其实也是将unlockAsync的异步操作转为同步操作。
unlockAsync最后会调用RedissonLock的unlockInnerAsync来实现释放锁的逻辑。
也是执行一段lua脚本。
1)先判断来释放锁的线程是不是加锁的线程,如果不是,那么直接返回nil,所以从这里可以看出,主要是通过一个if条件来防止线程释放了其它线程加的锁。
2)如果来释放锁的线程是加锁的线程,那么就将加锁次数减1,然后拿到剩余的加锁次数 counter 变量。
3)如果counter大于0,说明有重入加锁,锁还没有彻底的释放完,那么就设置一下锁的过期时间,然后返回0
4)如果counter没大于0,说明当前这个锁已经彻底释放完了,于是就把锁对应的key给删除,然后发布一个锁已经释放的消息,然后返回1。
七、如何实现超时自动释放锁
前面我们说了不指定锁超时时间的话,那么会有看门狗线程不断的延长加锁时间,不会导致锁超时释放,自动过期。那么指定超时时间的话,是如何实现到了指定时间超时释放锁的呢?
能够设置超时自动释放锁的方法。
void lock(long leaseTime, TimeUnit unit)
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit)