基于Redisson实现Redis分布式锁以及源码探究

2,657 阅读12分钟

RedissonLock使用与实现

首先需要引入Redisson的依赖。

<!-- Redisson Redis的分布式客户端 -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.11.4</version>
</dependency>

Redisson的使用可以参考官方github的wiki

简单使用

在引入redisson后,先来简单的使用一下Redisson的lock。

@Test
public void testLock() {
    Config config = new Config();
    // 使用cluster集群服务
    config.useSingleServer()
            // 设置节点地址
            .setAddress("redis://127.0.0.1:6379")
            // 设置客户端连接的名字
            .setClientName("myCluster")
            // 设置连接密码
            .setPassword("redis666")
            // 使用0号数据库
            .setDatabase(0);
    // 使用提供的配置创建Redisson客户端
    client = Redisson.create(config);
    // 根据名称获取一个非公平锁的实例
    RLock rLock = client.getLock("redlock");
    ExecutorService service = Executors.newFixedThreadPool(5);
    for (int i = 0; i < 5; ++i) {
        service.submit(() -> {
            boolean isLock;
            try {
                // 参数1 waitTime:向Redis获取锁的超时时间
                // 参数2 leaseTime:锁的失效时间(从开始获取锁时计时)
                // 参数3 unit:时间单位
                isLock = rLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
                if (isLock) {
                    logger.info("我获取到锁啦");
                    count ++;
                    logger.info("count: [{}]", count);
                }
            }catch (InterruptedException e){
                e.printStackTrace();
            }finally {
                // 最终释放锁
                rLock.unlock();
            }
        });
    }
    try {
        Thread.sleep(10000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

测试结果如下,资源可以安全地被并发访问。

2020-02-06 22:21:31.039 [pool-1-thread-1] INFO  com.ncusofter.redis.TestRedisson - 我获取到锁啦
2020-02-06 22:21:31.039 [pool-1-thread-1] INFO  com.ncusofter.redis.TestRedisson - count: [1]
2020-02-06 22:21:31.061 [pool-1-thread-2] INFO  com.ncusofter.redis.TestRedisson - 我获取到锁啦
2020-02-06 22:21:31.061 [pool-1-thread-2] INFO  com.ncusofter.redis.TestRedisson - count: [2]
2020-02-06 22:21:31.064 [pool-1-thread-3] INFO  com.ncusofter.redis.TestRedisson - 我获取到锁啦
2020-02-06 22:21:31.064 [pool-1-thread-3] INFO  com.ncusofter.redis.TestRedisson - count: [3]
2020-02-06 22:21:31.067 [pool-1-thread-4] INFO  com.ncusofter.redis.TestRedisson - 我获取到锁啦
2020-02-06 22:21:31.068 [pool-1-thread-4] INFO  com.ncusofter.redis.TestRedisson - count: [4]
2020-02-06 22:21:31.073 [pool-1-thread-5] INFO  com.ncusofter.redis.TestRedisson - 我获取到锁啦
2020-02-06 22:21:31.073 [pool-1-thread-5] INFO  com.ncusofter.redis.TestRedisson - count: [5]

Redission封装了锁的实现,其继承了java.util.concurrent.locks.Lock的接口,让我们像操作我们的本地Lock一样去操作Redission的Lock,同时RedissonLock还实现了异步加锁的方法以支持异步编程。

获取锁

通过tryLock()方法尝试获取锁。

@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    // 获取锁的超时等待时间
    long time = unit.toMillis(waitTime);
    // 获取当前的时间戳
    long current = System.currentTimeMillis();
    // 获取当前线程Id
    long threadId = Thread.currentThread().getId();
    // 尝试获取锁并返回锁的剩余失效时间
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // 如果返回的失效时间为空,表示锁获取成功(后面看tryAcquire可以知道)
    if (ttl == null) {
        return true;
    }

    // 申请锁的耗时如果大于等于超时等待时间,则申请锁失败
    time -= System.currentTimeMillis() - current;
    if (time <= 0) {
        // 通过 promise.trySuccess 设置异步执行的结果为null,并将promise的状态设置为completed
        acquireFailed(threadId);
        return false;
    }

    current = System.currentTimeMillis();
    // 订阅锁释放事件,并通过await方法阻塞等待锁释放,有效的解决了无效的锁申请浪费资源的问题:
    // 基于信息量,当锁被其它线程占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知等待的线程进行锁的竞争
    RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
    // await 方法内部是用CountDownLatch来实现阻塞,获取subscribe异步执行的结果
    // 当 subscribeFuture.await返回false,说明等待时间已经超出获取锁的超时等待时间
    if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
        if (!subscribeFuture.cancel(false)) {
            subscribeFuture.onComplete((res, e) -> {
                if (e == null) {
                    // 取消解锁事件的订阅
                    unsubscribe(subscribeFuture, threadId);
                }
            });
        }
        // 申请锁失败
        acquireFailed(threadId);
        return false;
    }

    // 当 subscribeFuture.await返回true,说明锁已经释放,进入循环尝试竞争锁
    try {
        // 计算获取锁的总耗时,如果大于等于最大等待时间,则获取锁失败
        time -= System.currentTimeMillis() - current;
        if (time <= 0) {
            acquireFailed(threadId);
            return false;
        }

        // 收到锁释放的信号后,在超时等待时间之内,循环一次接着一次的尝试获取锁
        // 
        // 若在最大等待时间之内还没获取到锁,则认为获取锁失败,返回false结束循环
        while (true) {
            long currentTime = System.currentTimeMillis();
            // 尝试获取锁
            ttl = tryAcquire(leaseTime, unit, threadId);
            // 获取锁成功,与上面一样
            if (ttl == null) {
                return true;
            }

            // 计算获取锁的总耗时,如果大于等于最大等待时间,则获取锁失败
            time -= System.currentTimeMillis() - currentTime;
            if (time <= 0) {
                acquireFailed(threadId);
                return false;
            }
          
            currentTime = System.currentTimeMillis();
            // 阻塞等待锁(通过信号量(共享锁)阻塞,等待解锁消息):
            if (ttl >= 0 && ttl < time) {
                // 如果锁的剩余小于超时等待时间 ,就在 ttl 时间内,从Entry的信号量获取一个许可
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                // 否则就在wait time 时间内从Entry的信号量获取一个许可
                getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
            }

            // 再次判断是否超时
            time -= System.currentTimeMillis() - currentTime;
            if (time <= 0) {
                acquireFailed(threadId);
                return false;
            }
        }
    } finally {
        // 最终不管是否加锁成功,都要取消解锁事件的订阅
        unsubscribe(subscribeFuture, threadId);
    }
}

加锁过程如下:

在这里插入图片描述
真正加锁代码的实现在tryLockInnerAsync中:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    // 获取锁的有效时间
    internalLockLeaseTime = unit.toMillis(leaseTime);
    // 通过EVAL命令执行Lua脚本获取锁,保证了原子性
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
              // 如果key不存在,则执行hset命令,然后通过pexpire命令设置锁的过期时间
              // 返回空值nil,表示获取锁成功
              "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; " +
              // 如果key已经存在,则判断value是否相同,如果相同,则锁的重入次数加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]);",
              // KEYS[1]:Collections.<Object>singletonList(getName())就是该分布式锁的key,也就是初始化锁时设置的名字。
              // ARGV[1]:internalLockLeaseTime,就是锁的有效时间;
              // ARGV[2]:getLockName(threadId),就是是获取锁时设置的唯一值 value,即UUID+threadId。
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

释放锁

解锁代码如下。

@Override
public void unlock() {
    try {
        // 通过unlockAsync里调用unlockInnerAsync实现释放锁
        get(unlockAsync(Thread.currentThread().getId()));
    } catch (RedisException e) {
        if (e.getCause() instanceof IllegalMonitorStateException) {
            throw (IllegalMonitorStateException) e.getCause();
        } else {
            throw e;
        }
    }
}

解锁过程如下:

在这里插入图片描述
真正解锁的代码如下:

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    // 执行lua脚本实现解锁
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // 如果key存在,但value不匹配,解锁失败                                
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +
            // 锁的重入次数减1                               
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            // 如果重入次数还大于0,说明该线程依然持有锁,更新失效时间                              
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            // 删除key以释放锁,并发布解锁的消息,通知其它线程来竞争锁                              
            "else " +
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
            // KEYS[1]、KEYS[2]:getName(), getChannelName()分别是锁的key和channel名称                     
            // ARGV[1]:LockPubSub.UNLOCK_MESSAGE表示解锁事件的消息,用于消息发布
            // ARGV[2]:internalLockLeaseTime锁的失效时间,用于重入锁的失效时间更新                                       // ARGV[3]:getLockName(threadId)当前锁的key对应value
            Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}

由于单纯的使用setnx有丢失锁的风险,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。而Redisson里实现的redlock算法时Redis官方唯一认可的Java客户端的实现。

Redlock实现原理

为什么要用redlock

一个有效的分布式锁最少要保证以下四点:

  • 互斥:在任何一个时刻,只有一个客户端可以持有锁。
  • 无死锁:即使锁定资源的客户端发生了崩溃或分区,也需要保证其它客户端可以继续获取锁。
  • 不能误解锁:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
  • 容错能力:只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁。 而如果仅仅通过setnx实现分布式锁,客户端A先获取锁,master节点突然宕机,salve节点还没完成锁的同步,然后slave变成master节点,此时客户端B成功获取锁,然而客户端A的锁还没释放,这不能满足锁的互斥原则,这就需要redlock来解决。

Redlock实现

在Redis的分布式环境中,这里假设有3个Redis master,这些节点完全互相独立,不存在主从复制或者其他集群协调机制(Redlock不依赖于集群,节点之间互不相关)。Redlock算法将在这3个实例上使用与在Redis单实例下相同方法获取和释放锁(也是使用setnx+lua来获取锁)。

Redlock算法在获取锁时,客户端执行以下操作:

  1. 首先以毫秒为单位获取当前时间。
  2. 尝试在3个实例中顺序地使用所有实例中相同的key和具有唯一性的value(可以使用UUID生成一个随机值)来获取锁。当尝试向Redis获取锁时,客户端应该设置一个超时等待时间,这个超时等待时间应该远小于锁的失效时间,否则获取的锁将可能是无效的(比如失效时间为10秒,超时等待时间应该为50毫秒左右)。这样可以防止客户端长时间与处于故障状态的Redis节点通信时一直阻塞:如果当前实例不可用,我们应该尽快尝试与下一个实例进行通信。
  3. 客户端通过从当前时间中减去在步骤1中获得的时间戳,来计算获取锁所花费的时间。当且仅当客户端能够在大多数实例(至少2个)中获取锁,并且获取锁所花费的总时间小于锁有效时间,才认为客户端已获取锁。
  4. 如果获取了锁,则将其有效时间视为初始有效时间减去经过的时间,如步骤3中所计算。
  5. 如果客户端由于某种原因(无法锁定2个实例或有效时间为负)而未能获得该锁,则它将尝试解锁所有实例(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

RedissonRedlock使用与实现

简单使用

这里使用三个完全独立的单机Redis实例来测试redlock的使用。

@Test
public void testRedlock() {
    String key = "redlock";

    // 获取三个完全独立的Redis实例的连接
    Config config1 = new Config();
   config1.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("redis666").setDatabase(0);
    RedissonClient client1 = Redisson.create(config1);

    Config config2 = new Config();
   config2.useSingleServer().setAddress("redis://127.0.0.1:6378").setPassword("redis666").setDatabase(0);
    RedissonClient client2 = Redisson.create(config2);

    Config config3 = new Config();
   config3.useSingleServer().setAddress("redis://127.0.0.1:6377").setPassword("redis666").setDatabase(0);
    RedissonClient client3 = Redisson.create(config3);

    // 通过相同的Key分别获取三个实例的锁对象
    RLock rLock1 = client1.getLock(key);
    RLock rLock2 = client2.getLock(key);
    RLock rLock3 = client3.getLock(key);

    // 通过获取的三个锁对象来构建一个RedissonRedlock实例
    // 这里就是redlock和普通lock的区别
    RedissonMultiLock redlock = new RedissonMultiLock(rLock1, rLock2, rLock3);

    // 下面业务处理逻辑和锁的使用都与普通lock一致
    ExecutorService service = Executors.newFixedThreadPool(5);
    for (int i = 0; i < 5; ++i) {
        service.submit(() -> {
            boolean isLock;
            try {
                // 参数1 waitTime:向Redis获取锁的超时时间
                // 参数2 leaseTime:锁的失效时间(从开始获取锁时计时)
                // 参数3 unit:时间单位
                isLock = redlock.tryLock(50, 10000, TimeUnit.MILLISECONDS);
                if (isLock) {
                    logger.info("我获取到锁啦");
                    count ++;
                    logger.info("count: [{}]", count);
                }
            }catch (InterruptedException e){
                e.printStackTrace();
            }finally {
                // 最终释放锁
                redlock.unlock();
            }
        });
    }
    try {
        Thread.sleep(10000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

RedissonRedlock的实现

Redlock锁和普通的lock锁的唯一区别就是redlock需要使用多个独立的Redis实例。这些实例必须是完全相互独立的,比如使用的是单机,那么就是三台独立的单机;如果使用的是主从模式,那么就是三个主从环境;如果使用的sentinel或cluster集群,那么也需要三个sentinel或cluster集群。

为什么要完全相互独立的redis实例呢,因为这里需要每个实例都能获取到锁(实际情况是大多数实例获取到锁就说明客户端获取锁成功),这样才能保证锁的高可用(部分实例宕机了没关系,锁不会丢失)。不管是主从、sentinel还是cluster,在同一时刻都只能获取到一个锁(因为每个key只会对应到一个节点中)。

下面以redis cluster为例说明redlock。

在这里插入图片描述
由于使用redlock算法需要额外的部署更多的实例,所以一般能使用普通lock实现的不会考虑使用redlock。

redlock加锁代码

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

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

    // 允许失败的最大实例数(N - (N/2 + 1))
    // locks.size() - locks.size()/2 + 1;;
    int failedLocksLimit = failedLocksLimit();
    List<RLock> acquiredLocks = new ArrayList<>(locks.size());
    // 遍历所有节点,使每个节点都去尝试加锁
    for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
        RLock lock = iterator.next();
        boolean lockAcquired;
        try {
            if (waitTime == -1 && leaseTime == -1) {
                // 这里的tryLock是RedissonLock中的普通加锁方法(下同)
                lockAcquired = lock.tryLock();
            } else {
                long awaitTime = Math.min(lockWaitTime, remainTime);
                lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
            }
        } catch (RedisResponseTimeoutException e) {
            // 加锁异常,也需要释放当前锁(防止因为客户端没有及时响应而导致下次无法加锁)
            unlockInner(Arrays.asList(lock));
            lockAcquired = false;
        } catch (Exception e) {
            lockAcquired = false;
        }

        // 加锁成功,将当前锁加入锁集合中
        if (lockAcquired) {
            acquiredLocks.add(lock);
        } else {
            // 如果大多数实例都获取到了锁,说明redlock加锁成功
            if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
                break;
            }
            
            if (failedLocksLimit == 0) {
                unlockInner(acquiredLocks);
                if (waitTime == -1) {
                    return false;
                }
                failedLocksLimit = failedLocksLimit();
                acquiredLocks.clear();
                // reset iterator
                while (iterator.hasPrevious()) {
                    iterator.previous();
                }
            } else {
                failedLocksLimit--;
            }
        }

        // 判断加锁的总时间有没有超过最大等待时间,如果超时了则加锁失败,并释放所有锁
        if (remainTime != -1) {
            remainTime -= System.currentTimeMillis() - time;
            time = System.currentTimeMillis();
            if (remainTime <= 0) {
                unlockInner(acquiredLocks);
                return false;
            }
        }
    }

    // 给每个节点设置失效时间
    if (leaseTime != -1) {
        List<RFuture<Boolean>> futures = new ArrayList<>(acquiredLocks.size());
        for (RLock rLock : acquiredLocks) {
            RFuture<Boolean> future = ((RedissonLock) rLock).expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS);
            futures.add(future);
        }

        for (RFuture<Boolean> rFuture : futures) {
            rFuture.syncUninterruptibly();
        }
    }

    return true;
}