年轻人,看看 Redisson 分布式锁—可重入锁吧!太重要了

作者 | 李祥    责编 | 张文

来源 | 企鹅杏仁技术站(ID:xingren-tech)

引言

作为后端开发,对于所谓的线程安全、高并发等一系列名词肯定都不会陌生,相关的一些概念及技术框架是面试中的宠儿,也是工作中解决一些特定场景下的技术问题的银弹。今天我们就来聊聊这些银弹中的其中一枚——分布式锁,更确切的说是分布式锁的其中一种轮子:Redisson 的可重入锁——基于 redis 实现的分布式锁。

俗话说得好:面试造火箭,工作拧螺丝(手动狗头)。

分布式锁大家应该也都不陌生,在解决譬如分布式服务下库存超卖等类似的场景下很常见,大家也应该都学习和使用过相关的框架,或者自己实现过类似的分布式锁。但一个完备的分布式锁实现应该考虑哪些问题,如何优雅且全面的实现一个分布式锁,以及这些实现背后的原理,这些可能才是我们需要考虑的。

就我而言,自己造不了火箭,但学习一下火箭是怎么造的还是蛮有意思的,说不定哪天螺丝拧多了需要自己造火箭呢。退一步讲,火箭造出来的过程也是一个个螺丝拧出来的嘛。只要屠龙刀在手,一刀 999,现在能杀鸡,将来也能宰牛, skr skr ~


分布式锁概览

2.1 锁是干嘛的?

谈到锁,第一印象就是 Java 本身支持的一些加锁,不管是是 synchronized 还是并发包下 Lock 的一些实现如 ReentrantLock,更甚一些无锁机制譬如并发包下的一些原子类如 AtomicInteger,都在不同粒度上保证了线程安全。而所谓的线程实现,归根到底也就是保证共享区或者共享变量的安全而已,而聊到线程安全,立马就能联想到三个特性:

  • 原子性

  • 可见性

  • 有序性

对于 Java 语言本身的一些保证线程安全的实现如 synchronized、Lock、原子类更甚至一些在此基础上的一些线程安全的集合是如何在不同粒度上保证原子性、可见性、有序性的这里就不拆开了,也不是本篇要讨论的。更为重要的,我们回到共享资源这个概念,对于单点下的应用,为什么一提到线程安全就是三大特性,为什么这三个特性可以保证线程安全。我的通俗理解是:

  • 对于共享资源的加锁或者修改(原子类)是原子的,不可拆分的,那么加锁或者修改本身就要么成功要么失败,不会有中间过程的误判

  • 而可见性是保证共享资源对所有线程可见,这个也没什么好解释的,只有对共享资源的任何修改都可感知,才不会在不同线程下决策不同

  • 有序性的前提是多线程,单线程下的指令重排不会改变串行结果,但多线程下的指令重排下对共享区域的修改会相互干扰,所以保证多线程的有序性也是必须的

加锁是手段,保证共享资源的安全才是目的,单点下 Java 是通过原子性、可见性、有序性来实现的。

2.2 分布式锁需要考虑什么?

前面我们废话了一堆,可以看出来锁的目的:保证共享资源的安全。现在不考虑单点锁还是分布式锁:我们考虑两个问题:

  • 共享资源在哪里?

  • 是否保证了原子性、有序性、可见性加锁就一定是完备的

对于第一个问题,在单点情况下,我们可以共享资源是一个实例变量或者是一段对资源进行操作的代码,这些资源在不同线程下共享,而这里的线程都是在一个 JVM 进程中。那么如果是分布式系统呢?举个例子:某个商品的总库存、某个优惠券批次的总数量,这些是共享资源吗?当然是,只是这里共享这些资源的对象从一个 JVM 进程下的多个线程变成了多个服务节点下的多个 JVM 进程下的多个线程而已。下面通过两张图我们可以对比一下:

a. 单进程下共享资源

b. 分布式系统下共享资源

可以看出来,在单个 JVM 进程中,共享资源只是在同一进程下的不同线程共享,不管共享资源是实例变量、代码段、或者数据库的资源,所以我们可以通过单点下的原子性、有序性、可见性来保证共享资源的安全。

而在分布式系统下,共享资源的范围就扩大到了多台机器下的多个进程中的多个线程中。

那么再看一下第二个问题,在分布式系统下原子性、有序性、可见性还管用吗?或者说这三个特性在分布式系统下还有用吗?

我的理解是:这三个特性是依然存在的,只是针对的对象和范围发生了变化。在单点情况下,任何共享资源都共存于同一个 JVM 进程中,共享资源状态同步的范围也只是在线程的工作内存和主内存之间而已,或者说共享资源的最终状态在主内存,而其变化状态发生在单点下的多线程的各自工作内存中,这三个特性所在的容器也只是单个 JVM 进程而已。而分布式系统下,共享资源的状态同步范围扩大了多台机器各自的进程(更细致一点是各个进程中不同的线程之间),共享资源的最终状态最终一定要依赖于 JVM 进程外的第三方,比如数据库、任意形式的服务器等等,而共享资源的状态变化发生在多个进程下的多个线程,因此分布式下的共享资源的安全保证,不仅仅是在线程之间,也在进程之间。

2.3 分布式锁要提供的最基础的能力

当然,前面一段理解可能有点过于冗繁,也可以说:分布式系统下整个服务集群是一个大容器,状态的同步范围在集群服务所有的线程之间,只是这些线程的交互不再只是通过单机的缓存一致性协议(如 MESI 协议等),而是扩大到了端到端的通信即网络交互,而共享资源的直接宿主也在第三方譬如其他服务、数据库等。那这时候这三个特性的范围如果也相应的扩大到集群线程之间,那共享资源的安全自然也是能够保证的。当然,这么说可能不太严谨,因为我也没在相关的资料上看到过有人在分布式系统之间使用原子性、有序性、可见性来说明分布式系统的多线程安全,这是只是借鉴思想,大家如果感觉名词不够专业,轻喷。

前面简单讨论了分布式系统下的共享资源以及保证线程安全的三个特性,我们考虑一下如何才能在分布式系统这个大容器下保证这三个特性,或者说如何在分布式系统下加锁?首先,锁的共享范围必然是要和要保护的资源一致的,在单点下共享资源就在单个 JVM 进程中,那么锁依靠 JVM 中的一些手段也就足够了,比如 synchronized、Lock、原子类(当然这个是无锁的)等。

而在分布式系统下,锁的生存范围必然是和集群节点平级的,要不然各个节点各自用自己的锁,大家对于对方的锁根本不认识也无法交流那岂不是乱掉了。所以分布式锁必须独立于各个节点之外,比如借助 redis、zookeeper 等实现,当然,本篇我们讨论的是 redis 的分布式锁,但我认为前面的思想是通用的,哪怕不用 redis、zookeeper 也可以实现,只是实现方式、效率等方面有所差异。即分布式锁最起码要实现进程间共享(这里的共享是指在不同进程间是一套,而不是说可以同时持有),并且能够保证共享资源的原子性、有序性、可见性。

这里多说一点,由于分布式锁宿主在 JVM 进程之间,各个进程加锁以及同步是通过端到端的进程通信,那么此时分布式系统下的可见性、有序性是自然满足的。

首先可见性很好理解,因为共享资源的获取本身就是服务与服务间的通信,可见性的粒度也应该在服务,只要共享资源发生改变,任何一个服务都可以查询到(不要说事务什么的,我觉得这里共享资源的状态同步应该是在事务的上层来看)。而有序性也是在分布式锁的前提下,不同服务之间对于共享资源的变更也变成了时间上是串行的,那么也自然满足的,当然这里会有性能的牺牲。那么原子性呢?我理解这里的原子性是靠分布式锁的获取等来保证的,只要加锁、释放等是原子的,那么锁所保护的资源(或操作)对于同级的操作就是一个原子的。

2.4 分布式锁还要考虑什么?

前面讨论了分布式锁怎么保证共享资源的安全,但是由于分布式锁宿主在譬如 redis、zookeeper 等中间件中,加锁、释放、锁续期等也是在进程与 redis 之间通信,那么就引出了一些单点加锁不存在的问题:那就是服务如果宕机了怎么办?或者加锁是有时间的,如果时间过了持有锁的任务还没有完成怎么办?这时候看起来就像下图可能出现的情况

出现这些问题的原因是虽然我们将分布式系统和锁的宿主看作一个大的通信系统,但其却是离散的,离散的节点自身可能存活、死亡等,在单个离散节点不存在时,其持有的锁却可能仍在另外一个离散节点存在(这里指的是依靠 redis 实现的分布式锁),那么对于其他节点来说锁也就永远无法获取了。反过来,如果持有锁的离散的服务节点对于共享资源的操作还没有完成,Redis 由于锁的时间到期而释放锁,那么其他的服务节点就可以获取到本不该获取的锁了,这时候共享资源必然是不安全的。而这些在单个进程中的锁不会存在,因为单进程下的锁、线程、资源都在一个容器即 JVM 进程中,JVM 进程死掉的话这些也就一起死掉了,自然也不会存在之前说的问题。可见,分布式锁不仅要维护共享资源的安全,还要维护锁自身在不同进程下的安全问题。


redis 分布式锁的一种实现—— Redisson 的可重入锁

3.1 如何使用 Redisson 的分布式锁

写到这里,我觉得前面的文字铺垫的太多了,代码和图片太少了,但对我个人而言我觉得会使用分布式锁没有什么太大的意义,所以我前面还是坚持写了一些冗繁的废话。那么,我们先看一下如何最简单的使用 Redisson 的分布式锁吧,毕竟 Talk is cheap,show me the code !

@Autowiredprivate RedissonClient redisson;
private static final String LOCK_PREFIX = "my:lock:";
@GetMapping("redis/lock/{seq}")public String lock(@PathVariable String seq) {    RLock lock = redisson.getLock(LOCK_PREFIX + seq);    try {        boolean lockSuccess = lock.tryLock(5, TimeUnit.SECONDS);        if (lockSuccess) {            System.out.println("get lock success");        } else {            System.out.println("get lock fail");        }        TimeUnit.SECONDS.sleep(15);    } catch (InterruptedException e) {        e.printStackTrace();        return seq + " mission dead";    } finally {        lock.unlock();    }    return seq + " mission completed";}
@Beanpublic RedissonClient redissonClient() {    Config config = new Config();    // 单点模式    config.useSingleServer()            .setAddress("redis://127.0.0.1:6379");    // 集群模式    /*config.useClusterServers()            .addNodeAddress("redis://127.0.0.1:7000")            .addNodeAddress("redis://127.0.0.1:7001")            .addNodeAddress("redis://127.0.0.1:7002")            .addNodeAddress("redis://127.0.0.1:7003")            .addNodeAddress("redis://127.0.0.1:7004")            .addNodeAddress("redis://127.0.0.1:7005");*/
    return Redisson.create(config);}

Redisson 实现的分布式锁的使用就是这么简单,这个也没什么好说的,我们公司的不少服务应该也都有过使用,就我接触到的有兑换券、优惠券等。下面我们就基于这段简单的代码来理解一下 Redisson 的分布式锁是如何实现的。

RedissonClient:同 redis 通信的组件

public class Redisson implements RedissonClient {
    static {        RedissonObjectFactory.warmUp();        RedissonReference.warmUp();    }
    protected final QueueTransferService queueTransferService = new QueueTransferService();    protected final EvictionScheduler evictionScheduler;    protected final ConnectionManager connectionManager;
    protected final ConcurrentMap<Class<?>, Class<?>> liveObjectClassCache = PlatformDependent.newConcurrentHashMap();    protected final Config config;    protected final SemaphorePubSub semaphorePubSub = new SemaphorePubSub();
    protected final ConcurrentMap<String, ResponseEntry> responses = PlatformDependent.newConcurrentHashMap();
    protected Redisson(Config config) {        this.config = config;        Config configCopy = new Config(config);
        connectionManager = ConfigSupport.createConnectionManager(configCopy);        evictionScheduler = new EvictionScheduler(connectionManager.getCommandExecutor());    }
    public EvictionScheduler getEvictionScheduler() {        return evictionScheduler;    }
    public CommandExecutor getCommandExecutor() {        return connectionManager.getCommandExecutor();    }
    public ConnectionManager getConnectionManager() {        return connectionManager;    }
    /**     * Create sync/async Redisson instance with default config     *     * @return Redisson instance     */    public static RedissonClient create() {        Config config = new Config();        config.useSingleServer()        .setTimeout(1000000)        .setAddress("redis://127.0.0.1:6379");//        config.useMasterSlaveConnection().setMasterAddress("127.0.0.1:6379").addSlaveAddress("127.0.0.1:6389").addSlaveAddress("127.0.0.1:6399");//        config.useSentinelConnection().setMasterName("mymaster").addSentinelAddress("127.0.0.1:26389", "127.0.0.1:26379");//        config.useClusterServers().addNodeAddress("127.0.0.1:7000");        return create(config);    }
    /**     * Create sync/async Redisson instance with provided config     *     * @param config for Redisson     * @return Redisson instance     */    public static RedissonClient create(Config config) {        Redisson redisson = new Redisson(config);        if (config.isReferenceEnabled()) {            redisson.enableRedissonReferenceSupport();        }        return redisson;    }        @Override    public RLock getLock(String name) {        return new RedissonLock(connectionManager.getCommandExecutor(), name);    }        // 省略巴拉巴拉}

不得不说,这段代码复制粘贴的是有点臭长啊,毕竟 CV 工程师,哈哈。

总结起来就是一句话:Redisson 类是 RedissonClient 的实现,封装了一些配置、同 redis 的连接管理、一些定时任务、发布订阅组件等,另外提供一些获取 Redisson 基于 Redis 实现的分布式锁、分布式集合、分布式信号量等接口方法,比如我们的分布式锁-可重入锁。

public RLock getLock(String name) {    return new RedissonLock(connectionManager.getCommandExecutor(), name);}

而这里实际上我们获取到的只是 Redisson 封装好的对分布式锁的抽象的对象而已,并不是真正的就执行加锁操作了。而加锁、释放锁等就是基于 Redisson 的锁接口 RLock 来做的,而本文讨论的可重入锁则 RedissonLock 是其中一种实现。

3.2 RedissonLock 是如何加锁的

下面我们就以 demo 的代码为入口看一下 RedissonLock 是如何加锁的:

@Overridepublic boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {    return tryLock(waitTime, -1, unit);}

通过 demo 中使用的 tryLock(long waitTime, TimeUnit unit) 我们可以看出来,真正调用的是下面这个方法:

@Overridepublic boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {    long time = unit.toMillis(waitTime);    long current = System.currentTimeMillis();    long threadId = Thread.currentThread().getId();    Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);    // 加锁成功 返回null 否则返回的是该锁将要过期的剩余时间    // lock acquired    if (ttl == null) {        return true;    }        time -= System.currentTimeMillis() - current;    // 未获取到锁,且第一次尝试获取锁花费时间超过了预设等待时间,则获取锁失败,不再等待    if (time <= 0) {        acquireFailed(waitTime, unit, threadId);        return false;    }        current = System.currentTimeMillis();    RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);    if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {        if (!subscribeFuture.cancel(false)) {            subscribeFuture.onComplete((res, e) -> {                if (e == null) {                    unsubscribe(subscribeFuture, threadId);                }            });        }        acquireFailed(waitTime, unit, threadId);        return false;    }
    try {        time -= System.currentTimeMillis() - current;        if (time <= 0) {            acquireFailed(waitTime, unit, threadId);            return false;        }            while (true) {            long currentTime = System.currentTimeMillis();            ttl = tryAcquire(waitTime, leaseTime, unit, threadId);            // lock acquired            if (ttl == null) {                return true;            }
            time -= System.currentTimeMillis() - currentTime;            if (time <= 0) {                acquireFailed(waitTime, unit, threadId);                return false;            }
            // waiting for message            currentTime = System.currentTimeMillis();            if (ttl >= 0 && ttl < time) {                subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);            } else {                subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);            }
            time -= System.currentTimeMillis() - currentTime;            if (time <= 0) {                acquireFailed(waitTime, unit, threadId);                return false;            }        }    } finally {        unsubscribe(subscribeFuture, threadId);    }//        return get(tryLockAsync(waitTime, leaseTime, unit));}

而关于这段代码呢,我们先忽略其他逻辑,重点看这一行:

Long ttl = tryAcquire(leaseTime, unit, threadId);

这一行第一次尝试加锁,接着往下看:

private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {    return get(tryAcquireAsync(leaseTime, unit, threadId));}
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {    // 如果自己设置了锁释放时间,则获取锁后直接返回,且不会设置定时刷新的逻辑(上层方法没有设置定时任务),则获取到锁后超过设定的事件后自动释放    // 或者在设定时间内手动调用释放锁    if (leaseTime != -1) {        return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);    }    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,                                                            TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {        // 未获取到锁        if (e != null) {            return;        }
        // 获取到锁,开启自动延期锁的定时任务        // lock acquired        if (ttlRemaining == null) {            scheduleExpirationRenewal(threadId);        }    });    return ttlRemainingFuture;}

可以看出来对于 leaseTime != -1 的判断会走两种方式:真正的加锁是通过 tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) 这个方法来做的,而当 leaseTime != -1 时,直接返回加锁结果了,而当 leaseTime = -1 时,在返回加锁结果之前,会监听加锁的结果:如果加锁成功了还会开启一个自动延期锁的定时任务。而这个 leaseTime 指的就是加锁成功后锁的默认持有时间。

当我们不指定 leaseTime 时,默认的锁持有时间是 30 秒(这个时间叫作看门狗 - lockWatchdogTimeout),并且每 10 秒(30/3)去确认一下锁的状态:如果锁仍未被释放,则重新设置锁的过期时间为 30 秒(当然,持有锁的服务宕机后在 30 秒后锁会自动释放,这个我们后面再说)。

而当我们指定 leaseTime 时,我们可以看出来前面的代码不会走到定时续期锁的逻辑,这时表示:成功获取到锁后,在 leaseTime 后,如果锁仍没有被服务主动释放,锁将自动过期,而不会管持有锁的线程有没有完成对应的操作,相当于在持有所得服务执行了比较耗时的任务且未完成时,这时锁已经被释放,这时候自然也是不安全的。上面两段代码的流程如下:

从前面的流程图我们可以看出,RedissonLock.tryLock(long waitTime, long leaseTime, TimeUnit unit) 是对于 waitTime, leaseTime 入参会产生不同的行为,这也是 RedissonLock 尝试加锁相对最完整的一个链路,其他方法譬如我们直接使用的 tryLock(long waitTime, TimeUnit unit) 也只是复用了其中一个逻辑分支。

3.3 RedissonLock分布式锁的数据结构与加锁原理

前面一小节我们看到了 RedissonLock 完整的加锁链路,那么分布式锁在 Redis 是如何实现的呢?怎么判断加锁失败以及锁的剩余时间呢?现在我们就来看看这个。

if (leaseTime != -1) {        return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);    }    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,                                                            TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);// 巴拉巴拉}

通过前面的代码我们可以看出来真正执行加锁以及返回加锁结果是调用了下面的方法:

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {        internalLockLeaseTime = unit.toMillis(leaseTime);
    // 锁不存在,加锁成功,设置hash数据结构锁: 锁名 -> 加锁线程:id -> 加锁次数(1)    // 锁存在且是本线程的锁 加锁次数增加:锁名 -> 加锁线程:id -> 加锁次数+1    // 锁存在且不是本线程的锁 加锁失败 返回锁剩余过期时间    return evalWriteAsync(getName(), LongCodec.INSTANCE, command,            "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]);",            Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));}

到了这里,我们才能看到 RedissonLock 的加锁(仅仅指执行加锁这一动作)以及锁在 Redis 中的数据结构的庐山真面目:可以看出来上面是执行了一段 lua 脚本,这段 lua 脚 本是会涉及到多个判断以及数据修改的,这个时候就可以回到我们说的关于加锁的原子性问题了。先不看这段加锁的逻辑,只考虑加锁过程涉及到多个判断以及操作时,那么那些动作必须是原子的,要么同时成功要么同时失败,而 RedissonLock 实现加锁过程的原子性就是借助了 lua 脚本(锁延期等也会使用 lua 脚本)。

那么我们看一下这段 lua 脚本的含义吧:结合注释,Redisson 实现的可重入锁的数据结构使用了 Redis中 的 hash 对象数据类型来实现,其在 Redis 中大概长这个样子:

从上面这张图我们可以看出来 Redisson 的分布式锁在 Redis 中的 hash 数据结构:{锁名}:{uuid:threadId}:{count},另外对于已经存在的健值对初始化过期时间为 30 秒。结合前面的加锁流程图,我们就可以看出来 Redisson 分布式锁是如何实现加锁的原子性,以下操作是一个原子操作:
  • 某一个节点下的线程加锁首先判断该线程对于的 hash 键是否存在

  • 若不存在(锁未被持有),则将锁的键设置为线程 id 对应的唯一标识,值为 1 (第一次加锁),返回空表示加锁成功

  • 锁存在且对应的是本线程,说明之前加锁的线程为同一个,则将 hash 值 1 (加锁次数,可重入),另外将该锁对应的存活时间重新设置,返回空表示加锁成功

  • 锁存在但键对应的不是当前线程,说明持有锁的是其他线程,返回锁剩余的过期时间表示加锁失败

到这里,Redisson 的分布式锁加锁的流程以及锁在 Redis 中的数据结构已经清楚了,这时候我们可以对比一下 Java 自身实现的可重入锁 ReentrantLock。对于 ReentrantLock,甚至更多的线程安全组件如 Semaphore、CountDownLatch 等,其底层的实现都依赖于 AQS(AbstractQueuedSynchronizer),而 AQS 本身是一个队列,队列中的节点 Node 同样也是封装了线程的对象,只是 AQS 是本地单节点的,Redis 却是分布式的可以被任何 JVM 共享。另外 AQS 中还封装了一个 int 类型的状态变量 state:

/** * The synchronization state. */private volatile int state;

当涉及到具体的实现时,state 有不同的含义,对 ReentrantLock 来说 state 就是可重入锁的加锁次数,对 Semaphore 来说 state 就是信号量,对 CountDownLatch 来说就是计数量。可以看出来, Java 的 AQS 一些抽象和 Redisson 实现的分布式锁是可以类比的,比如 thread 标识对应的封装,加锁次数等。只是 AQS 的实现原子操作一般是基于原子类的 CAS,而 Redisson 实现原子操作是基于 Redis 的 lua 脚本。另外 AQS 实现队列节点状态同步是基于队列本身可以遍历的特性以及节点中的几种状态(这里不再赘述),而 Redisson 不同线程之间阻塞同步是基于发布订阅(后面会提到)。可以得出:本地锁和分布式锁很多概念和思想是相似的,甚至其数据结构以及目标都是可类比的,只是分布式锁对本地锁的对象、范围、通信方式基于服务之间通信进行了实现。关于 AQS 的原理这里不再展开,大家可以参考 JDK 的源码。

3.4 锁的自动续期

前面我们从 Redisson 加锁为入口,分析了加锁的整体流程并详细看了加锁时的细节以及数据结构,现在我们看一下 Redisson 分布式锁是如何自动续期的。前面我们已经提到了当第一次加锁成功时会开启自动续期的定时任务,对于的代码入口即为:

// 获取到锁,开启自动延期锁的定时任务    // lock acquired    if (ttlRemaining == null) {        scheduleExpirationRenewal(threadId);    }

继续往下看,进入如下代码:

private void scheduleExpirationRenewal(long threadId) {    ExpirationEntry entry = new ExpirationEntry();    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);    if (oldEntry != null) {        // 表示不是第一次加锁 则加锁次数加 1 不会再开启续期锁 因为第一次加锁时调用 scheduleExpirationRenewal(long threadId) 会进入        // else 会开启 renewExpiration()        oldEntry.addThreadId(threadId);    } else {        // 在加锁时第一次调用 开启自动续期(定时重设锁的过期时间)        entry.addThreadId(threadId);        renewExpiration();    }}

ExpirationEntry 封装了定时任务对应的线程对象,结合注释这一段也不必展开,我们继续往下看真正开启续期的方法 renewExpiration():

private void renewExpiration() {    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());    // 锁已经不存在了直接返回    if (ee == null) {        return;    }        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {        @Override        public void run(Timeout timeout) throws Exception {            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());            if (ent == null) {                return;            }            Long threadId = ent.getFirstThreadId();            if (threadId == null) {                return;            }                        RFuture<Boolean> future = renewExpirationAsync(threadId);            // 这里监听续期 成功后递归调用(十秒后再次重复)            future.onComplete((res, e) -> {                if (e != null) {                    log.error("Can't update lock " + getName() + " expiration", e);                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());                    return;                }                                if (res) {                    // reschedule itself                    renewExpiration();                }            });        }        // 10 秒续期一次(如果还持有锁)  30000/3    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);        ee.setTimeout(task);}

可以看出来锁自动续期的流程为:

  1. 若锁已经不存在了(比如手动释放了锁),直接返回

  2. 若锁仍存在,调用 Redis 异步设置锁的过期时间 renewExpirationAsync(threadId),同时监听续期结果

  3. 若续期成功,则递归调用 renewExpiration(),否则异常返回

  4. 以上过程每 10 秒重复一次 (internalLockLeaseTime / 3)

然后我们看一下调用 Redis 对锁进行续期的过程:

protected RFuture<Boolean> renewExpirationAsync(long threadId) {    // 当前线程持有的锁还存在 重新设置锁的过期时间(默认 30 秒)    // 否则失败    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +                    "return 1; " +                    "end; " +                    "return 0;",            Collections.singletonList(getName()),            internalLockLeaseTime, getLockName(threadId));}

这里同样使用 lua 脚本来执行了一段原子操作:

  • 判断当前线程对应的锁是否存在,若存在则重新设置锁的过期时间(默认为 30 秒),返回 true

  • 否则返回 false

3.5 锁的手动释放

至此,Redisson 的加锁、自动续期我们已经讨论过了,现在看一下锁的手动释放, 其入口为:

public void unlock() {    try {        get(unlockAsync(Thread.currentThread().getId()));    } catch (RedisException e) {        if (e.getCause() instanceof IllegalMonitorStateException) {            throw (IllegalMonitorStateException)e.getCause();        } else {            throw e;        }    }    }

接着看底层实现 unlockAsync(final long threadId):

public RFuture<Void> unlockAsync(long threadId) {    RPromise<Void> result = new RedissonPromise<Void>();    // 释放锁    RFuture<Boolean> future = unlockInnerAsync(threadId);
    // 监听释放锁结果    future.onComplete((opStatus, e) -> {        // 取消自动续期        cancelExpirationRenewal(threadId);
        if (e != null) {            result.tryFailure(e);            return;        }
        if (opStatus == null) {            IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "                    + id + " thread-id: " + threadId);            result.tryFailure(cause);            return;        }
        result.trySuccess(null);    });
    return result;}

可以看出来,释放锁会执行以下操作:

  • 调用 Redis 释放锁

  • 监听释放锁结果,取消自动续期

然后看一下真正释放锁的操作:

protected RFuture<Boolean> unlockInnerAsync(long threadId) {    // 若锁不存在 返回    // 若锁存在 加锁次数 -1    // 若加锁次数仍不等于 0 (可重入),重新设置锁的过期时间,返回    // 若加锁次数减为 0,删除锁,同步发布释放锁事件,返回    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +                    "return nil;" +                    "end; " +                    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +                    "if (counter > 0) then " +                    "redis.call('pexpire', KEYS[1], ARGV[2]); " +                    "return 0; " +                    "else " +                    "redis.call('del', KEYS[1]); " +                    "redis.call('publish', KEYS[2], ARGV[1]); " +                    "return 1; " +                    "end; " +                    "return nil;",            Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));}

上面这段 lua 脚本的含义基本与注释一致,这里不再赘述。至此,锁会被原子性的释放。

3.6 加锁等待

讨论了加锁成功、锁自动续期、锁释放后,我们再来看一下加锁等待。前面加锁的代码中,我们可以看到,若制定了加锁的等待时间 waitTime 时,若锁已经被占有,加锁会失败并返回锁剩余的过期时间,然后循环尝试加锁,对应以下代码:

    current = System.currentTimeMillis();    RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);    if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {        if (!subscribeFuture.cancel(false)) {            subscribeFuture.onComplete((res, e) -> {                if (e == null) {                    unsubscribe(subscribeFuture, threadId);                }            });        }        acquireFailed(waitTime, unit, threadId);        return false;    }
    try {        time -= System.currentTimeMillis() - current;        if (time <= 0) {            acquireFailed(waitTime, unit, threadId);            return false;        }
        // 等待锁释放 循环获取锁        while (true) {            long currentTime = System.currentTimeMillis();            ttl = tryAcquire(waitTime, leaseTime, unit, threadId);            // lock acquired            if (ttl == null) {                return true;            }
            time -= System.currentTimeMillis() - currentTime;            if (time <= 0) {                acquireFailed(waitTime, unit, threadId);                return false;            }
            // waiting for message            currentTime = System.currentTimeMillis();            if (ttl >= 0 && ttl < time) {                subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);            } else {                subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);            }
            time -= System.currentTimeMillis() - currentTime;            if (time <= 0) {                acquireFailed(waitTime, unit, threadId);                return false;            }        }    } finally {        unsubscribe(subscribeFuture, threadId);    }

在上面的 while 循环中,我们可以看出来,每次循环都会调用

ttl = tryAcquire(waitTime, leaseTime, unit, threadId);

这里就回到了我们之前分析的加锁流程,不再赘述。整个加锁等待流程如下:

  • 如果加锁成功,返回成功

  • 加锁失败,基于发布订阅(基于 Semaphore )阻塞,收到锁释放消息后继续循环,再次尝试加锁

  • 如果整个加锁尝试时间超过了 waitTime 后仍然未抢到锁,返回加锁失败


总结

至此,Redisson 基于 Redis 实现的分布式锁的可重入锁 RedissonLock 的大致原理就分析完了。我们分析了分布式系统下保证共享资源安全的一些必要特性,然后针对 Redisson 实现的可重入锁的加锁、自动续期、锁释放、锁等待的代码进行了分析,整个过程有所简略,只关注了整体流程。更为细节的内容如如何和 Redis 进行通信、配置管理覆盖、发布订阅如何实现,感兴趣的话大家可以自己探索一下。



☞「1s」即可!用 VS Code 一键玩转 GitHub 代码!☞Android 12 预览版发布,64G手机用户:我又活了
☞Linux 登陆火星,占有率再超 Windows,NASA 还在 GitHub 发布源码教程
☞25 万行逆向源码遭下架!


已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 代码科技 设计师:Amelia_0503 返回首页