2

1. 前言

之前写过一篇《Redis分布式锁的实现》的文章,主要介绍的Redis分布式锁的原始性实现,核心是基于setnx来加锁,以及使用lua保障事务的原子性等。但毕竟比较原始,需要根据不同的应用场景做不同的代码实现,也容易考虑不周。当时文章中就有提到 Redisson框架,刚好最近工作中又用的比较多,这次就着重介绍。

Redisson 是架设在 Redis基础上的一个Java开发框架,底层基于 Netty框架,为使用者提供了一系列具有分布式特性的常用工具类。Redisson的功能非常丰富,具体可参考 github中文wiki,但本文只介绍 Redisson分布式锁的功能。

2. 普通可重入锁

2.1. 使用示例

在SpringBoot项目通过Redisson来加锁非常容易,不需要像之前文章中一样写一大堆代码,框架屏蔽掉了很多细节。如下例:

Config config = new Config();
config.useSingleServer().setAddress("redis://ip:port").setPassword("password").setDatabase(0);
RedissonClient redissonClient = Redisson.create(config);

RLock lock = redissonClient.getLock("LOCK_KEY");
long waitTime=500L;
long leaseTime=15000L;
boolean isLock;
try {
    isLock = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
    if (isLock) {
        // do something ...
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
} finally {
    lock.unlock();
}

注意代码中 Config 并无限制,示例中是Redis单节点连接,但实际上可以是哨兵模式、集群模式、主从模式等。

2.2. 源码讲解

前面例子中加锁用到了RLock接口,这里贴一下源码:
org.redisson.api.RLock.java

public interface RLock extends Lock, RLockAsync {
    String getName();

    void lockInterruptibly(long var1, TimeUnit var3) throws InterruptedException;

    boolean tryLock(long var1, long var3, TimeUnit var5) throws InterruptedException;

    void lock(long var1, TimeUnit var3);

    boolean forceUnlock();

    boolean isLocked();

    boolean isHeldByThread(long var1);

    boolean isHeldByCurrentThread();

    int getHoldCount();

    long remainTimeToLive();
}

对于可重入锁,接口对应的实现方法在org.redisson.RedissonLock类里面,源码就不贴了,可以看到落到Redis时,实际的“加锁”和“解锁”过程也是一段lua脚本。

2.2.1. 加锁

1、lua
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', 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]);

参数解释:

  • KEYS[1]:被锁资源名,框架命名NAME。
  • ARGV[1]:过期时间。
  • ARGV[2]:当前程序标识(UUID + 当前threadId),源码命名LOCK_NAME。

加锁的逻辑,是在redis中存入一个Hash类型值。资源一旦被锁,初次设置Value为1,也只有当前程序可重复加锁,即Value往上加1。

2、加锁阻塞

加锁的java方法为:

    public 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 = this.tryAcquire(leaseTime, unit, threadId);
        if (ttl == null) {
            return true;
        } else {
            time -= System.currentTimeMillis() - current;
            if (time <= 0L) {
                this.acquireFailed(threadId);
                return false;
            } else {
                current = System.currentTimeMillis();
                RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
                if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
                    if (!subscribeFuture.cancel(false)) {
                        subscribeFuture.onComplete((res, e) -> {
                            if (e == null) {
                                this.unsubscribe(subscribeFuture, threadId);
                            }

                        });
                    }

                    this.acquireFailed(threadId);
                    return false;
                } else {
                    boolean var16;
                    try {
                        time -= System.currentTimeMillis() - current;
                        if (time <= 0L) {
                            this.acquireFailed(threadId);
                            boolean var20 = false;
                            return var20;
                        }

                        do {
                            long currentTime = System.currentTimeMillis();
                            ttl = this.tryAcquire(leaseTime, unit, threadId);
                            if (ttl == null) {
                                var16 = true;
                                return var16;
                            }

                            time -= System.currentTimeMillis() - currentTime;
                            if (time <= 0L) {
                                this.acquireFailed(threadId);
                                var16 = false;
                                return var16;
                            }

                            currentTime = System.currentTimeMillis();
                            if (ttl >= 0L && ttl < time) {
                                this.getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                            } else {
                                this.getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                            }

                            time -= System.currentTimeMillis() - currentTime;
                        } while(time > 0L);

                        this.acquireFailed(threadId);
                        var16 = false;
                    } finally {
                        this.unsubscribe(subscribeFuture, threadId);
                    }

                    return var16;
                }
            }
        }
    }

结合lua和java源码可得知,只有当同时满足下列条件时,加锁线程才会阻塞住:

  • EXISTS NAME = 1,即锁资源存在;
  • HEXISTS LOCK_NAME = 0,即当前线程并未加锁;
  • waitTime > 0L,即不存在阻塞等待时间。
3、各加锁方法对比
  • 申明方法有无InterruptedException:方法申明时抛出 InterruptedException,表示当前方法在等待时,支持其他线程通过调用interrupt方法,中断当前线程方法的执行。
  • 有无leaseTime:设置leaseTime即设置锁的过期时间,若无或leaseTime=-1L,则通过watchDog自动续锁。
  • 有无waitTime:设置锁阻塞时的最大阻塞时间。
  • lock和tryLock:往往lock是获取不到锁时阻塞,tryLock获取不到锁时也会立即返回,但如果包含waitTime参数,则另算。
4、看门狗机制

如果拿到分布式锁的节点宕机,且这个锁正好处于锁住的状态时,会出现锁死的状态,为了避免这种情况的发生,锁都会设置一个过期时间。即前面在加锁时传入的leaseTime。某些应用场景中,如果在指定时间中,我们尚未完成业务,此时就需要给锁“续期”。如果整个过程完全可控,可以在程序中手动给锁续期。但如果希望能自动续期,就可以用到Redisson的Wath Dog(看门狗)机制。

Redisson提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放。默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。

下面就是加锁的源码,注意,在调用加锁方法时,如果想用看门狗,则传leaseTime值为-1L。如果给leaseTime设置了有效值,那么看门狗就不会生效,锁不会自动续期,而是在你指定的时间后自动解锁。

    private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
        if (leaseTime != -1L) {
            return this.tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
        } else {
            RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
            ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
                if (e == null) {
                    if (ttlRemaining == null) {
                        this.scheduleExpirationRenewal(threadId);
                    }
                }
            });
            return ttlRemainingFuture;
        }
    }

2.2.2. 解锁

1、lua(unlock)
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;

参数解释:

  • KEYS[1]:被锁资源名。
  • KEYS[2]:解锁时广播通道名。
  • ARGV[1]:解锁时广播通道消息(值为0L)。
  • ARGV[2]:过期时间。
  • ARGV[3]:当前程序标识(UUID + 当前threadId)。

解锁的逻辑,是先判断被锁资源名是否存在,如果存在则给Value减1,当Value为0时,则删除Key,并向指定通道广播消息。

广播通道的设计很有亮点,当多个线程同时竞争锁时,未抢到锁的线程无需无效轮询,只需订阅一个通道。当锁释放时,在通道中广播消息,通知那些等待获取锁的线程现在可以获得锁了,那些线程再去竞争锁,避免性能资源的浪费。

2、lua(forkUnlock)
if (redis.call('del', KEYS[1]) == 1) then
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1
else
    return 0
end

2.2.3. RLock 接口解读

org.redisson.api.RLock 接口继承了java.util.concurrent.locks.Lock 接口,我把它们合在一起,结合自己的理解做了方法注释:

public interface RLock {
    /**
     * 获取锁的KEY (即:RedissonLock.getLock(String name);中的参数 name)
     */
    String getName();

    /**
     * 阻塞获取锁,如果获取不到锁,会一直阻塞等待
     * 加锁成功后,设置锁过期时间为leaseTime
     * 阻塞时,如果被其他线程执行interrupt方法,会终止阻塞并抛出InterruptedException返回
     *
     * @param leaseTime 锁租约(过期)时间
     * @param unit leaseTime对应的时间单位
     * @throws InterruptedException 线程中断异常
     */
    void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException;

    /**
     * 尝试获取锁,如果获取不到锁,会最大阻塞等待waitTime,超时后会正常返回
     * 加锁成功后,设置锁过期时间为leaseTime
     * 阻塞时,如果被其他线程执行interrupt方法,会终止阻塞并抛出InterruptedException返回
     *
     * @param waitTime 阻塞最大等待时间
     * @param leaseTime 锁租约(过期)时间
     * @param unit waitTime、leaseTime共同对应的时间单位
     * @throws InterruptedException 线程中断异常
     */
    boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

    /**
     * 阻塞获取锁,如果获取不到锁,会一直阻塞等待
     * 加锁成功后,设置锁过期时间为leaseTime
     * 阻塞时,不受其他线程的interrupt方法干扰,获取锁之前不会终止线程
     *
     * @param leaseTime 锁租约(过期)时间
     * @param unit leaseTime对应的时间单位
     */
    void lock(long leaseTime, TimeUnit unit);

    /**
     * 强制解锁,立即返回
     * 对于同一个锁NAME,会解锁所有线程资源加的锁;如果可重入锁多次加锁,也会一次性解除所有锁。
     * Redis:1、DEL NAME && 2、PUBLISH channelName msg (向通道广播解锁消息)
     */
    boolean forceUnlock();

    /**
     * 判断资源NAME是否有被锁(但可能被多个线程、加多次锁)
     * Redis:EXISTS NAME
     *
     * @return
     */
    boolean isLocked();

    /**
     * 判断指定线程是否获得锁(通过 HEXISTS 命令)
     * Redis:HEXISTS NAME LOCKNAME(id+threadId)
     *
     * @param threadId 线程ID
     */
    boolean isHeldByThread(long threadId);

    /**
     * 判断当前线程是否获得锁
     * Redis:HEXISTS NAME LOCKNAME(id+currentThreadId)
     *
     */
    boolean isHeldByCurrentThread();

    /**
     * 获取当前线程,获得锁的数量(通过 HGET 命令)
     * Redis:HGET NAME LOCKNAME(id+currentThreadId)
     *
     */
    int getHoldCount();

    /**
     * 获取锁资源剩余生存时间
     * Redis: PTTL NAME
     *
     * @return 完全redis pttl命令结果:返回单位为毫秒;当key不存在时,返回 -2;当key存在但没有设置剩余生存时间时,返回 -1。
     */
    long remainTimeToLive();

    // java.util.concurrent.locks.Lock.java

    /**
     * 阻塞获取锁,如果获取不到锁,会一直阻塞等待
     * 等同于 leaseTime=-1L,会触发 Watch Dog 机制,当业务未执行完成时,会自动给锁续期,默认每次续期30s
     * 阻塞时,不受其他线程的interrupt方法干扰,获取锁之前不会终止线程
     */
    void lock();

    /**
     * 阻塞获取锁,如果获取不到锁,会一直阻塞等待
     * 等同于 leaseTime=-1L,会触发 Watch Dog 机制,当业务未执行完成时,会自动给锁续期,默认每次续期30s
     * 阻塞时,如果被其他线程执行interrupt方法,会终止阻塞并抛出InterruptedException返回
     *
     * @throws InterruptedException 线程中断异常
     */
    void lockInterruptibly() throws InterruptedException;

    /**
     * 尝试获取锁,如果获取不到锁,会立即返回
     * 等同于 leaseTime=-1L,会触发 Watch Dog 机制,当业务未执行完成时,会自动给锁续期,默认每次续期30s
     * 阻塞时,不受其他线程的interrupt方法干扰,获取锁之前不会终止线程
     */
    boolean tryLock();

    /**
     * 尝试获取锁,如果获取不到锁,会最大阻塞等待waitTime,超时后会正常返回
     * 等同于 leaseTime=-1L,会触发 Watch Dog 机制,当业务未执行完成时,会自动给锁续期,默认每次续期30s
     * 阻塞时,如果被其他线程执行interrupt方法,会终止阻塞并抛出InterruptedException返回
     *
     * @param waitTime 阻塞最大等待时间
     * @param unit waitTime对应当时间单位
     * @throws InterruptedException
     */
    boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException;

    /**
     * 解锁
     * 对于某个锁KEY,只会解除当前线程加的锁;如果可重入锁多次加锁,只会解除单次锁
     */
    void unlock();

    /**
     * Redisson 不支持该方法的实现
     * @return throw new UnsupportedOperationException();
     */
    Condition newCondition();
}

3. RedLock 红锁

3.1. 概念说明

也是在之前的那一篇文章中,也提到了RedLock,中文直译“红锁”。

redis多节点可能都会获得锁

如果多个节点都能获得锁,分布式锁就没有意义了,导致多个节点都获得锁的原因:

  • 主从复制延迟:Redis 主从复制是异步的。当主节点写入数据后,可能需要一些时间才能将数据同步到从节点。如果在数据还没完全同步的情况下主节点发生故障,从节点提升为新主节点,那么新主节点上可能没有最新的锁信息,从而导致另一个客户端获取到相同的锁。
  • 网络分区:在网络分区的情况下,不同的 Redis 节点可能无法互相通信。客户端可能会连接到不同的节点并尝试获取锁,导致多个客户端同时获得锁。
  • 故障转移(Failover):在 Redis 集群中,如果主节点宕机,会进行故障转移选举新的主节点。如果在故障转移过程中存在网络延迟或者其他问题,也可能导致多个客户端同时获得锁。

RedLock是Redis官方提出的算法,具体流程包括:

  1. 获取当前时间。
  2. 依次N个节点获取锁,并设置响应超时时间,防止单节点获取锁时间过长。
  3. 锁有效时间=锁过期时间-获取锁耗费时间,如果第2步骤中获取成功的节点数大于
    N/2+1,且锁有效时间大于0,则获得锁成功。
  4. 若获得锁失败,则向所有节点释放锁。

简单点说,就是在锁过期时间内,如果半数以上的节点成功获取到了锁,则说明获取锁成功。这个有点像ZooKeeper的选举机制。这里讲讲Redisson中的实现方法。

3.2. 使用示例

Redisson关于RedLock的使用代码上及其简单,只是将几个锁组合成一个“大锁”,然后再正常使用“大锁”的加锁/解锁。

Config config1 = new Config();
config1.useSingleServer().setAddress("redis://ip1:port1")
        .setPassword("password1").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);

Config config2 = new Config();
config2.useSingleServer().setAddress("redis://ip2:port2")
        .setPassword("password2").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);

Config config3 = new Config();
config3.useSingleServer().setAddress("redis://ip3:port3")
        .setPassword("password3").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);

String lockKey = "REDLOCK_KEY";
RLock lock1 = redissonClient1.getLock(lockKey);
RLock lock2 = redissonClient2.getLock(lockKey);
RLock lock3 = redissonClient3.getLock(lockKey);

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
long waitTime=500L;
long leaseTime=15000L;
try {
    isLock = redLock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
    if (isLock) {
        // do something ...
    }
} catch (Exception e) {
    ... ...
} finally {
    redLock.unlock();
}

注意代码中 Config 并无限制,示例中是Redis单节点连接,但实际上可以是哨兵模式、集群模式、主从模式等。

3.3. 源码讲解

在讲Redisson的 RedLock(红锁)之前,先讲 MultiLock(联锁),原因先看 RedissonRedLock源码,完全是继承 RedissonMultiLock的所有功能。

RedissonRedLock.java

public class RedissonRedLock extends RedissonMultiLock {
    public RedissonRedLock(RLock... locks) {
        super(locks);
    }

    protected int failedLocksLimit() {
        return this.locks.size() - this.minLocksAmount(this.locks);
    }

    protected int minLocksAmount(List<RLock> locks) {
        return locks.size() / 2 + 1;
    }

    protected long calcLockWaitTime(long remainTime) {
        return Math.max(remainTime / (long)this.locks.size(), 1L);
    }

    public void unlock() {
        this.unlockInner(this.locks);
    }
}

RedissonMultiLock.java核心代码

    // 加锁
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        long newLeaseTime = -1L;
        if (leaseTime != -1L) {
            if (waitTime == -1L) {
                newLeaseTime = unit.toMillis(leaseTime);
            } else {
                newLeaseTime = unit.toMillis(waitTime) * 2L;
            }
        }

        long time = System.currentTimeMillis();
        long remainTime = -1L;
        if (waitTime != -1L) {
            remainTime = unit.toMillis(waitTime);
        }

        long lockWaitTime = this.calcLockWaitTime(remainTime);
        int failedLocksLimit = this.failedLocksLimit();
        List<RLock> acquiredLocks = new ArrayList(this.locks.size());
        ListIterator iterator = this.locks.listIterator();

        while(iterator.hasNext()) {
            RLock lock = (RLock)iterator.next();

            boolean lockAcquired;
            try {
                if (waitTime == -1L && leaseTime == -1L) {
                    lockAcquired = lock.tryLock();
                } else {
                    long awaitTime = Math.min(lockWaitTime, remainTime);
                    lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
                }
            } catch (RedisResponseTimeoutException var21) {
                this.unlockInner(Arrays.asList(lock));
                lockAcquired = false;
            } catch (Exception var22) {
                lockAcquired = false;
            }

            if (lockAcquired) {
                acquiredLocks.add(lock);
            } else {
                if (this.locks.size() - acquiredLocks.size() == this.failedLocksLimit()) {
                    break;
                }

                if (failedLocksLimit == 0) {
                    this.unlockInner(acquiredLocks);
                    if (waitTime == -1L) {
                        return false;
                    }

                    failedLocksLimit = this.failedLocksLimit();
                    acquiredLocks.clear();

                    while(iterator.hasPrevious()) {
                        iterator.previous();
                    }
                } else {
                    --failedLocksLimit;
                }
            }

            if (remainTime != -1L) {
                remainTime -= System.currentTimeMillis() - time;
                time = System.currentTimeMillis();
                if (remainTime <= 0L) {
                    this.unlockInner(acquiredLocks);
                    return false;
                }
            }
        }

        if (leaseTime != -1L) {
            List<RFuture<Boolean>> futures = new ArrayList(acquiredLocks.size());
            Iterator var24 = acquiredLocks.iterator();

            while(var24.hasNext()) {
                RLock rLock = (RLock)var24.next();
                RFuture<Boolean> future = ((RedissonLock)rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);
                futures.add(future);
            }

            var24 = futures.iterator();

            while(var24.hasNext()) {
                RFuture<Boolean> rFuture = (RFuture)var24.next();
                rFuture.syncUninterruptibly();
            }
        }

        return true;
    }
    
    // 解锁
    public void unlock() {
        List<RFuture<Void>> futures = new ArrayList(this.locks.size());
        Iterator var2 = this.locks.iterator();

        while(var2.hasNext()) {
            RLock lock = (RLock)var2.next();
            futures.add(lock.unlockAsync());
        }

        var2 = futures.iterator();

        while(var2.hasNext()) {
            RFuture<Void> future = (RFuture)var2.next();
            future.syncUninterruptibly();
        }

    }

RedissonRedLock.java 中重写了 RedissonMultiLock.java里的几个方法:

  • failedLocksLimit:MultiLock中返回0,RedLock中返回 locks.size() / 2 - 1
  • calcLockWaitTime:MultiLock中返回 remainTime,RedLock中返回 Math.max(remainTime / (long)this.locks.size(), 1L)

通过源码容易看到,Redisson中的 RedLock算法完全是基于 MultiLock实现的。Redisson 支持这种“联合锁”的概念,将多个 RLock锁放入一个 ArrayList中,然后开始遍历加锁。只不过 MultiLock的要求比较苛刻,List中的所有的 RLock加锁时,不能存在任何加锁失败的,即 failedLocksLimit=0。而 RedLock要求放松一点,只要过半加锁成功即可,即 failedLocksLimit = locks.size() / 2 - 1。但解锁时,要求将整个 ArrayList 中的锁都解一遍。


KerryWu
641 声望159 粉丝

保持饥饿