暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

浅析分布式锁

阿东编程之路 2022-09-25
403


我们之前讲的 synchronized、juc 下的锁只能保证本地操作的原子性和本地资源的线程安全,如果在分布式场景下,程序在不同的 JVM 虚拟机中运行,本地的锁是无法实现原子性操作的,需要引入分布式锁才可以实现分布式下的操作原子性和共享资源的线程安全。
分布式锁其实就是控制不同进程或主机之间访问共享资源的一种锁实现,使用场景也很多,主要分为以下两种:
  • 保证共享数据正确:并发修改共享数据会有现成安全问题,比如先查再写;

  • 保证操作执行一次:有时候为了节省资源让事件只执行一次或保证接口的幂等性。

分布式锁需要满足的基本条件:
  • 原子性:同一时刻,只有一个实例中的一个线程持有锁;
  • 避免死锁:如果某个实例或服务获取锁后且在解锁之前发生故障,会导致锁一直存在,别的实例或服务永远无法获取锁执行后续流程,所以需要避免这种情况。主流的方案是设置一个锁过期时间;
  • 可靠性:为了避免单点故障,锁服务需要支持集群模式且有一套容错机制。

分布式基本上都是通过公共存储实现的。

一. 基于 MySQL 实现分布式锁
基于 MySQL 实现的分布式锁需要提前创建一张表,通过插入一个唯一键或者更新字段状态来实现。
创建锁表:
    CREATE TABLE `lock` (
    `key` varchar(32) NOT NULL,
    `created_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `expired_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '过期时间',
    `lock_by` varchar(32) NOT NULL COMMENT '线程唯一标识',
    PRIMARY KEY (`key`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    加锁:
      insert into lock 
      (key, expired_time, lock_by)
      values
      ("锁key", "锁过期时间戳", "唯一标识");
      因为数据是天然线程安全的且 key 是唯一的,多个线程同时插入只有一个会成功,插入成功代表获取到锁。
      解锁
        delete from lock 
        where key"锁key" and lock_by = "唯一标识"
        上述只是 MySQL 实现分布式锁的一个例子,也可以通过更新锁状态的方式加锁解锁;不过由于MySQ是基于磁盘存储,性能自然不太行,所以基于 MySQL 实现的分布式锁并不是主流。

        二. 基于 Redis 实现分布式锁
        Redis 是基于内存的高性能内存数据库(KV 键值对存储)。所有操作都基于内存的,拥有高效的数据结构,单线程省去线程切换的开销(网络 IO 和键值对读写是单线程的,6.0 版本对网络 IO 增加了多线程处理但是读写还是单线程),并且在网络 IO 上采用多路复用机制,这些都是 Redis 性能强劲的原因。
        Redis 实现分布式锁是基于 setnx 命令,setnx 全称是 set if not exists,就是如果不存在才 set 。
        Redis客户端的命令:
          SETNX 'lock_key' '1'

          返回操作结果:

          如果我们再次 setnx 就会返回 0(失败):

          之前我们说了分布式锁需要防止客户端崩溃没来得及执行解锁逻辑导致死锁,所以可以通过 expire 命令对该键设置过期时间。
          使用开源的 Redis Java sdk - Jedis 的加锁逻辑:
            if(jedis.setnx(key, value) == 1) {
            jedis.expire(key, expireTime);
            }
            但是 setnx 和 expire 是两步操作,如果客户端 setnx 成功后,expire 时由于网络异常或者服务挂掉了,该锁就永不失效从而导致死锁

            从 2.6.12 版本后,Redis 支持了 setnx 和 expire 的原子命令:
              SET "lock_key" "1" NX EX 10
              set 命令后加上 NX 代表 setnx,也就是当 key 不存在才会 set 成功;EX 代表过期时间,单位为 s;过期时间参数也可以换成 PX,单位是 ms。
              那在代码中使用 Jedis 的加锁逻辑:
                public boolean lock(String key, String requestId, Integer expireSeconds) {
                try {
                return "OK".equals(jedis
                .set(key, requestId, new SetParams().nx().ex(expireSeconds)));
                } catch (Exception e) {
                log.info("lock error", e);
                return false;
                }
                }
                解锁的逻辑:
                  public void unlock(String key) {
                  try {
                  jedis.del(key);
                  } catch (Exception e) {
                  log.error("unlock error, key={}", key, e);
                  }
                  }

                  那如果像这样调用上述方法进行加锁解锁有问题吗?
                    String key = "lockKey";
                    if (lock(key, "1", 3)) {
                    try {
                    // 业务逻辑
                    } finally {
                    unlock(key);
                    }
                    }

                    还真有问题,我们来捋一下,其实上述代码分为三步:加锁 -> 执行业务逻辑 -> 解锁;我们来分析下:


                    时刻1:线程 1 获取到锁;
                    时刻2:线程 1 执行业务逻辑;
                    时刻3:线程1 所在的服务可能发生线程切换、服务流量较大发生 GC 停顿或没抢到 CPU 执行权等情况;
                    时刻4:达到设置的过期时间,锁过期;接着线程 2 获取到锁;
                    时刻5:线程 1 抢到 CPU 执行权执行 finally 内的 unlock 方法,将线程 2 争抢到的锁删除;
                    时刻6:由于锁被删除,线程 3 此时也能获取到锁;
                    时刻7:线程2和线程3同时执行业务逻辑,破坏了原子性,最终可能会导致数据不正确。

                    我们可以看得出来,造成上述问题的根本原因是线程释放了别人的锁。要解决的话也比较简单,就是加锁的时候加上一个唯一标识解锁的时候判断锁上的唯一标识是否匹配,匹配才允许解锁juc 包下的锁也是类似该逻辑):
                    所以我们的逻辑就改成这样:
                      public void method() {
                      String key = "lockKey";
                      String requestId = UUID.randomUUID().toString();
                      if (lock(key, requestId, 3)) {
                      try {
                      // 业务逻辑
                      } finally {
                      if (Objects.equals(jedis.get(key), requestId)) {
                      unlock(key);
                      }
                      }
                      }
                      }
                      加锁时将唯一标识放到 value 上,解锁时判断锁的 value 值是否和唯一标识匹配,匹配代表当前锁属于该线程,允许解锁。这样就能保证线程只会释放自己的锁

                      加上唯一标识后就没有问题了吗?
                      还是有问题,因为解锁的过程不是原子的:get -> 判断 -> delete,还是会因为一些问题比如 GC 停顿、CPU 被占满等原因导致阻塞或是网络异常导致误释放别人的锁,我们再来分析下:

                      时刻1:线程 1 生成唯一标识,获取到锁,然后执行业务逻辑;
                      时刻2:get(key) 判断唯一标识值匹配,判断出锁属于该线程;
                      时刻3:线程 1 由于 GC 停顿、CPU 被占满等原因导致阻塞;
                      时刻4:达到锁超时时间,锁自动过期;
                      时刻5:因为锁已过期,此时线程 2 获取到了锁;
                      时刻6:线程 1 此时抢到 CPU 执行权继续执行释放锁 unlock() 操作;
                      时刻7:由于锁被释放,线程 3 也争抢到锁;
                      时刻8:线程 2 和线程 3 还是会同时执行业务逻辑,破坏了原子性,最终可能会导致数据不正确。

                      要解决这个问题,就必须保证 get -> 判断 -> delete 的原子性,这时候就要使用 LUA 脚本配合 Redis 一起使用了,Redis 中使用 eval 命令支持 LUA 脚本的执行,我们可以将  get -> 判断 -> delete 三步逻辑写在脚本里:
                        if redis.call("get",KEYS[1]) == ARGV[1] then
                        return redis.call("del",KEYS[1])
                        else
                        return 0
                        end

                        上述脚本的逻辑是:先 get 锁的 value(KEYS[1] 是获取集合 key 中的第一个key),和唯一标识进行比较(ARGV[1] 是获取参数集合的第一个参数),如果匹配成功就通过 del 命令将锁释放并返回结果,否则返回失败。
                        Redis 会把整个 lua 脚本作为一个整体执行,执行的过程中不会被别的命令打断,从而保证多个命令的原子性。
                        所以将这三步逻辑写成一个脚本命令发送给 Redis,就能保证原子性了。
                        使用 lua 脚本代码解锁逻辑如下:
                          String UNLOCK_LUA = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then\n" +
                          " return redis.call(\"del\",KEYS[1])\n" +
                          " else\n" +
                          " return 0\n" +
                          " end";
                          public void unlock(String key, String requestId) {
                          try {
                          jedis.eval(UNLOCK_LUA, 1, key, requestId);
                          } catch (Exception e) {
                          log.error("unlock error", e);
                          // 发送至消息队列重试或告警
                          }
                          }

                          针对 Redis 中 lua 脚本的使用,还有一个优化点,对于较长的脚本,可以预先使用 SCRIPT LOAD 命令将脚本加载到 Redis 中,然后 Redis 会返回一串摘要,我们可以将摘要缓存在本地,后面再通过 EVALSHA 命令 + 摘要 运行脚本,减少网络传输中不必要的开销。

                          锁过期时间设置多少合适?
                          这个过期时间需要提前预测下中间业务代码的执行时间,但实际场景会有很多不确定的因素,比如 GC 停顿、等待数据库连接、慢SQL以及操作系统网络等问题导致耗时增加,有可能锁已经过期,但是业务逻辑还没执行完,又会出现锁失效的情况
                          其实针对这个问题可以加个锁续期的逻辑,当加锁成功后,启动一个定时任务,每隔一段时间检查锁是否存在和唯一标识,如果存在就进行续期,不存在就结束定时任务;例如加锁时间是 30 秒,可以设置定时任务每 10 秒执行一次,每次续期 30 秒;这套逻辑在 Redisson 框架中已经实现,感兴趣的话可以去看看源码。

                          如何实现线程阻塞?
                          如果获取不到锁,本地锁(synchronized 和 juc 锁)都有一套阻塞唤醒机机制管理线程,而 Redis 在服务端没有这样的实现,如果业务场景需要阻塞,则需要我们自己实现,比如获取不到锁的线程进入 while 循环自旋获取锁,同时需要限制自旋次数和间隔时间

                          Redis 集群主从切换导致锁失效
                          为了保证 Redis 的高可用,我们一般会以集群模式部署 Redis,同时为了提高性能,还会再加个分片集群(一致性 hash 算法将 key 分布到不同的 Redis 节点上)的模式。
                          分布式系统中有个著名的CAP理论(一致性(Consistency),可用性(Availability)和分区容错性(Partition tolerance)),分布式系统只能保证其中的两项,而分布式系统最重要的就是P(分区容错):当发生网络分区时还可以对外提供服务;所以分布式面临的抉择就是在发生 P(网络分区)时,选择 C(一致性)还是 A(可用性);
                          Redis 选择的是可用性,当 Redis 主节点收到命令执行后会直接返回结果,后续异步同步至从节点。而在这期间就会有个时间窗口主节点有锁数据而从节点还未同步锁数据,如果此时主节点发生宕机,Redis 集群会发生选举选择一台从节点为主节点继续对外提供读写,而新的主节点还未同步到锁数据,那么锁数据就会丢失导致锁失效。
                          针对这种场景,也有一种业界褒贬不一的解决方案:Redlock(红锁算法),大概的逻辑是向 N 个独立的 Redis 节点发起加锁请求,如果有N/2 + 1 及以上的 Redis 节点返回成功,就算加锁成功;中间的过程还需要重新计算加锁时间,逻辑较为复杂且会引入新的问题,还需要依赖 N 个完全独立的 Redis 节点,性能也比较差
                          有兴趣可以去官网看下 Redlock 的介绍:https://redis.io/docs/reference/patterns/distributed-locks/

                          就算是 Redlock 也不是百分之百可靠的,鱼和熊掌不可兼得,有得必有失;Redis 普通的分布式锁也够大部分场景使用了。

                          三. 基于 Zookeeper 实现分布式锁
                          Zookeeper 一般被用作注册中心和配置中心,存储的数据结构类似树,树由节点 Znode 组成,Znode 分为四种:
                          • 持久节点:创建后不主动删除不会被清理。
                          • 持久顺序节点:创建后不主动删除不会被清理,并且 Zookeeper 会根据顺序给节点进行编号。
                          • 临时节点:创建后,当创建节点的客户端和 Zookeeper 断开连接后就会被删除。
                          • 临时顺序节点:创建后,当创建节点的客户端和 Zookeeper 断开连接后就会被删除,并且 Zookeeper 会根据顺序给节点进行编号。

                          主流的方案是使用 临时顺序节点 来实现分布式锁,使用临时节点后就不需要关注锁的过期时间;客户端和 Zookeeper 服务端维护一个 Session 长连接,这个长连接依赖客户端定时向服务端发送心跳检测维持,当 Zookeeper 服务端超过Session 的设置的过期时间没收到心跳,就会认为 Session 过期,并将该客户端创建的所有临时节点删除。

                          基于 Zookeeper 实现的分布式锁的逻辑如下:
                          1. 在锁路径下创建一个临时顺序节点。
                          2. 判断当前节点是否是最小的节点,如果是代表获取锁成功。
                          3. 如果不是最小的节点,监听上一个节点并阻塞。
                          4. 上一个节点的线程处理完业务逻辑将节点删除后会唤醒下一个节点(ZK 的 watch 机制);
                          5. 被唤醒的节点执行业务逻辑。
                          这个逻辑看似无懈可击,并且还支持线程阻塞,但其实也并非百分之百可靠的;因为我们上面说了临时节点是依赖 Session 长连接的,而这个长连接又依赖客户端定时发送心跳维持;如果客户端因为出现短暂的网络分区或 GC 停顿导致心跳没发出去,Zookeeper 就会判断该 Session 失效,将该客户端的临时节点删除,此时别的线程又可以获取到锁,最终导致锁失效;所以 Zookeeper 作分布式锁不适合业务逻辑执行时间长的场景
                          其次,使用 Zookeeper 实现分布式锁的性能是比 Redis 差很多的(Redlock除外),Zookeeper 在一致性和可用性上选择了一致性,主节点接收读写请求,并将写请求同步至各个从节点成功后才最终返回成功,而这也是 Zookeeper 性能差的原因。

                          综上所述,Zookeeper 只适合并发较低、业务执行时间短或只固定在某个时间点有并发的场景;比如分布式服务在凌晨 2 点统计数据,为了减少依赖没有引入第三方的分布式任务服务,就在本地使用定时任务,这个时候就可以使用 Zookeeper 的分布式锁,并发只在固定时间点且并发数仅仅为机器个数

                          四. 总结
                          主流的分布式锁是 Redis 和 Zookeeper 两种实现,二者各有优缺点:
                          • 功能上:Zookeeper 本身就可以防止客户端崩溃导致的死锁,并且支持线程阻塞;而 Redis 需要手动加过期时间防止死锁,必要时还需要有锁续期的逻辑,还需要有特定的逻辑防止误释放别人的锁;
                          • 性能上:由于 Zookeeper 是 AP(一致性),性能上肯定不行;Redis 选择的 AP(可用性)和本身的一些特性所以性能很高。

                          不管是 Redis 和 Zookeeper,所实现的分布式锁都不是百分之百可靠的;想要保证可靠还是需要根据业务场景进行使用和优化改造,最好是有兜底方案,比如定时任务补偿检查保证数据缓存正确,相关数据库修改(非幂等)的可以加个乐观锁(版本号)。




                          https://mp.weixin.qq.com/s/-N4x6EkxwAYDGdJhwvmZLw
                          https://redis.io/docs/reference/patterns/distributed-locks/
                          文章转载自阿东编程之路,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                          评论