# Redis/Redisson 分布式锁

# 1. 分布式锁使用场景

一般我们使用分布式锁有两个场景:

  • 效率:使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。比如用户付了钱之后有可能不同节点会发出多封短信。

  • 正确性:加分布式锁同样可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。

Redis 因为其性能好,实现起来分布式锁简单,所以让很多人都对基于 Redis 实现的分布式锁十分青睐。

提示

除了能使用 Redis 实现分布式锁之外,Zookeeper 也能实现分布式锁。但是项目中不可能仅仅为了实现分布式锁而专门引入 Zookeeper ,所以,除非你的项目体系中本来就有 Zookeeper(来实现其它功能),否则不会单独因为分布式锁而引入它。

# 2. SETNX 命令

早期,SETNX 是独立于 SET 命令之外的另一条命令。它的意思是 SET if Not eXists,即,在键值对不存在的时候才能设值成功。

注意

SETNX 命令的价值在于:它将 判断设值 两个操作合二为一,从而避免了 查查改改 的情况的出现。

后来,在 Redis 2013 年推出的 2.6.12 版本中,Redis 为 SET 命令官方提供了 NX 选项,是的 SET 命令也能实现 SETNX 命令的功能。其语法如下:

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

EX 值的是 key 的存活时间,单位为秒。PXEX 作用一样,唯一的不同就是后者的单位是微秒(使用较少)

NXXX 作用是相反的。NX 表示只有当 key『不存在时』才会设置其值;XX 表示当 key 存在时才设置 key 的值。

在 “升级” 了 SET 命令之后,Redis 官方说:“由于 SET 命令选项可以替换 SETNX,SETEX,PSETEX,因此在 Redis 的将来版本中,这三个命令可能会被弃用并最终删除”。

所以,现在我们口头所说的 SETNX 命令,并非单指 SETNX 命令,而是包括带 NX 选项的 SET 命令(甚至以后就没有 SETNX 命令了)

# 3. SETNX 的使用

在使用 SETNX 操作实现分布式锁功能时,需要注意以下几点:

  • 这里的『锁』指的是 Redis 中的一个认为约定的键值对。谁能创建这个键值对,就意味着谁拥有这整个『锁』。

  • 使用 SETNX 命令获取『锁』时,如果操作返回结果是 0(表示 key 已存在,设值失败),则意味着获取『锁』失败(该锁被其它线程先获取),反之,则设值成功,表示获取『锁』成功。

    • 如果这个 key 不存在,SETNX 才会设置该 key 的值。此时 Redis 返回 1 。

    • 如果这个 key 存在,SETNX 则不会设置该 key 的值。此时 Redis 返回 0 。

  • 为了防止其它线程获得『锁』之后,有意或无意,长期持有『锁』而不释放(导致其它线程无法获得该『锁』)。因此,需要为 key 设置一个合理的过期时间。

  • 当成功获得『锁』并成功完成响应操作之后,需要释放『锁』(可以执行 DEL 命令将『锁』删除)

在代码层面,与 Setnx 命令对应的接口是 ValueOperations 的 setIfAbsent 方法。

# 4. Redis SETNX 的问题

如果在代码中使用 Redis 的 SETNX 命令,那么使用逻辑的伪代码如下:

String uuid1 = ...;
// lock
set Test uuid1 NX PX 3000
try {
// biz handle....
} finally {
    // unlock
    String uuid2 = get Test;
    if (uuid1.equals(uuid2) {
        redisTool.del('Test');
    }
}

上面的代码逻辑有 2 个小问题:

  1. 上锁时,设置的超时自动删除时长(3 秒),设置多长合适?万一设置短了怎么办?

    如果设置短了,在业务逻辑执行完之前时间到期,那么 Redis 自动就把键值对给删除了,即,把锁给释放了,这不符合逻辑。

  2. 解锁时,查 - 删 操作是 2 个操作,由两个命令完成,非原子性。

当然,上述两个问题我们都能解决点,不过有人( Redisson )帮我们把这些事情做好了。

# 5. Redisson 如何解决上述问题

  • Redisson 解决 “过期自动删除时长” 问题的思路和方案

    Redisson 中客户端一旦加锁成功,就会启动一个后台线程(惯例称之为 watch dog 看门狗)。watch dog 线程默认会每隔 10 秒检查一下,如果锁 key 还存在,那么它会不断的延长锁 key 的生存时间,直到你的代码中去删除锁 key 。

  • Redisson 解决 “查 - 删 非原子性” 问题的思路和方案

    Redisson 的上锁和解锁操作都是通过 Lua 脚本实现的。Redis 中 执行 Lua 脚本能保证原子性,整段 Lua 脚本的执行是原子性的,在其执行期间 Redis 不会再去执行其它命令。

# 6. Redisson 的简单使用

  • 步骤 1:引入 redisson 包:
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.15.6</version>
</dependency>
  • 步骤 2:配置 Redisson Client:
@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
//        config.setLockWatchdogTimeout();
    config.useSingleServer()
            .setAddress("redis://127.0.0.1:6379")
            .setKeepAlive(true);
    return Redisson.create(config);
}
  • 步骤 3:使用 Redisson Client:
RLock hello = redissonClient.getLock("hello");

hello.lock();
hello.unlock();
  • 步骤 4:验证
for (int i = 0; i < 5; i++) {
    final long seconds  = i;
    new Thread(() -> {
        RLock hello = redissonClient.getLock("hello");
        hello.lock(); System.out.println("lock success。准备睡 " + seconds + " 秒,再起来释放锁");
        try {
            TimeUnit.SECONDS.sleep(seconds);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        hello.unlock();
    }).start();
}

# 7. Redisson 的锁

你通过 RedissonClient 拿到的锁都是 “可重入锁” 。

这里的 “可重入” 的意思是:持有锁的线程可以反复上锁,而不会失败,或阻塞等待;锁的非持有则上锁时,则会失败,或需要等待。

当然,如果你对一个锁反复上锁,那么逻辑上,你应该对它执行同样多次的解锁操作。

hello.lock(); System.out.println("lock success!");
hello.lock(); System.out.println("lock success!");
hello.lock(); System.out.println("lock success!");

hello.unlock();
hello.unlock();
hello.unlock();

使用 lock() 上锁时由于你没有指定过期删除时间,所以,逻辑上只有当你调用 unlock() 之后,Redis 中代表这个锁的简直对才会被删除。

当然你也可以在 lock 时指定超时自动解锁时间:

// 加锁以后 10 秒钟自动解锁
lock.lock(10, TimeUnit.SECONDS);

这种情况下,如果你有意或无意没有调用 unlock 进行解锁,那么 10 秒后,Redis 也会自动删除代表这个锁的键值对。


当两个不同的线程对同一个锁进行 lock 时,第二个线程的上锁操作会失败。而上锁失败的默认行为是阻塞等待,直到前一个线程释放掉锁。

这种情况下,如果你不愿意等待,那么你可以调用 tryLock() 方法上锁。tryLock 上锁会立刻(或最多等一段时间)返回,而不会一直等(直到所得持有线程释放)。

// 拿不到就立刻返回
hello.tryLock();

// 拿不到最多等 1 秒。1 秒内始终拿不到,就返回
hello.tryLock(1, TimeUnit.SECONDS);

// 拿不到最多等 1 秒。1 秒内始终拿不到,就返回。
// 如果拿到了,自动在 10 秒后释放。
hello.tryLock(1, 10, TimeUnit.SECONDS);

# 8. 上锁原理

Redisson 在上锁时,向 Redis 中添加的简直对的键是 UUID + thread-id 拼接而成的字符串;值是这个锁的上锁次数。

Redisson 如何保证线程间的互斥以及锁的重入(反复上锁)

因为代表这锁的简直对的键中含有线程 ID ,因此,当你执行上锁操作时,Redisson 会判断你是否是锁的持有者,即,当前线程的 ID 是否和键值对中的线程 ID 一样。

如果当前执行 lock 的线程 ID 和之前执行 lock 成功的线程的 ID 不一致,则意味着是 “第二个人在申请锁” ,那么就 lock 失败;如果 ID 是一样的,那么就是 “同一个” 在反复 lock,那么就累加锁的上锁次数,即实现了重入。

# 9. watch dog 自动延期机制

如果在使用 lock/tryLock 方法时,你指定了超时自动删除时间,那么到期之后,Redis 会自动将代表锁的键值对给删除掉。

如果,你在使用 lock/tryLock 方法时,没有指定超时自动删除时间,那么,就完全依靠你的手动删除( unlock 方法 ),那么,这种情况下你会遇到一个问题:如果你有意或无意中忘记了 unlock 释放锁,那么锁背后的键值对将会在 Redis 中长期存在!

再次强调

Redisson 看门狗(watch dog)在指定了加锁时间时,是不会对锁时间自动续租的。

在 watch dog 机制中,有一个被 “隐瞒” 的细节:表面上看,你的 lock 方法没有指定锁定时长,但是 Redisson 去 Redis 中添加代表锁的键值对时,它还是添加了自动删除时间。默认 30 秒(可配置)

这意味着,如果,你没有主动 unlock 进行解锁,那么这个代表锁的键值对也会在 30 秒之后被 Redis 自动删除,但是很显然,并没有。这正是因为 Redisson 利用 watch dog 机制对它进行了续期( 使用 Redis 的 expire 命令重新指定新的过期时间)

redisson-watch-dog.png

Redisson 的 watch dog 实现核心代码如上图源码所示:

  1. 当你调用 lock 方法上锁,且没有指定锁定时间时,Redisson 在向 Redis 添加完键值对之后会调用到上面的 renewExpiration() 方法;

  2. 在 renewExpiration 方法中,Redisson 向线程池中添加了一段 “代码” ,并要求其在 30/3 秒之后( internalLockLesaseTime / 3 )执行;

  3. 这段代码在被执行时,它为代表锁的键值对重新设置过期时间(30 秒),并且递归调用了自己,将自己又一次交给线程池在 10 秒之后执行。

逻辑上,变相地就是实现了一个 10 秒续期一次的定时任务。Redisson 会不停地为这个键值对重置过期删除时间,直到你在代码层面调用了 unlock 删除了这个键值对为止。

无休止地续期会不会导致代表锁的键值对永远存在?

watch dog 利用这这样的一个隐含逻辑:如果 watch dog 线程(执行续期的线程)还存在,那就意味着这个项目仍然是在正常运行的,项目正常运行,那么意味着一切正常,只是执行业务的线程没有执行完而已。

如果整个项目挂掉了,那么 watch dog 线程自然也就挂掉了,watch dog 线程挂掉了,那么就没有无限续期了,那么最多 30 秒后那个键值对也就被 Redis 删除了。

有没有可能项目的进程还在,但是持有锁的线程挂掉了?这是 bug ,应该解决!

# 10. Redisson 执行的 Lua 脚本(了解)

  • 枷锁的 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]);
  • 释放锁的 Lua 脚本
if (redis.call('exists', KEYS[1]) == 0) then
       redis.call('publish', KEYS[2], ARGV[1]);
        return 1; 
        end;
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;