Redisson 实现分布式锁原理浅析

在上一文中分布式锁的三种实现方案总结了目前业界常见的实现分布式锁的三种方案:

  • 1、基于数据库表排他锁
  • 2、基于Redis的setNX命令+过期时间+lua脚本
  • 3、基于Zookeerper的临时结点。

本文我们继续通过源码分析一下Redisson实现分布式锁的原理。

回顾:Redis 实现分布式锁主要步骤

  1. 指定一个key作为锁标记,存入 Redis 中,并且指定一个 唯一的用户标识 作为 value。
  2. 当 key 不存在时才能设置值,确保同一时间只有一个客户端进程获得锁,满足 互斥性 特性。
  3. 给key设置一个过期时间,防止因系统异常导致没能删除这个 key,满足 防死锁 特性。
  4. 当处理完业务之后需要清除这个 key 来释放锁,清除 key 时需要校验 value 值,需要满足 只有加锁的人才能释放锁

Redisson实现分布式锁

下面从加锁机制、锁互斥机制、Watch dog 机制、可重入加锁机制、锁释放机制等五个方面对 Redisson 实现分布式锁的底层原理进行分析。

1、加锁原理

Redisson加锁其实是通过一段 lua 脚本实现的,如下:

我们可以把这一段lua脚本拿出来分析一下:

// 检查是否key已经被占用,如果没有则设置超时时间和唯一标识,初始化value=1
"if (redis.call('exists', KEYS[1]) == 0) then " +         
        "redis.call('hincrby', KEYS[1], ARGV[2], 1);" +   
        "redis.call('pexpire', KEYS[1], ARGV[1]);" +      
        "return nil;" +                                  
 "end;" +                                                
//锁重入,需要判断锁的key field 都一直情况下 value 加一                         
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then" +
        "redis.call('hincrby', KEYS[1], ARGV[2], 1);" + 
         //锁重入重新设置超时时间 
        "redis.call('pexpire', KEYS[1], ARGV[1]);" +      
        "return nil; " +                                  
 "end; " +
//返回剩余的过期时间
"return redis.call('pttl', KEYS[1]);"                  

在Redisson中,加锁需要以下三个参数:

(1)KEYS[1] :需要加锁的key,这里需要是字符串类型。这个参数就是我们给Redisson加锁时传入的那个key,比如:

//create Lock
RLock lock = redisson.getLock("lock");

(2)ARGV[1] :锁的超时时间,防止死锁。默认时间是30s,这个也可以在加锁的时候设置

(3)ARGV[2]:锁的唯一标识,id(UUID.randomUUID()) + “:” + threadId,比如:285475da-9152-4c83-822a-67ee2f116a79:52。

通过这段脚本可以看到,Redsson在实现分布式锁的时候没有使用SETNX,而是使用了hincrby这个命令。

命令语法:HINCRBY key filed increment

这个命令可以为哈希表key中的域field的值加上增量increment。增量也可以为负数,相当于对给定域进行减法操作。如果 key 不存在,一个新的哈希表被创建并执行 HINCRBY 命令。如果域 field 不存在,那么在执行命令前,域的值被初始化为 0 。

上面这一段加锁的 lua 脚本的作用是:第一段 if 判断语句,就是用 exists product 命令判断一下,如果你要加锁的那个锁 key 不存在的话,你就进行加锁。如何加锁呢?使用 hincrby 命令设置一个 hash 结构,类似于在 Redis 中使用下面的操作:

接着会执行 pexpire myLock 30000 命令,设置 myLock 这个锁 key 的生存时间是 30 秒。到此为止,加锁完成。

有的小伙伴可能此时就有疑问了,如果此时有第二个客户端请求加锁呢? 这就是下面要说的锁互斥机制。


2、锁互斥机制

此时,如果客户端 2 来尝试加锁,会如何呢?首先,第一个 if 判断会执行 exists product,发现 product这个锁 key 已经存在了。接着第二个 if 判断,判断一下,product锁 key 的 hash 数据结构中,是否包含客户端 2 的 ID,这里明显不是,因为那里包含的是客户端 1 的 ID。所以,客户端 2 最后会执行:

//返回剩余的过期时间
"return redis.call('pttl', KEYS[1]);"  

返回的一个数字,这个数字代表了 product这个锁 key 的剩余生存时间。

了解了上面这些知识点以后,接下来分析一下阻塞锁非阻塞锁的逻辑。

阻塞锁

从lock()方法开始追溯源码,RedissonLock类中有的两个重载的方法分别来自java的Lock接口和Redsson的RLock接口,他们处理逻辑都在下面那个私有的lock()方法中

Redisson阻塞锁上锁的逻辑:

可以看到代码很长,但是只要你清楚如何使用Redis实现一把分布式锁,看懂应该不难。大致流程如下:当一个线程尝试获取锁时,首先会尝试调用tryAcquire()方法尝试获取锁,如果获取锁成功就会返回null(这个在lua脚本中可以看到,成功加锁会返后nil)。如果首次尝试获取锁失败,表示已经有别的线程在使用锁,那就会进入到自旋过程中,直到加锁成功返回null。

非阻塞锁

流程分析:

  1. 尝试获取锁,返回 null 则说明加锁成功,返回一个数值,则说明已经存在该锁,ttl 为锁的剩余存活时间。
  2. 如果此时客户端 2 进程获取锁失败,那么使用客户端 2 的线程 id(其实本质上就是进程 id)通过 Redis 的 channel 订阅锁释放的事件。如果等待的过程中一直未等到锁的释放事件通知,当超过最大等待时间则获取锁失败,返回 false,也就是第 286 行代码。如果等到了锁的释放事件的通知,则开始进入一个不断重试获取锁的循环。
  3. 循环中每次都先试着获取锁,并得到已存在的锁的剩余存活时间。如果在重试中拿到了锁,则直接返回。如果锁当前还是被占用的,那么等待释放锁的消息,具体实现使用了 JDK 的信号量 Semaphore 来阻塞线程,当锁释放并发布释放锁的消息后,信号量的 release() 方法会被调用,此时被信号量阻塞的等待队列中的一个线程就可以继续尝试获取锁了。

特别注意:以上过程存在一个细节,这里有必要说明一下,也是分布式锁的一个关键点:当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题


3、Watch dog机制

客户端 1 加锁的锁 key 默认生存时间才 30 秒,如果超过了 30 秒,客户端 1 还想一直持有这把锁,怎么办呢?

Redisson 提供了一个续期机制, 只要客户端 1 一旦加锁成功,就会启动一个 Watch Dog。

从以上源码我们看到 leaseTime 必须是 -1 才会开启 Watch Dog 机制,也就是如果你想开启 Watch Dog 机制必须使用默认的加锁时间为 30s。如果你自己自定义时间,超过这个时间,锁就会自定释放,并不会延长。

Watch Dog 机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个 RedissonLock.EXPIRATION_RENEWAL_MAP里面,然后每隔 10 秒 (internalLockLeaseTime / 3) 检查一下,如果客户端 1 还持有锁 key(判断客户端是否还持有 key,其实就是遍历 EXPIRATION_RENEWAL_MAP 里面线程 id 然后根据线程 id 去 Redis 中查,如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间。

异步续命也是通过一段lua脚本实现的,lua脚本如下:

注意:这里有一个细节问题,如果服务宕机了,Watch Dog 机制线程也就没有了,此时就不会延长 key 的过期时间,到了 30s 之后就会自动过期了,其他线程就可以获取到锁。


4、可重入加锁机制

Redisson支持重入锁,比如下面的代码:

public boolean submit(int num) {                               
    RLock lock = redisson.getLock(MUTEX_LOCK_KEY);             
    lock.lock();   //阻塞式锁                                      
    try {                                                      
        lock.lock();                                           
        Integer stock = (Integer) redisUtils.get("stock");     
        if (stock > 0 && num <= stock) {                       
            //下单                                               
            stock = stock - num;                               
            Thread.sleep(10000);                               
            redisUtils.set("stock", stock);                    
            System.out.print("库存充足。下单成功。剩余库存:" + stock + "\n");
        } else {                                               
            System.out.print("库存不足,无法下单。库存:" + stock + "\n");  
        }                                                      
    } catch (InterruptedException e) {                         
        e.printStackTrace();                                   
    } finally {                                                
        lock.unlock();                                         
        lock.unlock();                                         
    }                                                          
    return true;                                               
}                                                              

我们再来分析一下那段加锁的lua脚本:

"if (redis.call('exists', KEYS[1]) == 0) then " +
   "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
   "redis.call('pexpire', KEYS[1], ARGV[1]); " +
   "return nil; " +
   "end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
    "return nil; " +
    "end; " +
"return redis.call('pttl', KEYS[1]);"

第一个 if 判断肯定不成立,exists product 会显示锁 key 已经存在。

第二个 if 判断会成立,因为 product的 hash 数据结构中包含的那个 ID 即客户端 1 的 ID,此时就会执行可重入加锁的逻辑,使用:hincrby product 285475da-9152-4c83-822a-67ee2f116a79:52 1 对客户端 1 的加锁次数加 1。锁重入的逻辑演示如下:

看到这里,小伙伴就应该明白了:Redisson分布式锁时使用Redis hash结构的incrby命令来实现的,其中key表示锁的名字,filed表示客户端唯一标识(UUID:线程id),value表示锁重入次数,每重入一次就计数就加1


5、锁释放机制

执行lock.unlock()就可以释放锁,我们来看一下释放锁的流程代码:

大致流程:

  • 调用unlockInnerAsync异步释放锁
  • 关闭该线程的Watch Dog,取消异步更新锁的过期时间

上面得代码核心调用就是unlockInnerAsync,这个方法也是通过一段lua脚本实现实际解锁逻辑的:

lua脚本的流程:

  1. 判断要释放的锁标志key是否存在,如果不存在返回nil;
  2. 如果要释放的锁标志key还存在,使用hincrby KEYS[1] ARGV[3] -1减给锁重入次数减1
  3. 如果锁的重入次数还是大于0就返回锁的过期时间;否者删除key,并且通过Redis的发布机制通知阻塞的进程去竞争锁

从代码来看,释放锁的步骤也主要分三步:

  1. 删除锁(这里注意可重入锁,在上面的脚本中有详细分析)。
  2. 广播释放锁的消息,通知阻塞等待的进程(向通道名为 redisson_lock__channel publish 一条 UNLOCK_MESSAGE 信息)。
  3. 关闭这个线程Watch Dog ,即将 RedissonLock.EXPIRATION_RENEWAL_MAP 里面的线程 id 删除,并且 cancel 掉 Netty 的那个定时任务线程。

方案优点

  1. Redisson 通过 Watch Dog 机制很好的解决了锁的续期问题。
  2. 和 Zookeeper 相比较,Redisson 基于 Redis 性能更高,适合对性能要求高的场景。
  3. 通过 Redisson 实现分布式可重入锁,比原生的 SET mylock userId NX PX milliseconds + lua 实现的效果更好些,虽然基本原理都一样,但是它帮我们屏蔽了内部的执行细节。
  4. 在等待申请锁资源的进程等待申请锁的实现上也做了一些优化,减少了无效的锁申请,提升了资源的利用率。

方案缺点

  1. 使用 Redisson 实现分布式锁方案最大的问题就是如果你对某个 Redis Master 实例完成了加锁,此时 Master 会异步复制给其对应的 slave 实例。但是这个过程中一旦 Master 宕机,主备切换,slave 变为了 Master。接着就会导致,客户端 2 来尝试加锁的时候,在新的 Master 上完成了加锁,而客户端 1 也以为自己成功加了锁,此时就会导致多个客户端对一个分布式锁完成了加锁,这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。所以这个就是 Redis Cluster 或者说是 Redis Master-Slave 架构的主从异步复制导致的 Redis 分布式锁的最大缺陷(在 Redis Master 实例宕机的时候,可能导致多个客户端同时完成加锁)

最后一张图总结:

参考
  • [1] https://zhuanlan.zhihu.com/p/135864820
  • [2] https://juejin.im/post/5e828328f265da47cd355a5d#heading-6

留言区

还能输入500个字符