赞
踩
redis 支持三种常用的客户端,分别是:jedis、lettuce、redisson。其中,各自的特点如下:
在分布式系统中涉及到多个服务同时对同一资源数据进行加锁操作,像传统的单机单进程加锁操作 synchronized 等 api 则无法适用(同时加锁都能在自己的服务中加锁成功,对同一数据进行各自服务的操作,造成数据混乱错误),分布式系统中需要一个公共的锁在多个服务中竞争共同使用,使服务串行化,来保证数据的正确性。其中,zookeeper 和 redis 是常见的分布式锁的实现方式,而 redisson 客户端则是 redis 比较出色的一个分布式锁的实现。
redisson 中分布式锁的相关源码实现如下:
在 redis 中,通过 setnx 命令可以保证一个 key 同时只能有一个线程设置成功,这样可以实现加锁的互斥性。但是 redisson 中没有这样实现,而是使用了 lua 脚本去保证加锁的原子性。其代码跟踪流程如下:
首先,通过 key 去拿锁,并锁主相应的业务代码(传入加锁的时间,或者不传使用默认的 30s)
先通过传入的 key,然后通过 RedissonClient 拿到一个 RLock 对象,通过 Rlock 对象实现加锁和释放锁功能。
getLock 方法获得的接口实现是 RedissonLock 对象,RedissonLock 对象有以下两个 lock () 方法,一个不带参数,一个带锁定时间 leaseTime 和时间单位 unit 参数
lock 方法会调用重载的 lock 方法,其中的入参锁定时间 leaseTime=-1 时表示不传,则使用默认时间 30s,之后会调用 tryAcquire 实现加锁逻辑,在 tryAcquire 方法中会传入当前线程 id
tryAcquire 会调用 tryAcquireAsync 方法,其中 tryAcquire 是 tryAcquireAsync 异步实现的同步等待,即该步骤是将异步转化为同步的过程
tryAcquireAsync 方法会调用 tryLockInnerAsycn 方法,其中会根据 leaseTime 参数是否 =-1 来判断赋值默认的 30s,若 =-1 表示未传入锁定时间,则赋值默认的 30s
最后,在 tryLockInnerAsycn 方法中会调用 lua 脚本执行设置锁和锁的时间等操作。
总结 lock 方法加锁的逻辑调用如下:
redisson 通过 lua 脚本实现加锁的原子性,其中,lua 脚本的具体实现逻辑解释如下:(主要解释第一部分 if 内容)
第一个 if 中主要参数含义:
这些参数是通过以下部分传入:
最后,分析这段 if 操作的中的 lua 代码逻辑:
从这里可以看出,第一次有某个客户端的某个线程来加锁的逻辑还是挺简单的,就是判断有没有人加过锁,没有的话就自己去加锁,设置加锁的 key,再存一下加锁的线程和加锁次数,设置一下锁的过期时间。简单示意图如下:
在以上流程中,在加锁流程中,会给锁设置一个过期时间,若没设置,则最后会给设置一个系统的默认的 30 秒。之所以这样操作,是为了防止死锁情况的发生。比如,若没有设置锁的过期时间,当业务代码没有执行完宕机了或者释放锁失败后,该线程会一直拿着锁得不到释放,导致死锁的发生。
问题:
若设置了时间,时间设置的过短,导致业务程序还没执行完,锁却释放了,则其他线程可以拿到锁,进行业务数据操作导致数据混乱问题。
解决:
给锁进行自动延迟时间操作
Redisson 中,针对没有设置加锁超时时间的情况下,会设置一个默认的 30 秒,但是针对默认的 30 秒时间,若再到期后还没执行完该线程,就会出现线程安全问题,所以,Redisson 对设置默认时间的情况下,会提供一个自动延长加锁时间的操作,俗称看门狗机制即 watchdog 机制。(Redisson 对设置了加锁时间的情况,不会启动看门狗机制)。
该看门狗机制是在 tryLockInnerAsync 方法加锁成功后,没有指定锁过期时间时,会启动一个定时任务,来定时延长加锁时间,默认每 10 秒执行一次。故看门狗机制本质上就是一个定时任务。具体代码如下:
最后定期执行的 lau 脚本如下:
脚本中:
KEYS [1]:锁的名字,即 demo 中的 mylock
ARGV [1]:锁的过期时间
ARGV [2]:加锁的唯一标识,demo 中的 b983c153-7421-469a-addb-44fb92259a1b:1
脚本的执行逻辑:
判断来续约的线程和加锁的线程是否是同一个,若是,则将锁的过期时间延长到 30s,然后返回 1,代表续约成功,否则返回 0,续约失败,下次定时任务也就不回再执行了。
注意:
线程在加锁后,业务执行完或者出错后都需要最终执行下锁的释放操作,若没有释放锁并且也启动了看门狗机制后,则锁将得不到释放而造成死锁,除非宕机后没有了定时任务的续约操作,到达过期时间自动失效。
可重入加锁:
即同一个客户端同一个线程也能多次对同一个锁进行加锁操作。也就是可以同时执行多次 lock 方法,流程也一样,最后也是会调用到 lua 脚本,也就是调用 lua 脚本中的第二个 if 逻辑,如下图:
该段 lua 逻辑:
判断当前已经加锁的 key 对应的加锁线程和要加锁的线程是不是同一个,是的话,就将这个线程加锁次数加 1,则实现了可重入加锁,同时返回 nil。
可重入加锁后加锁 key 对应的值为:
mylock:{“b983c153-7421-469a-addb-44fb92259a1b:1”:2}
加锁 lua 脚本的第二段 if 逻辑示意图:
线程在加锁处理完业务后,是需要进行最终释放锁操作,若没有释放锁操作,会有以下问题产生:
问题一:若没有设置超时时间,则 Redisson 会启动看门狗机制,则会出现超时时间一直续约,导致该线程的锁无法失效,而产生死锁问题,除非出现宕机情况才能使该锁失效。
问题二:若设置了超时时间,但是超时时间较长,业务程序在很短的时间内执行完,导致剩余这段时间锁得不到释放而产生资源浪费、效率地下问题。
针对这些问题,所以需要业务执行完后主动去释放锁,Redisson 中的释放锁流程:
unlocl () 方法会调用 unlockAsycn,并传入线程 id,实现异步转同步操作
unlockAsync () 最终会调用 unlockInnerAsync 来实现锁的释放逻辑
unlockInnerAsync () 方法的代码逻辑如下:
该 lua 逻辑中:
先判断释放锁的线程是否是加锁的线程,若不是,则直接返回 nil,这可以防止释放了其他线程加的锁
若是需要释放的线程,则进行该线程的加锁次数 - 1 操作,然后拿到剩余加锁次数的 counter 变量值
若 counter>0,说明有重入锁,锁还没有彻底释放完,然后设置下剩余锁的过期时间,然后返回 0
若 counter 不大于 0,说明锁释放完了,然后把锁对应的 key 进行删除,然后发布一个锁已经释放的消息,然后返回 1
流程图:
Redisson 中在 lock 和 tryLock 方法中通过传入锁的失效时间 leaseTime 来实现锁的超时自动释放,其在代码中的体现如下:
其中,是否指定超时时间,最终都会调用 tryAcquireAsync 方法,其中会传入锁失效时间 leaseTime,若未指定失效时间时,传入的 leaseTime=-1,则在 tryAcquireAsync 方法中会重新指定 leaseTime=30s 的默认时间,定时时就以指定的失效时间为准。其中指定与不指定的区别在于,不指定时会开启看门狗机制即 scheduleExpirationRenewal (threadId) 方法;指定时间后不会开启该机制,在达到时间后自动失效。
上面我们分析了第一次加锁逻辑和可重入加锁的逻辑,因为 lua 脚本加锁的逻辑同时只有一个线程能够执行(redis 是单线程的原因),所以一旦有线程加锁成功,那么另一个线程来加锁,前面两个 if 条件都不成立,最后通过调用 redis 的 pttl 命令返回锁的剩余的过期时间回去。
这样,客户端就根据返回值来判断是否加锁成功,因为第一次加锁和可重入加锁的返回值都是 nil,而加锁失败就返回了锁的剩余过期时间。
所以加锁的 lua 脚本通过条件判断就实现了加锁的互斥操作,保证其它线程无法加锁成功。
所以总的来说,加锁的 lua 脚本实现了第一次加锁、可重入加锁和加锁互斥的逻辑。
如图,tryAcquire 方法执行最终的加锁逻辑,若该方法执行失败后,返回的 ttl 不为空,则会进入下面的 while (true) 死循环中(自旋),不停的执行 tryAcquire 方法来尝试加锁,所谓的阻塞,其实就是自旋加锁方式。
但是这种阻塞可能会产生问题,因为如果其它线程释放锁失败,那么这个阻塞加锁的线程会一直阻塞加锁,这肯定会出问题的。所以有没有能够可以指定阻塞的时间,如果超过一定时间还未加锁成功的话,那么就放弃加锁的方法。答案就是下面的方法:
超时放弃加锁的方法:
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit)
boolean tryLock(long time, TimeUnit unit)
通过 waitTime 参数或者 time 参数来指定超时时间。这两个方法的主要区别就是上面的方法支持指定锁超时时间,下面的方法不支持锁超时自动释放。
tryLock (long time, TimeUnit unit) 这个方法最后也是调用 tryLock (long waitTime, long leaseTime, TimeUnit unit) 方法的实现。代码如下
其实通过源码就可以看出是怎么实现一定时间之内还未获取到锁就放弃加锁的逻辑,其实相比于一直获取锁,主要是加了超时的判断,如果超时了,那么就退出循环,放弃加锁
在实际的业务场景中,其实会有很多读多写少的场景,那么对于这种场景来说,使用独占锁来加锁,在高并发场景下会导致大量的线程加锁失败,阻塞,对系统的吞吐量有一定的影响,为了适配这种读多写少的场景,Redisson 也实现了读写锁的功能。
读写锁的特点:
Redisson 中读写锁的代码:
Redisson 通过 RedissonReadWriteLock 类来实现读写锁的功能,通过这个类可以获取到读锁或者写锁,所以真正的加锁的逻辑是由读锁和写锁实现的。
那么 Redisson 是如何具体实现读写锁的呢?
前面说过,加锁成功之后会在 redis 中维护一个 hash 的数据结构,存储加锁线程和加锁次数。在读写锁的实现中,会往 hash 数据结构中多维护一个 mode 的字段,来表示当前加锁的模式。
所以能够实现读写锁,最主要是因为维护了一个加锁模式的字段 mode,这样有线程来加锁的时候,就能根据当前加锁的模式结合读写的特性来判断要不要让当前来加锁的线程加锁成功。
批量加锁的意思就是同时加几个锁,只有这些锁都算加成功了,才是真正的加锁成功。
比如说,在一个下单的业务场景中,同时需要锁定订单、库存、商品,基于这种需要锁多种资源的场景中,Redisson 提供了批量加锁的实现,对应的实现类是 RedissonMultiLock。
Redisson 提供了批量加锁使用代码如下。
批量加锁的代码源码如下:
就是根据顺序去依次调用传入 mylock1、mylock2、mylock3 加锁方法,然后如果都成功加锁了,那么 multiLock 就算加锁成功。
对于单 Redis 实例来说,如果 Redis 宕机了,那么整个系统就无法工作了。所以为了保证 Redis 的高可用性,一般会使用主从或者哨兵模式。但是如果使用了主从或者哨兵模式,此时 Redis 的分布式锁的功能可能就会出现问题。
举个例子来说,假如现在使用了哨兵模式,如图:
基于这种模式,Redis 客户端会在 master 节点上加锁,然后异步复制给 slave 节点。
但是突然有一天,因为一些原因,master 节点宕机了,那么哨兵节点感知到了 master 节点宕机了,那么就会从 slave 节点选择一个节点作为主节点,实现主从切换,如图:
这种情况看似没什么问题,但是不幸的事发生了,那就是客户端对原先的主节点加锁,加成之后还没有来得及同步给从节点,主节点宕机了,从节点变成了主节点,此时从节点是没有加锁信息的,如果有其它的客户端来加锁,是能够加锁成功的。。。。
那么如何解决这种问题呢?Redis 官方提供了一种叫 RedLock 的算法,Redisson 刚好实现了这种算法
RedLock 算法:
在 Redis 的分布式环境中,我们假设有 N 个 Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。之前我们已经描述了在 Redis 单实例下怎么安全地获取和释放锁。我们确保将在每(N) 个实例上使用此方法获取和释放锁。在这个样例中,我们假设有 5 个 Redis master 节点,这是一个比较合理的设置,所以我们需要在 5 台机器上面或者 5 台虚拟机上面运行这些实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
Redisson 对 RedLock 算法的实现
RedissonRedLock 加锁过程如下:
RedissonRedLock 底层其实也就基于 RedissonMultiLock 实现的,RedissonMultiLock 要求所有的加锁成功才算成功,RedissonRedLock 要求只要有 N/2 + 1 个成功就算成功
参考文章:
https://blog.csdn.net/weixin_45630885/article/details/125088885
https://mp.weixin.qq.com/s/EhucmYblfrRxbAuJTdPlfg
https://github.com/redisson/redisson/wiki/
http://redis.cn/topics/distlock.html
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。