Redisson读写锁加锁机制分析

redis的读写有锁吗 redisson读写锁_redis的读写有锁吗

前几篇说了 Redisson 的可重入锁和公平锁是如何实现的

这里来讲一下 Redisson 的读写锁是如何实现的,这里在具体学习源码的时候,不要去具体扣他每一行的命令到底是执行的什么操作,扣这些细节是没有意义的

那么我们要学习源码中的哪些内容呢?

主要是要学习它的 设计思想 ,也就是为了实现功能做了哪些设计,以及实现的 流程 ,了解原理就好了!

redis的读写有锁吗 redisson读写锁_客户端_02

读锁加锁流程

这里我们先来看读写锁中的 读锁 的加锁流程,首先还是先从调用入口进入:

public static void main(String[] args) throws InterruptedException {
    Config config = new Config();
    config.useSingleServer()
            .setAddress("redis://127.0.0.1:6379")
            .setPassword("123456")
            .setDatabase(0);
    //获取客户端
    RedissonClient redissonClient = Redisson.create(config);
    // 获取读写锁
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("11_come");
    // 获取读锁
    readWriteLock.readLock().lock();
    //关闭客户端
    redissonClient.shutdown();
}

这里其实 lock() 方法还是进入了 RedissonLock 的 lock() 方法,而读锁和之前说的可重入锁的区别就在于最底层加锁的方法,也就是最终的 tryLockInnerAsync() 方法不同,接下来进入 读写锁的加锁方法 查看:

redis的读写有锁吗 redisson读写锁_读锁_03

整体的加锁方法如上,这里先说参数含义,之后来说一下这个 lua 脚本在做什么事情:

  • KEYS[1] :读写锁的名称, 也就是 11_come
  • KEYS[2] :读锁超时时间的前缀,用来表示每一把读锁,也就是 {11_come}:23d25595-6532-4105-a24b-98fea0997bc5:1:rwlock_timeout
  • ARGV[1] :锁的释放时间,默认是 30000ms ,也就是 30s
  • ARGV[2] :锁的标识,也就是当前客户端线程的唯一标识,UUID+threadId ,即 23d25595-6532-4105-a24b-98fea0997bc5:1

接下来在说 lua 脚本之前,先说一下 读写锁的特性 ,即:

  • 读锁和读锁之间不互斥
  • 读锁和写锁之间互斥
  • 写锁和写锁之间互斥
读锁分支1

那么 Redisson 为了实现读写锁的这三个特性,一定要有一个标识,来表明当前加的锁是读锁还是写锁对吧,这样才可以继续来判断是否要进行互斥操作

redis的读写有锁吗 redisson读写锁_读写锁_04

在读锁加锁的第一个分支中,可以看到先去获取 KEYS[1] 中的 mode 属性值了,这个属性值就存储了当前加的锁是读锁还是写锁

如果发现 mode == false 的话,说明没有上锁,当前客户端就可以成功上锁

这里在客户端上锁时,设置了 3 个属性值,加锁之后,在 Redis 中存储的锁信息如下图:

redis的读写有锁吗 redisson读写锁_分布式_05

  • hset KEYS[1] mode read) :目的是表明当前锁是写锁还是读锁
  • hset KEYS[1] ARGV[2] 1):将当前客户端线程的 lockName:UUID+threadId 设置到锁的哈希结构中,value 是该锁的重入次数
  • set KEYS[2] .. ':1' 1):设置一个键值对,用来 标识每一把锁的超时时间 。这里的 .. 在 lua 脚本中的含义是进行字符串拼接,因此 key 是 "KEYS[2]:1" ,即 {lockName}:UUID:threadId:{number},这里的 number 表示是重入的第几把锁,如果是第二次重入的锁,number 就为 2
    这里为什么要设置这个键值对呢,是为了避免在锁释放之后,读写锁的超时时间和读锁的超时时间出现不一致,具体解释可以看后边的 【键值对对每个锁的超时时间标识】
读锁分支2

接下来说一下读锁加锁的【分支2】

redis的读写有锁吗 redisson读写锁_读锁_06

在【分支1】中如果发现锁并没有被其他客户端线程获取的话,就会直接上锁

如果锁已经被其他客户端线程获取时,此时有两种情况:

  • 其他客户端线程获取的是读锁,读锁之间不互斥,因此可以获取锁
  • 其他客户端获取的锁是写锁,并且发现当前客户端线程的信息在锁的哈希结构中存在,说明是发生了写锁重入

那么在这两种情况下都可以获取锁,在这个【分支 2】中就先通过 hincrby 来对锁的重入次数 +1,并且通过 set 命令将当前线程的信息设置到 Redis 中

这里 set 命令是存储了 key,1 这个键值对这个 key 其实就是 KEYS[2] .. ':' .. ind ,在 lua 脚本中 .. 的含义就是进行字符串拼接,因此这里的 key 就是 KEYS[2]:ind ,这里 ind 的含义是当前锁是重入的第几次,比如是第二次重入,那么 ind = 2

可以看到,整个读锁加锁的流程是比较简单的,主要是了解一下这里对于读锁加锁有在 Redis 中存储的信息

这里再啰嗦一下 Redis 中存储的锁信息为:

  • 读写锁的哈希结构:包括了读写锁的类型、每把锁的唯一标识以及重入次数
  • 键值对:主要作用就是标识每一把读锁的超时时间

根据实际加锁案例来学习读锁加锁的流程

那么假设有一个客户端先来 加读锁 ,那么加锁之后,Redis 中存储的锁信息如下:

redis的读写有锁吗 redisson读写锁_分布式_05

可以看到在 KEYS[1] 这个哈希结构中,ARGV[2] 这个键值对主要来标识当前读锁的重入次数

而下边的 KEYS[2]:1 这个键值对则主要来对每一个锁的超时时间进行标识,通过这个键值对就可以获取不同读锁的剩余存活时间

上边说了一个客户端来加锁的情况,假设此时有另一个客户端 B 来加读锁,那么此时 Redis 中的锁结构将变为:

redis的读写有锁吗 redisson读写锁_分布式_08

可以看到这里客户端 A 和 B 的分布式锁的 UUID 不同,因此在读写锁 11_come 的哈希结构中出现了两把锁的标识

那么如果此时客户端 A 再次加锁,也就是发生同一把锁的重入,此时 Redis 的锁结构将变为:

redis的读写有锁吗 redisson读写锁_读写锁_09

可以看到当发生 锁重入 的时候,会先在锁的哈希结构中,将锁的重入次数 + 1

并且新增加一个键值对 "{11_come}:23d25595-6532-4105-a24b-98fea0997bc5:1:rwlock_timeout:2"(客户端A的KEYS[2]:2): "1" ,这里是客户端 A 重入的

来表示重入的这把锁的超时时间

在 Redisson 中,哈希结构中的键值对主要用来存储每把锁的重入次数,而外边的键值对主要用来存储每把锁的超时时间

那么到了这里应该对读锁的加锁流程,以及在 Redis 中锁信息的存储结构就有比较清楚的了解了

为什么要对每个锁的超时时间进行标识?

这里再说一下为什么要给每一个客户端线程都创建一个键值对来标识每个锁的超时时间,如下:

redis的读写有锁吗 redisson读写锁_分布式_10

这个键值对的超时时间其实只有在读锁释放的时候才使用得到,接下来举一个例子:

比如说客户端 A 创建一把读锁,此时读锁超时时间默认为 30s,此时读写锁 11_come 的超时时间为 30s,并且客户端 A 创建的这把读锁的超时时间也为 30s,如下:

redis的读写有锁吗 redisson读写锁_分布式_11

假设此时客户端 B 在 5s 后来创建一把读锁,此时会将读写锁 11_come 的超时时间重置为 30s,并且客户端 B 创建的这把读锁的超时时间也为 30s,由于 B 在 5s 后来加锁了,因此客户端 A 的锁此时超时时间为 25s

redis的读写有锁吗 redisson读写锁_客户端_12

那么假如客户端 B 的读锁释放了,此时只剩下客户端 A 的读锁,此时客户端 A 的读锁超时时间为 25s,而读写锁 11_come 的超时时间为 30s,这显然不合理,如下:

redis的读写有锁吗 redisson读写锁_读写锁_13

因此 Redisson 考虑到了这种情况,那么为了保证在锁释放之后,可以让 读写锁 11_come每一把读锁的超时时间 保持一直,因此需要记录下来每一把读锁的超时时间,也就是通过 "{11_come}:23d25595-6532-4105-a24b-98fea0997bc5:1:rwlock_timeout:1" 这个 key 来记录

因此在客户端 A 的读锁释放的时候,会去将读写锁 11_come 的超时时间设置为客户端 A 的读锁超时时间,即 25s

redis的读写有锁吗 redisson读写锁_读锁_14

读锁的锁续期

Redisson 的读锁 重写了锁续期 的方法,如下:

redis的读写有锁吗 redisson读写锁_客户端_15

这里先说一下 lua 脚本中参数的含义:

  • KEYS[1] :读写锁的名称,即 11_come
  • KEYS[2] :key 的前缀,即 {11_come}
  • ARGV[1] :锁超时时间,默认 30s
  • ARGV[2] :当前客户端线程的唯一标识,UUID+threadId,即 9f459868-d210-43c6-afb5-d1bce1fa3472:1

在这个锁续期方法中,其实就是对 读写锁 以及 每一把读锁 进行续期,整个流程并不算复杂,通过看注释应该就可以理解

读锁的解锁流程

读锁的解锁就不打算细说了,如果加锁看明白了,读锁也是不太难的,流程就是减少重入次数,如果重入次数为 0 的话,就将读写锁的哈希结构删除掉即可

不过这里要说一下读锁在解锁时,会使用 publish 发布一条消息,这里说一下发布的这个消息有什么作用

其实不只是在读锁解锁会 publish 消息,在公平锁、可重入锁解锁时都会发布,这里发布消息的目的就是让其他的客户端线程可以知道锁已经被释放了,避免等待较长的时间

发布消息

首先,看一下在读锁解锁时,如何发布消息:

redis的读写有锁吗 redisson读写锁_读锁_16

可以看到,在解锁的两个地方发布了消息:

  • 第一个地方:发现 mode == false ,也就是锁已经被释放了,就发布一条通知
  • 第二个地方:解锁成功,在删除锁的哈希结构后,通过 publish 发布一条通知

这里发布的通知的命令是:publish [channel] [message]

channel 的值是 KEYS[2] ,即 redisson_rwlock:{11_come} ,redisson_rwlock 是固定前缀,{11_come} 就是我们这把读写锁的名称,因此这里发布的消息是放在这个通道中了

message 的值是 ARGV[1] ,即 LockPubSub.UNLOCK_MESSAGE ,其实就是一个 Long 值,值为 0

订阅消息

那么既然发布了通知,肯定就要订阅通知,上边已经说了,这个通知的作用就是在线程加锁失败的时候,会进入等待,那么在等待之前会先去这个通道中订阅消息,这样在其他锁被释放之后,等待锁的线程就可以收到消息通知,直接去获取锁,就不需要一直等待了

那么订阅通知的地方就在 RedissonLock 的 lock 方法中:

redis的读写有锁吗 redisson读写锁_redis的读写有锁吗_17

当发现锁获取失败之后,先订阅通道,再去 while 循环中等待获取锁!

至此读写锁中,读锁的加锁、锁续期、解锁就已经说完了,写锁相对于读锁来讲比较简单一些,都是重复性的内容,这里就不再赘述!