1、简介
redis 支持三种常用的客户端,分别是:jedis、lettuce、redisson。其中,各自的特点如下:
- jedis:提供了比较全面的 redis 命令支持,但是其使用阻塞的 I/O,方法调用都是同步的,程序流需要等到 socket 处理完 I/O 才能执行,不支持异步。其线程不是安全的。
- lettuce:是一个完全非阻塞的 redis 客户端,使用 netty 构建,提供响应式、异步和同步的数据访问。支持高级的 redis 特性,如哨兵、集群、管道、自动连接和 redis 数据模型。官网 ,git 项目地址。
- redisson:在 redis 基础上实现的 Java 驻内存数据网络,提供了一系列的分布式 Java 常用对象,以及分布式服务。官网,git 项目地址 ,git 项目 Wiki 地址。
2、redisson 分布式锁相关源码分析
在分布式系统中涉及到多个服务同时对同一资源数据进行加锁操作,像传统的单机单进程加锁操作 synchronized 等 api 则无法适用(同时加锁都能在自己的服务中加锁成功,对同一数据进行各自服务的操作,造成数据混乱错误),分布式系统中需要一个公共的锁在多个服务中竞争共同使用,使服务串行化,来保证数据的正确性。其中,zookeeper 和 redis 是常见的分布式锁的实现方式,而 redisson 客户端则是 redis 比较出色的一个分布式锁的实现。
redisson 中分布式锁的相关源码实现如下:
2.1、加锁的原子性保证
在 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 方法加锁的逻辑调用如下:
2.2、lua 脚本实现加锁解析
redisson 通过 lua 脚本实现加锁的原子性,其中,lua 脚本的具体实现逻辑解释如下:(主要解释第一部分 if 内容)
第一个 if 中主要参数含义:
- KEYS [1]:锁的名称,即实例中传入的 name=mylock
- ARGV [1]:锁的锁定时间,即过期时间,不设置默认 30s
- ARGV [2]:表示加锁的唯一标示,由 UUID + 线程 ID 组成。一个 Redisson 客户端一个 UUID,表示 Redisson 客户端的唯一性,所以 UUID + 线程 ID 组成了加锁的唯一标识,可以理解为某个客户端的某个线程加锁。
这些参数是通过以下部分传入:
- getRawName ():为回去锁的名称
- leaseTime:传入锁过期的时间,不传则会指定默认的 30s
- getLockName:获取加锁的客户端线程的唯一标志
最后,分析这段 if 操作的中的 lua 代码逻辑:
- 先调用 redis 的 exists 命令判断加锁的 key 是否存在,若不存在,则进入 if。即当前 key 没有被当前任何一个客户端和线程加锁使用,这是第一次,故可以进行加锁使用操作。
- 然后调用 redis 的 hincrby 命令,设置加锁的 key 和加锁的某个线程,加锁次数设置为 1。其中加锁设置比较关键,是实现可重入锁特性的一个关键数据。用 hash 数据结构保存,在 hincrby 命令完成后会形成如下的数据结构
mylock:{
“b983c153-7421-469a-addb-44fb92259a1b:1”:1
} - 最后调用 redis 的 pexpire 命令,设置加锁 key 的过期时间
从这里可以看出,第一次有某个客户端的某个线程来加锁的逻辑还是挺简单的,就是判断有没有人加过锁,没有的话就自己去加锁,设置加锁的 key,再存一下加锁的线程和加锁次数,设置一下锁的过期时间。简单示意图如下:
2.3、为什么最终会设置一个锁的过期时间
在以上流程中,在加锁流程中,会给锁设置一个过期时间,若没设置,则最后会给设置一个系统的默认的 30 秒。之所以这样操作,是为了防止死锁情况的发生。比如,若没有设置锁的过期时间,当业务代码没有执行完宕机了或者释放锁失败后,该线程会一直拿着锁得不到释放,导致死锁的发生。
问题:
若设置了时间,时间设置的过短,导致业务程序还没执行完,锁却释放了,则其他线程可以拿到锁,进行业务数据操作导致数据混乱问题。
解决:
给锁进行自动延迟时间操作
2.4、Redisson 中自动延长加锁时间
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,续约失败,下次定时任务也就不回再执行了。
注意:
线程在加锁后,业务执行完或者出错后都需要最终执行下锁的释放操作,若没有释放锁并且也启动了看门狗机制后,则锁将得不到释放而造成死锁,除非宕机后没有了定时任务的续约操作,到达过期时间自动失效。
2.5、可重入加锁的实现
可重入加锁:
即同一个客户端同一个线程也能多次对同一个锁进行加锁操作。也就是可以同时执行多次 lock 方法,流程也一样,最后也是会调用到 lua 脚本,也就是调用 lua 脚本中的第二个 if 逻辑,如下图:
该段 lua 逻辑:
判断当前已经加锁的 key 对应的加锁线程和要加锁的线程是不是同一个,是的话,就将这个线程加锁次数加 1,则实现了可重入加锁,同时返回 nil。
可重入加锁后加锁 key 对应的值为:
mylock:{“b983c153-7421-469a-addb-44fb92259a1b:1”:2}
加锁 lua 脚本的第二段 if 逻辑示意图:
2.6、如何主动释放锁、以及避免其他线程释放了自己加的锁
线程在加锁处理完业务后,是需要进行最终释放锁操作,若没有释放锁操作,会有以下问题产生:
问题一:若没有设置超时时间,则 Redisson 会启动看门狗机制,则会出现超时时间一直续约,导致该线程的锁无法失效,而产生死锁问题,除非出现宕机情况才能使该锁失效。
问题二:若设置了超时时间,但是超时时间较长,业务程序在很短的时间内执行完,导致剩余这段时间锁得不到释放而产生资源浪费、效率地下问题。
针对这些问题,所以需要业务执行完后主动去释放锁,Redisson 中的释放锁流程:
unlocl () 方法会调用 unlockAsycn,并传入线程 id,实现异步转同步操作
unlockAsync () 最终会调用 unlockInnerAsync 来实现锁的释放逻辑
unlockInnerAsync () 方法的代码逻辑如下:
该 lua 逻辑中:
先判断释放锁的线程是否是加锁的线程,若不是,则直接返回 nil,这可以防止释放了其他线程加的锁
若是需要释放的线程,则进行该线程的加锁次数 - 1 操作,然后拿到剩余加锁次数的 counter 变量值
若 counter>0,说明有重入锁,锁还没有彻底释放完,然后设置下剩余锁的过期时间,然后返回 0
若 counter 不大于 0,说明锁释放完了,然后把锁对应的 key 进行删除,然后发布一个锁已经释放的消息,然后返回 1
流程图:
2.7、实现超时自动释放锁
Redisson 中在 lock 和 tryLock 方法中通过传入锁的失效时间 leaseTime 来实现锁的超时自动释放,其在代码中的体现如下:
其中,是否指定超时时间,最终都会调用 tryAcquireAsync 方法,其中会传入锁失效时间 leaseTime,若未指定失效时间时,传入的 leaseTime=-1,则在 tryAcquireAsync 方法中会重新指定 leaseTime=30s 的默认时间,定时时就以指定的失效时间为准。其中指定与不指定的区别在于,不指定时会开启看门狗机制即 scheduleExpirationRenewal (threadId) 方法;指定时间后不会开启该机制,在达到时间后自动失效。
2.8、如何实现不同线程加锁的互斥
上面我们分析了第一次加锁逻辑和可重入加锁的逻辑,因为 lua 脚本加锁的逻辑同时只有一个线程能够执行(redis 是单线程的原因),所以一旦有线程加锁成功,那么另一个线程来加锁,前面两个 if 条件都不成立,最后通过调用 redis 的 pttl 命令返回锁的剩余的过期时间回去。
这样,客户端就根据返回值来判断是否加锁成功,因为第一次加锁和可重入加锁的返回值都是 nil,而加锁失败就返回了锁的剩余过期时间。
所以加锁的 lua 脚本通过条件判断就实现了加锁的互斥操作,保证其它线程无法加锁成功。
所以总的来说,加锁的 lua 脚本实现了第一次加锁、可重入加锁和加锁互斥的逻辑。
2.9、加锁失败后如何实现阻塞等待加锁
如图,tryAcquire 方法执行最终的加锁逻辑,若该方法执行失败后,返回的 ttl 不为空,则会进入下面的 while (true) 死循环中(自旋),不停的执行 tryAcquire 方法来尝试加锁,所谓的阻塞,其实就是自旋加锁方式。
但是这种阻塞可能会产生问题,因为如果其它线程释放锁失败,那么这个阻塞加锁的线程会一直阻塞加锁,这肯定会出问题的。所以有没有能够可以指定阻塞的时间,如果超过一定时间还未加锁成功的话,那么就放弃加锁的方法。答案就是下面的方法:
2.10、如何实现阻塞等待一定时间还未加锁成功就放弃加锁
超时放弃加锁的方法:
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) 方法的实现。代码如下
其实通过源码就可以看出是怎么实现一定时间之内还未获取到锁就放弃加锁的逻辑,其实相比于一直获取锁,主要是加了超时的判断,如果超时了,那么就退出循环,放弃加锁
2.11、如何实现公平锁
- 什么是公平锁
所谓的公平锁就是指线程成功加锁的顺序跟线程来加锁的顺序是一样,实现了先来先成功加锁的特性,所以叫公平锁。就跟排队一样,不插队才叫公平。
前面几节讲的 RedissonLock 的实现是非公平锁,但是里面的一些机制,比如看门狗都是一样的。 - 公平锁和非公平锁的比较
公平锁的优点是按序平均分配锁资源,不会出现线程饿死的情况,它的缺点是按序唤醒线程的开销大,执行性能不高。非公平锁的优点是执行效率高,谁先获取到锁,锁就属于谁,不会 “按资排辈” 以及顺序唤醒,但缺点是资源分配随机性强,可能会出现线程饿死的情况。 - 如何使用公平锁
通过 RedissonClient 的 getFairLock 就可以获取到公平锁。Redisson 对于公平锁的实现是 RedissonFairLock 类,通过 RedissonFairLock 来加锁,就能实现公平锁的特性,使用代码如下
该 lua 脚本的具体部分逻辑:
当线程来加锁的时候,如果加锁失败了,那么会将线程扔到一个 set 集合中,这样就按照加锁的顺序给线程排队,set 集合的头部的线程就代表了接下来能够加锁成功的线程。当有线程释放了锁之后,其它加锁失败的线程就会来继续加锁,加锁之前会先判断一下 set 集合的头部的线程跟当前要加锁的线程是不是同一个,如果是的话,那就加锁成功,如果不是的话,那么就加锁失败,这样就实现了加锁的顺序性。
2.12、如何实现读写锁
在实际的业务场景中,其实会有很多读多写少的场景,那么对于这种场景来说,使用独占锁来加锁,在高并发场景下会导致大量的线程加锁失败,阻塞,对系统的吞吐量有一定的影响,为了适配这种读多写少的场景,Redisson 也实现了读写锁的功能。
读写锁的特点:
- 读与读是共享的,不互斥
- 读与写互斥
- 写与写互斥
Redisson 中读写锁的代码:
Redisson 通过 RedissonReadWriteLock 类来实现读写锁的功能,通过这个类可以获取到读锁或者写锁,所以真正的加锁的逻辑是由读锁和写锁实现的。
那么 Redisson 是如何具体实现读写锁的呢?
前面说过,加锁成功之后会在 redis 中维护一个 hash 的数据结构,存储加锁线程和加锁次数。在读写锁的实现中,会往 hash 数据结构中多维护一个 mode 的字段,来表示当前加锁的模式。
所以能够实现读写锁,最主要是因为维护了一个加锁模式的字段 mode,这样有线程来加锁的时候,就能根据当前加锁的模式结合读写的特性来判断要不要让当前来加锁的线程加锁成功。
- 如果没有加锁,那么不论是读锁还是写锁都能加成功,成功之后根据锁的类型维护 mode 字段。
- 如果模式是读锁,那么加锁线程是来加读锁的,就让它加锁成功。
- 如果模式是读锁,那么加锁线程是来加写锁的,就让它加锁失败。
- 如果模式是写锁,那么加锁线程是来加写锁的,就让它加锁失败(加锁线程自己除外)。
- 如果模式是写锁,那么加锁线程是来加读锁的,就让它加锁失败(加锁线程自己除外)。
2.13、如何实现批量加锁(联锁)
批量加锁的意思就是同时加几个锁,只有这些锁都算加成功了,才是真正的加锁成功。
比如说,在一个下单的业务场景中,同时需要锁定订单、库存、商品,基于这种需要锁多种资源的场景中,Redisson 提供了批量加锁的实现,对应的实现类是 RedissonMultiLock。
Redisson 提供了批量加锁使用代码如下。
批量加锁的代码源码如下:
就是根据顺序去依次调用传入 mylock1、mylock2、mylock3 加锁方法,然后如果都成功加锁了,那么 multiLock 就算加锁成功。
2.14、Redis 分布式锁存在的问题
对于单 Redis 实例来说,如果 Redis 宕机了,那么整个系统就无法工作了。所以为了保证 Redis 的高可用性,一般会使用主从或者哨兵模式。但是如果使用了主从或者哨兵模式,此时 Redis 的分布式锁的功能可能就会出现问题。
举个例子来说,假如现在使用了哨兵模式,如图:
基于这种模式,Redis 客户端会在 master 节点上加锁,然后异步复制给 slave 节点。
但是突然有一天,因为一些原因,master 节点宕机了,那么哨兵节点感知到了 master 节点宕机了,那么就会从 slave 节点选择一个节点作为主节点,实现主从切换,如图:
这种情况看似没什么问题,但是不幸的事发生了,那就是客户端对原先的主节点加锁,加成之后还没有来得及同步给从节点,主节点宕机了,从节点变成了主节点,此时从节点是没有加锁信息的,如果有其它的客户端来加锁,是能够加锁成功的。。。。
那么如何解决这种问题呢?Redis 官方提供了一种叫 RedLock 的算法,Redisson 刚好实现了这种算法
2.15、如何实现 RedLock 算法
RedLock 算法:
在 Redis 的分布式环境中,我们假设有 N 个 Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。之前我们已经描述了在 Redis 单实例下怎么安全地获取和释放锁。我们确保将在每(N) 个实例上使用此方法获取和释放锁。在这个样例中,我们假设有 5 个 Redis master 节点,这是一个比较合理的设置,所以我们需要在 5 台机器上面或者 5 台虚拟机上面运行这些实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
- 获取当前 Unix 时间,以毫秒为单位
- 依次尝试从 N 个实例,使用相同的 key 和随机值获取锁。在步骤 2,当向 Redis 设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个 Redis 实例。
- 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)
- 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个 Redis 实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)
Redisson 对 RedLock 算法的实现
RedissonRedLock 加锁过程如下:
- 获取所有的 redisson node 节点信息,循环向所有的 redisson node 节点加锁,假设节点数为 N,例子中 N 等于 5。一个 redisson node 代表一个主从节点
- 如果在 N 个节点当中,有 N/2 + 1 个节点加锁成功了,那么整个 RedissonRedLock 加锁是成功的
- 如果在 N 个节点当中,小于 N/2 + 1 个节点加锁成功,那么整个 RedissonRedLock 加锁是失败的
- 如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败
RedissonRedLock 底层其实也就基于 RedissonMultiLock 实现的,RedissonMultiLock 要求所有的加锁成功才算成功,RedissonRedLock 要求只要有 N/2 + 1 个成功就算成功
参考文章:
https://blog.youkuaiyun.com/weixin_45630885/article/details/125088885
https://mp.weixin.qq.com/s/EhucmYblfrRxbAuJTdPlfg
https://github.com/redisson/redisson/wiki/
http://redis.cn/topics/distlock.html