Redis分布式锁下之Redisson | 小册免费学

1,320 阅读13分钟

自定义Redis分布式锁的弊端

在自定义Redis分布式锁中用来解决多节点定时任务的拉取问题(避免任务重复执行)

存在的问题:

加锁操作不是原子性的(即setnx和expire两步操作不是原子性的,中间宕机会导致死锁)

public boolean tryLock(String lockKey, Object value, long expireTime, TimeUnit timeUnit) { // 先setnx boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, value); if (lock){ // 给lockKey设置过期时间 再expire redisTemplate.expire(lockKey,expireTime,timeUnit); } return lock; }

高版本的SpringBoot Redis依赖其实提供了加锁的原子性:

@Override public boolean tryLockAtomic(String lockKey, Object value, long expireTime, TimeUnit timeUnit) { try { redisTemplate.opsForValue().set(lockKey,value,expireTime,timeUnit); return true; } catch (Exception e) { e.printStackTrace(); } return false; }

解锁操作不是原子性的(可能造成不同节点之间互相删锁)

上一篇中设计的unlock不是原子操作,但可以避免不同节点之间互相删锁。 @Override public boolean unLock(String lockKey, Object value) { Object originValue = redisTemplate.opsForValue().get(lockKey); if (originValue != null && value.equals(originValue)){ redisTemplate.delete(lockKey); return true; } return false; }

锁续期问题,使用队列的方式缩减定时任务执行时间,直接把任务丢到任务中,但实际上可能存在任务堆积,个别情况会出现:上次拉取的某个任务并丢到Redis队列中,但由于队列比较繁忙,该任务还未被执行,数据库中状态也尚未更改成status = 1(已执行),结果下次又拉取到同一任务,重复执行(简单的解决策略是:虽然无法阻止重复任务入队,但是出队消费时可以判断status=0后执行)

引入Redis Message Queue 会让系统变得更加复杂,假如上面得模型结构导致偶发性得BUG,非常不好排查,所以定时任务应该设计的简单点:

想要设计一个较完备的Redis分布式锁,必须至少解决三个问题:

  • 加锁原子性(setnx和expire要保证原子性,否则会容易发生死锁)
  • 解锁原子性(不能误删别人的锁)
  • 需要考虑业务/定时任务的时间,并为锁续期
  • 如果不考虑性能啥的,加解锁原子性都可以通过lua脚本实现(利用Redis单线程特性)

lua脚本 一次执行一个脚本,要么成功,要么失败,不会和其他指令交错执行。

最难的是如果根据实际业务的执行时间给锁续期。

虽然我们可以通过判断MACHINE_ID避免不同节点互相删除锁: 但我们本质需要的是:

Redisson已经实现为锁续期。了解Rredisson的锁续期机制

Redisson案例

上面代码产生的疑问:

  • lock()方法是原子性的吗?
  • lock()有设置过期时间吗?是多少
  • lock()实现锁续期了吗
  • lock()方法怎么实现阻塞的?又怎么被唤醒?

lock()源码解析

lock加锁,去除异常的情况,只有加锁成功和加锁失败两种情况,看下加锁成功的情况 lock加锁流程:

Rlock#lock()--->ttl = tryAcquire(-1, leaseTime, unit, threadId)--->tryAcquireAsync(waitTime, leaseTime, unit, threadId)--->tryLockInnerAsync--> evalWriteAsync--->executorService.evalWriteAsync;

流程: 1.尝试上锁,上锁成功返回null,失败返回ttl 2.调用tryAcquireAsync获取到异步结果Future,调用get从Future获取异步结果ttl 3.调用tryLockInnerAsync执行lua脚本,返回RFuture(等待回调)(传入lua脚本,锁的名称等参数)(evalWriteAsync方法向Redis发送脚本并执行,返回RFuture)(RFuture的作用:1.上锁的结果看RFuture的ttlRemaining,2.通过RFuture设置回调函数) 4.RFuture设置回调方法,在lua脚本里,加锁成功ttlRemaining为空,加锁失败返回上一个锁还剩多少时间(当加锁成功时,启动额外的线程为锁续期) 5.返回RFuture 6.从RFuture中获取ttlRemaining,然后通过ttl是否为空判断是否加锁成功。

整个流程简单来说就是通过执行lua脚本(原子性的,要么成功,要么失败)尝试上锁,返回RFuture,RFuture可以获取到ttl,通过判断这个ttl判断是否加锁成功,还可以设置回调函数,在这个回调函数里(当加锁成功的时候,启动额外的线程为锁续期)

两个难点:

  • lua脚本写了啥
  • ttlRemaining.onComplete()有什么作用
  • lua脚本解读
  • 在执行lua脚本时,KEYS,ARGV分别是什么

`KEYS:Collections.singtonList(getName()) ARGV:InternalLockLeaseTime,getLockName(threadId)

-- 如果不存在锁:"bravo1988_distributed_lock" if (redis.call('exists', KEYS[1]) == 0) then -- 使用hincrby设置锁:hincrby bravo1988_distributed_lock a1b2c3d4:666 1 redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 设置过期时间。ARGV[1]==internalLockLeaseTime redis.call('pexpire', KEYS[1], ARGV[1]); -- 返回null return nil; end;

-- 如果当前节点已经设置"bravo1988_distributed_lock"(注意,传了ARGV[2]==节点id) if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then -- 就COUNT++,可重入锁 redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 设置过期时间。ARGV[1]==internalLockLeaseTime redis.call('pexpire', KEYS[1], ARGV[1]); -- 返回null return nil; end;

-- 已经存在锁,且不是当前节点设置的,就返回锁的过期时间ttl return redis.call('pttl', KEYS[1]);`

简单来说:

  • 1.如果当前节点不存在 key的锁 ,则使用hincrby 加锁 并设置过期时间 最后返回null 表示加锁成功
  • 2.如果当前节点存在key的锁,则hincrby count++ 可重入锁 并设置过期时间,最后返回null
  • 3.如果已经存在锁 切不是当前节点设置的,返回上一个锁的过期时间ttl

Redisson设计的分布式锁是采用hash结构:

  • key :Lock_Name(锁的名称) + Client_Id(节点ID)
  • value:count(重入次数)

回调函数的作用:

CompleteFuture的回调机制: `RFuture#onComplete方法 ttlRemainingFuture.onComplete((ttlRemaining, e) -> { // 发生异常时直接return if (e != null) { return; }

    // 说明加锁成功
    if (ttlRemaining == null) {
        // 启动额外的线程,按照一定规则给当前锁续期
        scheduleExpirationRenewal(threadId);
    }
});

` onComplete 把回调函数压入任务栈中,方便异步线程弹栈执行。

至此,我们解决了之前的两个问题

lua脚本是什么意思(原子性的加锁设置过期时间,处理当前节点加锁问题) ttlRemainingFuture.onComplete()作用(设置回调函数,等异步线程获取到异步结果后调用) onComplete中的参数BiConsumer会被包装成一个对象压入任务栈中,然后被异步线程回调。

Redisson异步回调机制 Redisson通过lua脚本尝试加锁后返回RFuture,RFuture做的两件事:

  • 通过RFuture获取ttlRemaining,也就是上一个锁的过期时间,如果为null则本次加锁成功,否则加锁失败,需要等待
  • 通过RFuture设置回调函数 ** 现在的问题是:**
  • 异步线程是谁?哪来的?
  • onComplete()设置回调函数是干嘛的?
  • 回调时的参数(ttlRemaining,e)哪来的?

DEBUG时发现,线程会先到达return ttl 再回调BiConsumer#accept到达scheduleExpirationRenewal

执行两个语句的线程一个是主线程,一个是异步线程

锁续期和RFuture回调相关

Redisson如何实现锁续期 `

private void renewExpiration() { ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (ee == null) { return; }

/**
* 启动一个定时器:Timeout newTimeout(TimerTask task, long delay, TimeUnit unit);
* 执行规则是:延迟internalLockLeaseTime/3后执行
* 注意啊,每一个定时任务只执行一遍,而且是延迟执行。
* 
* 那么问题就来了:
* 1.internalLockLeaseTime/3是多久呢?
* 2.如果定时任务只执行一遍,似乎解决不了问题啊,本质上和我们手动设置过期时间一样:多久合适呢?
*/ 
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;
        }
        
        // 定时任务的目的是:重新执行一遍lua脚本,完成锁续期,把锁的ttl拨回到30s
        RFuture<Boolean> future = renewExpirationAsync(threadId);
        // 设置了一个回调
        future.onComplete((res, e) -> {
            if (e != null) {
                log.error("Can't update lock " + getName() + " expiration", e);
                // 如果宕机了,就不会续期了
                return;
            }
            // 如果锁还存在(没有unLock,说明业务还没结束),递归调用当前方法,不断续期
            if (res) {
                // reschedule itself
                renewExpiration();
            }
        });
    }
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

ee.setTimeout(task);

}

/**

  • 重新执行evalWriteAsync(),和加锁时的lua脚本比较类似,但有点不同
  • 这里设置expire的参数也是internalLockLeaseTime
  • 看来我们不得不去调查一下internalLockLeaseTime了! */ protected RFuture renewExpirationAsync(long threadId) { 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)); }

梳理一下WatchDog锁续期机制: lock()第一次成功加锁时,设置锁过期时间默认为30s,这个变量来自于WatchDog变量 // 重点 private RFuture tryAcquireAsync(long waitTime=-1, long leaseTime=-1, TimeUnit unit=null, long threadId=666) {

// lock()默认leaseTime=-1,所以会跳过if
if (leaseTime != -1) {
    return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}

// 执行lua脚本加锁,返回RFuture。第二个参数就是leaseTime,来自LockWatchdogTimeout!!!
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
                                        waitTime=-1,
                                        commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()=30秒,
                                        TimeUnit.MILLISECONDS, 
                                        threadId=666, 
                                        RedisCommands.EVAL_LONG);

// 设置回调方法
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    // 发生异常时直接return
    if (e != null) {
        return;
    }

    // 说明加锁成功
    if (ttlRemaining == null) {
        // 启动额外的线程,按照一定规则给当前锁续期
        scheduleExpirationRenewal(threadId);
    }
});

// 返回RFuture,里面有ttlRemaining
return ttlRemainingFuture;

}

// 执行lua脚本上锁 RFuture tryLockInnerAsync(long waitTime=-1, long leaseTime=30*1000, TimeUnit unit=毫秒, long threadId=666, RedisStrictCommand command) { // 略... }

onComplete()设置回调,等Redis调用回来后,异步线程回调BiComsumer#accept,进入scheduleExpirationRenewal,开始每隔10s给锁续期。 `

和加锁一样,执行lua脚本其实很快,所以这里future.onComplete虽说是异步,但很快就会被调用,然后就会调用renewExpiration,然后又是一个TimerTask,隔10秒后又给锁续期。也就是说,Redisson的Watchdog定时任务虽然只执行一次,但是每次递归,所以相当于:重复延迟执行。 开启为锁续期的异步线程是守护线程,只要主线程任务不结束,就会一直给锁续期。

锁释放的两种情况:

  • 任务结束,主动unLock删除锁
  • 任务结束,不调用unLock,但由于守护线程已经结束,不会有后台线程给锁续期,过了30秒自动过期 加锁失败逻辑涉及:
  • while(true)死循环
  • 释放锁时Redis的Publish通知(在后面的unLock流程会看到)
  • 其他节点收到锁释放的信号后重新争抢锁

unLock源码解析

`-- 参数解释: -- KEYS[1] => "distributed_lock" -- KEYS[2] => getChannelName() -- ARGV[1] => LockPubSub.UNLOCK_MESSAGE -- ARGV[2] => internalLockLeaseTime -- ARGV[3] => getLockName(threadId)

-- 锁已经不存在,返回null if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil; end;

-- 锁还存在,执行COUNT--(重入锁的反向操作) local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);

-- COUNT--后仍然大于0(之前可能重入了多次) if (counter > 0) then -- 设置过期时间 redis.call('pexpire', KEYS[1], ARGV[2]); return 0; -- COUNT--后小于等于0,删除锁,并向对应的Channel发送消息(NIO),消息类型是LockPubSub.UNLOCK_MESSAGE(锁释放啦,快来抢~) else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end;

return nil;`

也就是说,当一个锁被释放时,原先持有锁的节点会通过NIO的Channel发送LockPubSub.UNLOCK_MESSAGE,告诉其他订阅的Client:我们已经释放锁了,快来抢啊!此时原本阻塞的其他节点就会重新竞争锁。

重入和反重入:

// 加锁三次 redisson.lock(); redisson.lock(); redisson.lock(); // 执行业务 executeTask(); // 相应的,就要解锁三次 redisson.unLock(); redisson.unLock(); redisson.unLock();

实际开发不会这样调用,但有时会出现子父类调用或者同一个线程反复使用同一把锁的多个方法,就会发生锁的重入count++,而当这些方法执行完毕逐个弹栈的过程中就会组个unLock解锁count-- lock(leaseTime,TimeUnit)自定义过期时间且不续期。

两个lock的区别,无参数的lock内部传入的leaseTime,unit为-1,null 所以当有参的lock传入的参数为-1,null时效果和无参的lock效果一样 `// 重点 private RFuture tryAcquireAsync(long waitTime=-1, long leaseTime=-1, TimeUnit unit=null, long threadId=666) {

// lock()默认leaseTime=-1,会跳过这个if执行后面的代码。但如果是lock(10, TimeUnit.SECONDS),会执行if并跳过后面的代码。
if (leaseTime != -1) {
    // 其实和下面的tryLockInnerAsync()除了时间不一样外,没什么差别
    return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}

// 但由于上面直接return了,所以下面的都不会执行!!
/*

RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
                                        waitTime=-1,
                                        commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()=30秒,
                                        TimeUnit.MILLISECONDS, 
                                        threadId=666, 
                                        RedisCommands.EVAL_LONG);

// 设置回调方法(不会执行!!)
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    // 发生异常时直接return
    if (e != null) {
        return;
    }

    // 说明加锁成功
    if (ttlRemaining == null) {
        // 启动额外的线程,按照一定规则给当前锁续期
        scheduleExpirationRenewal(threadId);
    }
});

// 不会执行!!
return ttlRemainingFuture;

*/

}

// 执行lua脚本加锁 RFuture tryLockInnerAsync(long waitTime=-1, long leaseTime=30*1000, TimeUnit unit=毫秒, long threadId=666, RedisStrictCommand command) { // 略... } ` 当leaseTime不等于-1时,直接执行lua脚本后就return了,不会设置回调函数,给锁续期啥的。

tryLock系列:让调用者自行决定加锁失败后的操作

我们查看lock源码,如果多个节点调用lock,那么没获得锁的节点线程订阅后会阻塞,直到原先持有锁的节点删除并publish LockPubSub.UNLOCK_MESSAGE。 但如果调用者不希望阻塞呢?他有可能想着:如果加锁失败,我就直接放弃。

加锁的目的:

在保证线程安全的前提下,尽量让所有线程都执行成功 在保证线程安全的前提下,只让一个线程执行成功 前者适用于秒杀,下单等操作,希望尽最大努力达成;后者使用于定时任务,只让一个节点去执行,没有获取锁就应该fast-fail(快速失败)

也就是说,节点获取锁失败后,可以有各种各样的处理方式:

  • 阻塞等待
  • 直接放弃
  • 重试N次后放弃
  • ... lock接口内部已经写死了,加锁失败后阻塞等待

而tryLock方法则去掉了这层中间判断,把结果直接返回给调用者,让调用者自行决定加锁失败后如何处理: `

@Test public void testTryLock() { RLock lock = redissonClient.getLock("bravo1988_distributed_lock"); boolean b = lock.tryLock(); if (b) { // 业务操作... }

// 调用立即结束,不阻塞

} `

tryLock加锁成功返回true,加锁失败返回false,后续怎么操作,全由各个节点自行决定 lock 和 tryLock

tryLock和lock一样,加锁成功时也会触发锁续期:(只是加锁失败后的逻辑交由调用者自行处理)

加锁失败后处理: @Test public void testLockSuccess() throws InterruptedException { RLock lock = redissonClient.getLock("bravo1988_distributed_lock"); // 基本等同于lock(),加锁成功也【会自动锁续期】,但获锁失败【立即返回false】,交给调用者判断是否阻塞或放弃 lock.tryLock(); // 加锁成功仍然【会自动锁续期】,但获锁失败【会等待10秒】,看看这10秒内当前锁是否释放,如果是否则尝试加锁 lock.tryLock(10, TimeUnit.SECONDS); // 加锁成功【不会锁续期】,加锁失败【会等待10秒】,看看这10秒内当前锁是否释放,如果是否则尝试加锁 lock.tryLock(10, 30, TimeUnit.SECONDS); }

两个参数时,并未修改leaseTime= -1的值,也就是加锁成功还是会给锁续期。

那么waitTime是用来控制什么的呢?

简而言之: tryLock()加锁送失败会立即返回false,而加了waitTime可以手动指定阻塞等待的时间 leaseTime的作用没变,-1时控制加锁成功后为锁续期

Redisson分布式锁的缺陷

在哨兵模式或者主从模式下,如果master实例宕机,可能导致多个节点同时完成加锁。

以主从模式为例,由于所有写操作都时现在master上进行,然后再同步给各个slave节点,所以master与各个slave节点之间的数据具有一定的延迟性,对于Redisson分布式锁而言,比如客户端刚刚对master写入Redisson锁,然后master异步复制给各个slave节点,但这个过长中master节点宕机了,其中一个slave节点经过选举变成了master节点,刚好,这个slave(其他slave加锁了)还没同步到Redissson锁,所以其他客户端可能再次加锁。