分布式锁

Lou.Chen2022年10月9日
大约 20 分钟

1.分布式锁概述

1.1 什么是分布式锁

单机情况下,为了避免同时操作一个共享变量产生数据问题,通常会使用一把锁来「互斥」,以保证共享变量的正确性,其使用范围是在「同一个进程」中。

分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。

一个最基本的分布式锁需要满足:

  • 互斥 :任意一个时刻,锁只能被一个线程持有;
  • 高可用 :锁服务是高可用的。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。

想要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上申请「加锁」。

而这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。

1.2 实现分布式的锁常见方案

  • 基于 Redis 实现的分布式锁
  • 基于 Zookeeper 实现的分布式锁

2.Redis实现分布式锁

2.1 SETNX+LUA基本实现

2.1.1 无法释放锁

在的Redis的String类型中,setnxSET if Not eXists)命令:如果key存在,则返回失败。如果key不存在,则设置值成功。

客户端1申请锁,加锁成功:

127.0.0.1:6379> SETNX lock 1
(integer) 1     // 客户端1,加锁成功

客户端2申请锁,加锁失败:

127.0.0.1:6379> SETNX lock 1
(integer) 0     // 客户端2,加锁失败
image-20220929172428209
    public static void main(String[] args) {
        Redis redis = new Redis();
        redis.execute(jedis -> {
//            获得锁,只有获得了锁才能进行数据的操作
//            这里我们只是测试作用,作为占坑检验操作
            Long setnx = jedis.setnx("k1", "v1");
            if (set != null && set.equals("OK")) {
                //业务操作
              	//.......
              	//.......
//              释放占用的资源
                jedis.del("k1");
            } else {
//                修改失败 有人占位
                System.out.println("有人占坑");
            }
        });
    }

但是,如果客户端1拿到锁后,如果发生以下场景就可能发生死锁:

  • 程序操作共享资源时出现异常,没有及时释放锁
  • 该服务进程挂了,没有及时释放锁

此时另一个客户端永远无法拿到此分布式锁,造成死锁

2.1.2 避免死锁

如何避免死锁呢,可以在申请锁的时候为锁加上一个过期时间。这里假设客户端获得锁后在操作共享资源时间不会超过10s

申请锁后并设置锁的超时时间:

127.0.0.1:6379> SETNX lock 1    // 加锁
(integer) 1
127.0.0.1:6379> EXPIRE lock 10  // 10s后自动过期
(integer) 1

    public static void main(String[] args) {
        Redis redis = new Redis();
        redis.execute(jedis -> {
//            获得锁,只有获得了锁才能进行数据的操作
//            这里我们只是测试作用,作为占坑检验操作
            Long setnx = jedis.setnx("k1", "v1");
            if (set != null && set.equals("OK")) {
                //给锁添加一个过期时间,防止应用在运行过程中抛出异常导致锁无法及时得到释放
                jedis.expire("k1", 5);
                //业务操作
              	//.......
              	//.......
//              释放占用的资源
                jedis.del("k1");
            } else {
//                修改失败 有人占位
                System.out.println("有人占坑");
            }
        });
    }

这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。

上述加锁设置超时时间为两条命令,不能保证原子性,会出现以下问题:

  • SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
  • SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
  • SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行

总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。

保证原子性:

在 Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。

但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:

// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
    public static void main(String[] args) {
        Redis redis = new Redis();
        redis.execute(jedis -> {
//            这里我们可以在set操作的同时,同时加入其他参数:nx不存在时操作,ex设置过期时间
//            这样我们可以防止在获得锁和设置过期时间,如果服务器挂掉,则这个时候锁被占用,无法及时得到释放,从而造成死锁。获得锁的时候同时设置时间保证原子性
            String set = jedis.set("k1", "v1", new SetParams().nx().ex(5));
            if (set != null && set.equals("OK")) {
//                给锁添加一个过期时间,防止应用在运行过程中抛出异常导致锁无法及时得到释放
                jedis.expire("k1", 5);
                //业务操作
              	//.......
              	//.......
//              释放占用的资源
                jedis.del("k1");
            } else {
//                修改失败 有人占位
                System.out.println("有人占坑");
            }
        });
    }

这样就解决了死锁问题,也比较简单。

但是由于客户端操作共享资源的时间不确定性,导致锁的超时时间不确定,就会出现以下问题:

  • 客户端 1 加锁成功,开始操作共享资源
  • 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
  • 客户端 2 加锁成功,开始操作共享资源
  • 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)

导致出现以下两个严重问题:

  • 锁过期:客户端1如果操作的共享资源时间比较长,导致超过了锁的超时时间,导致锁自动被释放。
  • 释放了别人的锁:锁被释放后,客户端2此时获取到锁。接着客户端1操作完共享资源后,释放了锁,此时释放的为客户端2的锁。

具体分析:

  • **锁过期:**设置 10秒? 20秒? 都不可。因为操作共享资源时间的不确定,这里不好确定锁的超时时间,由于业务的复杂性和不确定性所以这里不能明确锁的超时时间。

  • **释放了别人的锁:**重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!

2.1.3 避免释放别人的锁

如何避免释放别人的锁?

解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。

例如,可以是自己的线程 ID,也可以是一个 UUID(随机且唯一),这里我们以 UUID 举例:

// 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK

这里的锁过期时间的影响现在暂时不考虑,假设完全够用

之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:

// 锁是自己的,才释放
if redis.get("lock") == $uuid:
    redis.del("lock")

这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了。

  • 客户端 1 执行 GET,判断锁是自己的

  • 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型)

  • 客户端 1 执行 DEL,却释放了客户端 2 的锁

由此可见,这两个命令还是必须要原子执行才行。

怎样原子执行呢?Lua 脚本

Lua 脚本的优势:

  • 使用方便,Redis 中内置了对 Lua 脚本的支持。

  • Lua 脚本可以在 Redis 服务端原子的执行多个 Redis 命令。

  • 由于网络在很大程度上会影响到 Redis 性能,而使用 Lua 脚本可以让多个命令一次执行,可以有效解决网络给 Redis 带来的性能问题。

在 Redis 中,使用 Lua 脚本,大致上两种思路:

安全释放锁的 Lua 脚本如下:

if redis.call("GET",KEYS[1]) == ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end

reids.call 表示调用get操作方式,KEYS[1]表示传过来要操作的key(可以有多个key,索引从1开始),ARGV表示传过来的其他参数。

  • 方式1:提前在 Redis 服务端写好 Lua 脚本,然后在 Java 客户端去调用脚本(推荐)。

    • 切换到redis安装目录,创建存储lua的文件夹

    • 创建vi releasewherevalueequal.lua文件

    • 编写内容,保存退出

    • 给Lua脚本求一个SHA1和(相当于给该lua文件算出一个标识符)

      • redis-cli script load "$(cat lua/releasewherevalueequal.lua)"
      • 返回的标识符:9d0abd0b3b3bfd1b5294f957dcab483e58b97c84
    • script load相当于这个命令会在 Redis 服务器中缓存 Lua 脚本,并返回脚本内容的 SHA1 校验和,然后在 Java 端调用时,传入 SHA1 校验和作为参数,这样 Redis 服务端就知道执行哪个脚本了。

    • 接下来,在 Java 端调用这个脚本。

          public static void main(String[] args) {
              Redis redis=new Redis();
              redis.execute(jedis -> {
      //            1、获取一个随机字符串
                  String s = UUID.randomUUID().toString();
      //            2、获取锁
                  String k1 = jedis.set("k1", s, new SetParams().nx().ex(50));
      //            3、判断是否拿到锁
                  if (k1 != null && k1.equals("OK")) {
      								//业务操作
                    	//.......
                    	//.......
      //                4、释放锁
      //                校验和;  keys;  其他的参数
      //                使用redis中加载的lua
                      jedis.evalsha("9d0abd0b3b3bfd1b5294f957dcab483e58b97c84", Arrays.asList("k1"),Arrays.asList(s));
                  }else{
                      System.out.println("没有拿到锁");
                  }
              });
          }
      
  • 方式2:可以直接在 Java 端去写 Lua 脚本,写好之后,需要执行时,每次将脚本发送到 Redis 上去执行。

        public static void main(String[] args) {
            Redis redis=new Redis();
            redis.execute(jedis -> {
    //            1、获取一个随机字符串
                String s = UUID.randomUUID().toString();
    //            2、获取锁
                String k1 = jedis.set("k1", s, new SetParams().nx().ex(50));
    //            3、判断是否拿到锁
                if (k1 != null && k1.equals("OK")) {
    								//业务操作
                  	//.......
                  	//.......
                  	//4、释放锁
    //               直接在java客户端中写lua
                    String script =
                            "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                                    "   return redis.call('del',KEYS[1]) " +
                                    "else" +
                                    "   return 0 " +
                                    "end";
                    jedis.eval(script, Arrays.asList("k1"), Arrays.asList(s));
                }else{
                    System.out.println("没有拿到锁");
                }
            });
        }
    

大致流程如下:

  • 加锁:SET lock_key $unique_id EX $expire_time NX

  • 操作共享资源

  • 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

image-20220930143259636

但是回到最后一个问题,锁过期时间不好评估怎么办?

2.2 使用Redisson实现锁的续期

前面我们提到,锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。

当时给的妥协方案是,尽量「冗余」过期时间,降低锁提前过期的概率。

这个方案其实也不能完美解决问题,那怎么办呢?

是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间所以也就不用考虑分布式锁被别人释放的问题了

如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson。如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。

image-20220930145321975

看门狗名字的由来于 getLockWatchdogTimeou() 方法,这个方法返回的是看门狗给锁续期的过期时间,默认为 30

//默认 30秒,支持修改
private long lockWatchdogTimeout = 30 * 1000;

public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {
    this.lockWatchdogTimeout = lockWatchdogTimeout;
    return this;
}
public long getLockWatchdogTimeout() {
  	return lockWatchdogTimeout;
}

默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。

Watch Dog 通过调用 renewExpirationAsync() 方法实现锁的异步续期:

protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认)
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getRawName()),
            internalLockLeaseTime, getLockName(threadId));
}

可以看出, renewExpirationAsync 方法其实是调用 Lua 脚本实现的续期,这样做主要是为了保证续期操作的原子性。

我这里以 Redisson 的分布式可重入锁 RLock 为例来说明如何使用 Redisson 实现分布式锁:

// 1.获取指定的分布式锁对象
RLock lock = redisson.getLock("lock");
// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制
lock.lock();
// 3.执行业务
...
// 4.释放锁
lock.unlock();

只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。

// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制
lock.lock(10, TimeUnit.SECONDS);

除此之外,这个 SDK 还封装了很多易用的功能:

  • 可重入锁
  • 乐观锁
  • 公平锁
  • 读写锁
  • Redlock(红锁,下面会详细讲)

小结

到这里我们再小结一下,基于 Redis 的实现分布式锁,前面遇到的问题,以及对应的解决方案:

  • 死锁:设置过期时间
  • 过期时间评估不好,锁提前过期:守护线程,自动续期
  • 锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放。并引用「Lua脚本」解决获取和判断唯一标识以及释放锁的原子性操作

2.3 Redis集群下分布式锁的可靠性

之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。

而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。

那当「主从发生切换」时,这个分布锁会依旧安全吗?

试想这样的场景:

  • 客户端 1 在主库上执行 SET 命令,加锁成功

  • 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)

  • 从库被哨兵提升为新主库,这个锁在新的主库上丢失了!

image-20221008105716159

可见,当引入 Redis 副本后,分布锁还是可能会受到影响。

怎么解决这个问题?

为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)

2.3.1 Redlock使用前提

Redlock 的方案基于 2 个前提:

  • 不再需要部署从库哨兵实例,只部署主库

  • 但主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。

image-20221008110314515
2.3.2 Redlock具体流程

Redlock 算法的思想是让客户端向 Redis 集群中的多个独立的 Redis 实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败

整体的流程是这样的,一共分为 5 步:

  • 客户端先获取「当前时间戳T1」
  • 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  • 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
  • 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  • 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

总结:

  • 客户端在多个 Redis 实例上申请加锁
  • 必须保证大多数节点加锁成功
  • 大多数节点加锁的总耗时,要小于锁设置的过期时间
  • 释放锁,要向全部节点发起释放锁请求

为什么要在多个实例上加锁?

本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

为什么大多数加锁成功,才算成功?

多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。

在分布式系统中,总会出现「异常节点」,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。

这是一个分布式系统「容错」问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。

为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?

因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

为什么释放锁,要操作所有节点?

在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。

例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。

所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。

好了,明白了 Redlock 的流程和相关问题,看似 Redlock 确实解决了 Redis 节点异常宕机锁失效的问题,保证了锁的「安全性」。

2.3.3 Redlock的争论

https://mp.weixin.qq.com/s/s8xjm1ZCKIoTGT3DCVA4awopen in new window

https://mp.weixin.qq.com/s?__biz=Mzg3NjU3NTkwMQ==&mid=2247505097&idx=1&sn=5c03cb769c4458350f4d4a321ad51f5a&source=41#wechat_redirectopen in new window

3.Zookeeper实现分布式锁

Zookeeper 的节点 Znode 有四种类型:

  • **持久节点:**默认的节点类型。创建节点的客户端与 zookeeper 断开连接后,该节点依旧存在。
  • **持久节点顺序节点:**所谓顺序节点,就是在创建节点时,Zookeeper 根据创建的时间顺序给该节点名称进行编号,持久节点顺序节点就是有顺序的持久节点。
  • **临时节点:**和持久节点相反,当创建节点的客户端与 zookeeper 断开连接后,临时节点会被删除。
  • **临时顺序节点:**有顺序的临时节点。
image-20221008164741966

大致流程:

每个客户端都往指定的节点下注册一个临时有序节点,越早创建的节点,节点的顺序编号就越小,那么我们可以判断子节点中最小的节点设置为获得锁。如果自己的节点不

是所有子节点中最小的,意味着还没有获得锁。这个的实现和前面单节点实现的差异性在于,每个节点只需要监听比自己小的节点,当比自己小的节点删除以后,客户端会

收到watcher事件,此时再次判断自己的节点是不是所有子节点中最小的,如果是则获得锁,否则就不断重复这个过程,这样就不会导致羊群效应,因为每个客户端只需要

监控一个节点。

现在大多使用Curator框架来实现分布式锁,内部封装了Java API对Zookeeper操作繁琐的问题。

Zookeeper 是如何保证让这个客户端一直持有锁呢?

客户端创建临时节点后会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端**「定时心跳」**来维持连接。如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。

image-20221008170611533

同样地,基于此问题,我们也讨论一下 GC 问题对 Zookeeper 的锁有何影响:

  • 客户端 1 创建临时节点 /lock 成功,拿到了锁
  • 客户端 1 发生长时间 GC
  • 客户端 1 无法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」
  • 客户端 2 创建临时节点 /lock 成功,拿到了锁
  • 客户端 1 GC 结束,它仍然认为自己持有锁(冲突)

可见,即使是使用 Zookeeper,也无法保证进程 GC、网络延迟异常场景下的安全性。

如果客户端已经拿到了锁,但客户端与锁服务器发生「失联」(例如 GC),那不止 Redlock 有问题,其它锁服务都有类似的问题,Zookeeper 也是一样!

优点:

  • 不需要考虑锁的过期时间
    • 客户端拿到锁后,只要连接不断,就可以一直持有锁
    • 如果客户端异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放
  • watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁

缺点:

  • 性能不如 Redis
  • 部署和运维成本高
  • 客户端与 Zookeeper 的长时间失联,锁被释放问题
  • 如果有大量的请求,那么会注册大量的监听事件,导致zookeeper服务器压力大

4.总结

实际项目中不建议使用 Redlock 算法,成本和收益不成正比。

如果不是非要实现绝对可靠的分布式锁的话,其实单机版 Redis 就完全够了,实现简单,性能也非常高。如果你必须要实现一个绝对可靠的分布式锁的话,可以基于 Zookeeper 来做,只是性能会差一些。

参考:

https://javaguide.cn/distributed-system/distributed-lock.html#redis-如何解决集群情况下分布式锁的可靠性open in new window

https://mp.weixin.qq.com/s/s8xjm1ZCKIoTGT3DCVA4awopen in new window

https://mp.weixin.qq.com/s/-2tAMhzpgNzCkImNFfS4vAopen in new window

https://blog.csdn.net/qq_41432730/article/details/123389670open in new window