一、前言

分布式锁在实际工作中的应用还是比较多的,其实现方式也有很多种,常见的有基于数据库锁、基于zookeeper、基于redis的,今天我们来讲下基于redis实现的分布式锁。

redisson是一个redis客户端框架,提供了分布式锁的功能特性,这里我们通过解析redisson的源码来分析它是如何基于redis来实现分布式锁的?

二、源码解析

2.1 样例代码

下面是一个分布式锁的简单样例代码

// 初始化配置,创建Redisson客户端
Config config = new Config();
config.setCodec(new JsonJacksonCodec())
        .useSingleServer()
        .setAddress("redis://192.168.10.131:6379");
RedissonClient client = Redisson.create(config);

// 获取分布式锁
RLock lock = client.getLock("myLock");
lock.lock();
System.out.println(Thread.currentThread().getId() + ": 获取到分布式锁");
try {
    Thread.sleep(60 * 1000);
} catch (Exception e) {
    e.printStackTrace();
} finally {
    // 解锁
    lock.unlock();
}

上面的样例代码比较简单,通过redisson客户端获取一个分布式锁,该分布式锁的key为myLock,睡眠60秒之后释放锁。这里比较重要的是lock()方法,该方法是获取锁的具体步骤,所以接下来详细解析一下该方法

2.2 整体流程

获取锁的流程图如下

具体流程为:

  • 第一次尝试获取锁,如果获取到锁,直接返回。如果未获取到锁,返回锁的剩余过期时间ttl
  • 当未获取到锁时,订阅频道redisson_lock__channel:{myLock}(订阅该频道的作用是,当该分布式锁被其他拥有者所释放时,会往该订阅频道发送一个解锁消息UNLOCK_MESSAGE,这时当前等待该分布式锁的线程会中断等待,并再次尝试获取锁
  • 开启死循环,尝试获取锁,如果未获取到锁,拿到锁的剩余过期时间,并等待该锁的剩余过期时间(中间过程中如果订阅频道有解锁消息UNLOCK_MESSAGE,会提前中断等待,继续循环),直到获取锁,退出循环
  • 获取到锁之后,取消订阅频道

源码如下

private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
        long threadId = Thread.currentThread().getId();
        // 1、第一次尝试获取锁,ttl为null,表示获取到锁,直接return
        Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
        if (ttl == null) {
            return;
        }

        // 2、订阅频道redisson_lock__channel:{myLock}
        CompletableFuture<RedissonLockEntry> future = subscribe(threadId);
        pubSub.timeout(future);
        RedissonLockEntry entry;
        if (interruptibly) {
            entry = commandExecutor.getInterrupted(future);
        } else {
            entry = commandExecutor.get(future);
        }

        try {
            // 3、开启循环
            while (true) {
                // 再次尝试获取锁,ttl为null,表示获取到锁,退出循环
                ttl = tryAcquire(-1, leaseTime, unit, threadId);
                if (ttl == null) {
                    break;
                }

                // 如果ttl大于等于0
                if (ttl >= 0) {
                    try {
                        // 等待ttl时间 或者 接收到解锁消息
                        entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                    } catch (InterruptedException e) {
                        ...
                    }
                } else {    // 如果ttl小于0,说明该锁未设置过期时间,等待接收解锁消息
                    if (interruptibly) {
                        entry.getLatch().acquire();
                    } else {
                        entry.getLatch().acquireUninterruptibly();
                    }
                }
            }
        } finally {
            // 退出订阅频道redisson_lock__channel:{myLock}
            unsubscribe(entry, threadId);
        }
    }

2.3 锁的获取

那么如何表示当前线程获取到锁

redisson中的分布式锁实质上是个hash结构的数据,假设锁的名称为myLock,那么当某个线程获取到锁之后,会在这个hash结构里设置一个hashkey,其为 【连接管理器id】 : 【线程id】,如下图

redisson通过执行lua脚本来获取锁,lua脚本如下

// 如果锁不存在,则成功获取到锁,设置锁的过期时间,并返回nil
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;
// 如果锁已存在,判断是否是当前线程已经获取到,如果是,对应的值加1
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]);

该lua脚本的主要作用是

  1. 如果锁不存在,则成功获取到锁,设置锁的过期时间(lockWatchdogTimeout默认是30秒),并返回nil
  2. 如果锁已存在,判断是否是当前线程已经获取到,如果是,对应的值加1
  3. 否则表示未获取到锁,返回锁的过期时间

这里的第一步为什么要设置锁的过期时间?其实是为了当锁的拥有者挂了之后,避免锁一直存在,导致其他应用永远无法获取到锁

2.4 锁续期

那么既然锁设置了过期时间,那很自然地想到,如果在锁过期的这段时间内,拥有锁的线程还未执行完业务逻辑,这时锁自动过期,导致其他应用也获取到了锁,从而产生逻辑错误。所以引入了锁续期

当获取到锁时,redisson会启动一个看门狗,该看门狗每隔 lockWatchdogTimeout / 3秒续期一次锁(假设lockWatchdogTimeout默认为30秒,则每隔10秒续期锁),源码如下

private void renewExpiration() {
    ...
    
    // 1、创建一个10秒后执行的延迟任务
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ...
            
            // 2、执行续期锁的lua脚本
            CompletionStage<Boolean> future = renewExpirationAsync(threadId);
            future.whenComplete((res, e) -> {
                ...
                
                // 3、res为true,代表锁续期成功,重新调用该方法,继续创建延迟任务
                // false表示锁续期失败
                if (res) {
                    renewExpiration();
                } else {
                    cancelExpirationRenewal(null);
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
}

续期锁的lua脚本如下:

// 如果锁存在这个hashkey,重新设置锁的过期时间
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end;
return 0;

到这里,redisson实现分布式锁的源码解析就结束了。

三、总结

redisson的源码中大量使用了异步编程,这导致阅读源码的难度系数较高,这里我也只是大概整理了一下,有问题的同学可以互相讨论一下或自行查阅源码。


kamier
1.5k 声望493 粉丝