用 Redis 实现分布式锁

Posted by icoding168 on 2020-04-10 16:42:21

分类: Redis  

加锁

利用 setnx 和 expire 命令

setnx 是 set if not exists 的缩写,setnx 命令会在赋值之前判断 key 是否已经存在,如果 key 不存在就赋值并返回 1,如果 key 存在就不会赋值并返回 0 。分布式锁还需要超时机制,因此还需要用 expire 命令设置过期时间。

setnx 和 expire 是分开的两步操作,不是原子操作,如果执行完 setnx 命令后获取锁的客户端因为异常而退出运行,锁将无法释放,从而影响到其它客户端获取锁。

为了解决这个问题,可以把 setnx 和 expire 放到同一个 Lua 脚本中执行,因为 Redis 在执行 Lua 脚本时不会执行其它脚本或 Redis 命令。

setnx + expire 的 Lua 脚本示例:

if redis.call('setnx',KEYS[1],ARGV[1]) == 1 
then redis.call('expire',KEYS[1],ARGV[2]) return 1 
else return 0 
end

利用 set key value 命令

从 2.6.12 版本开始,Redis 为 set 命令增加了一些可选参数,可以在 set key value 的同时设置过期时间以及赋值方式,从而能够不借助 Lua 脚本也能完成原子操作。

SET key value[EX seconds][PX milliseconds][NX|XX]

  • EX seconds: 设定过期时间,单位为秒
  • PX milliseconds: 设定过期时间,单位为毫秒
  • NX: 仅当 key 不存在时设置值
  • XX: 仅当 key 存在时设置值

解锁

解锁并不是删除掉锁对应的 key 就行了,还要考虑到锁误删、锁过期、解锁通知等问题。

锁误删

举个例子,线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。

解决方案:通过在 value 中设置当前线程加锁的标识,在删除之前验证 key 对应的 value 判断锁是否是当前线程持有,可生成一个 UUID 标识当前线程。由于判断 value 和删除 key 是分开的两步操作,不是原子操作,因此要用 Lua 脚本。

// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
    then return redis.call('del', KEYS[1])
else return 0
end

锁过期

举个例子,线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。

有两种方式可以解决锁过期自动释放的问题:

  • 将过期时间设置得足够长,确保代码逻辑在锁释放之前能够执行完成。
  • 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。

解锁通知

举个例子,线程 A 成功获取到锁之后继续执行后续的逻辑,在此期间线程 B 尝试获取锁但失败了,因为线程 A 的锁还没有释放,线程 B 只能等待线程 A 解锁,为了保障分布式系统整体上有一个较高的运行效率,线程 A 解锁之后,应尽快通知线程 B 现在可以获取锁了。

有两种方式可以解决这个问题:

  • 客户端轮询:当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
  • 使用 Redis 的发布订阅功能:当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息。

可重入锁

当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。

为了实现可重入锁,可以借助 Redis 的 hash 数据类型,加锁的时候既存锁的标识也对重入次数进行计数,加锁时重入次数加 1,线程每退出一个同步块则重入次数减 1,当计数归 0 时释放锁。

Redisson 客户端

Redisson 是一个用 Netty 开发的 Redis 客户端,除了基本的增删改查操作,它还可以帮助我们在客户端层面解决实现分布式锁过程中遇到的各种问题,例如锁误删、锁过期、解锁通知等问题。

Redisson 支持 Redis 多种模式下的分布式锁:

单机模式

Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379")
                .setPassword("123456").setDatabase(0);

        RedissonClient redissonClient = Redisson.create(config);

        RLock disLock = redissonClient.getLock("DISLOCK");
        boolean isLock;
        try {
            isLock = disLock.tryLock(500, 15000, TimeUnit.MILLISECONDS);
            if (isLock) {
                // TODO 
                Thread.sleep(15000);
            }
        } catch (Exception e) {
        } finally {
            disLock.unlock();
        }

哨兵模式、集群模式跟单机模式的不同是 Config 对象的构造。

哨兵模式

Config config = new Config();
        config.useSentinelServers()
                .addSentinelAddress("redis://127.0.0.1:6378",
                        "redis://127.0.0.1:6379", "redis://127.0.0.1:6380")
                .setMasterName("master").setPassword("123456").setDatabase(0);

集群模式

Config config = new Config();
        config.useClusterServers()
                .addNodeAddress("redis://127.0.0.1:6375",
                        "redis://127.0.0.1:6376", "redis://127.0.0.1:6377",
                        "redis://127.0.0.1:6378", "redis://127.0.0.1:6379",
                        "redis://127.0.0.1:6380").setPassword("123456")
                .setScanInterval(5000);

Redlock 算法

高可用的 Redis 服务不会只有一个 Redis 节点,如果发生了主从切换或集群脑裂,就可能会出现锁丢失的情况,Redis 作者 antirez 提出了一种更完善的分布式锁的实现方式:Redlock 算法。

Redlock 算法的流程

搭建 N 个 Redis master 节点,这些节点完全独立,不存在主从复制或者其他集群协调机制,在这 N 个节点上使用与在 Redis 单节点下相同的方法来获取和释放锁。

假设现在已经搭建了 5 个 Redis 节点,客户端执行以下操作来获取锁:

  • 保存当前 Unix 时间为加锁开始时间,以毫秒为单位。
  • 依次尝试从 5 个节点,使用相同的 key 和具有唯一性的 value 获取锁。当向Redis 请求获取锁时,客户端必须设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如锁的自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免在 Redis 节点挂掉的情况下,客户端还在等待响应结果。如果 Redis 节点没有在规定时间内响应,客户端就去另一个 Redis 节点请求获取锁。
  • 客户端使用当前时间减去加锁开始时间就得到获取锁使用的时间,当且仅当从 (N/2 + 1) 个Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  • 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间。
  • 如果获取锁失败,客户端应该在所有的 Redis 实例上进行解锁操作,因为可能已经获取了部分节点的锁,如果这部分节点不解锁的话会影响其他客户端获取锁。

用 Redisson 实现 Redlock

以单机模式的 Redis 为例,用 Redisson 实现 Redlock 的代码如下:

Config config1 = new Config();
        config1.useSingleServer().setAddress("redis://127.0.0.1:6378")
                .setPassword("123456").setDatabase(0);
        RedissonClient redissonClient1 = Redisson.create(config1);

        Config config2 = new Config();
        config2.useSingleServer().setAddress("redis://127.0.0.1:6379")
                .setPassword("123456").setDatabase(0);
        RedissonClient redissonClient2 = Redisson.create(config2);

        Config config3 = new Config();
        config3.useSingleServer().setAddress("redis://127.0.0.1:6380")
                .setPassword("123456").setDatabase(0);
        RedissonClient redissonClient3 = Redisson.create(config3);

        String resourceName = "REDLOCK";
        RLock lock1 = redissonClient1.getLock(resourceName);
        RLock lock2 = redissonClient2.getLock(resourceName);
        RLock lock3 = redissonClient3.getLock(resourceName);
        RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
        boolean isLock;
        try {
            isLock = redLock.tryLock(500, 30000, TimeUnit.MILLISECONDS);

            System.out.println("isLock = " + isLock);

            if (isLock) {
                // TODO 
                Thread.sleep(30000);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            redLock.unlock();
        }