从分布式理论到如何做一个生产级别的分布式锁

前言

微服务的流行,使得现在基本都是分布式开发,也就是同一份代码会在多台机器上部署运行,此时若多台机器需要同步访问同一个资源(同一时间只能有一个节点机器在运行同一段代码),就需要使用到分布式锁。然而做好一个分布式锁并不容易,要考虑的点非常多,建议架构能力一般的公司对于分布式锁还是使用现有的开源框架来做(例如Redis的Redisson、Zookeeper的Curator、etcd等等),如果需要基于Redis、ZK进行自研的话,建议阅读接下来讨论的几个要点。

1. AP还是CP?

首先我觉得最重要的就是考虑分布式协议的CAP特性,因为这直接决定了分布式锁的强弱、性能的强弱,详细的CAP理论到分布式一致性协议的讨论请点击访问,接下来看看AP、CP模型的分布式锁都将会有哪些表现

1.1 CP模型

这里CP模型我选用了Zookeeper(Zab协议)做例子,其实Etcd的Raft算法也是一个道理,但是我没用过Etcd…

首先,Zab和Raft是如何保证CP模型呢?没有这个基础的读者建议火速浏览这篇文章,这里只讨论优点和可能存在的某些问题。
在这里插入图片描述
在这里插入图片描述

主要是由图1的数据复制规则+图2的选举主节点规则结合起来,使得Set的数据在集群半数节点以上存活的时候一定不会丢,保证了数据一致性,但是如果集群半数以上节点宕机,集群将不对外服务,查询不到值,也是一种一致性的体现(不给总比给错乱给好,至少在分布式锁场景下是这样,不加锁总比锁失效强吧?)

CP模型存在的问题?

从图3的数据复制规则来看,客户端要求Set一个值的时候并没有立即返回,而是需要确保这个值在半数以上的节点上保存下来了才会返回给客户端成功响应,这样的话延时性就取决于最快的那半数节点的写入性能,而且加多了网络通信来回的开销,在一定程度上延时性会弱一些,框架如果在高并发场景下可能会出现性能下滑(Zookeeper)的结果,这也就是为什么Zookeeper作为注册中心不被看好的原因,微服务链路调用都需要使用注册中心获取服务的IP地址,并发量可见一斑,但IP地址这种东西小概率存在不一致(服务刚上线,但注册中心没有这个服务的IP地址,使得不被访问到)其实是可以接受的,在注册中心的场景下延时性才最重要,这也就是为什么Nacos的注册中心会选用我们接下来要讨论的AP模型,他的延时性相对是要好的。

但是如果在不容许分布式锁失效且并发量、性能要求不是特别严格的场景下,这种CP模型是再合适不过了。

1.2 AP模型

这里AP模型我选用了Redis的主备集群做例子

首先,为什么Redis的主备是AP模型?
在这里插入图片描述

从图3可以看出来,redis的主备复制采用了异步增量复制(在新的节点启动时会全量+增量优化启动复制的时间,这里不讨论全量+增量模式),主Master节点设置值之后立刻就会返回给客户端OK信息,接下来增量复制值给Slave从节点
在这里插入图片描述
试想一下,若在第三步返回给客户端OK信息后主Master节点宕机了,数据没来得及复制给Slave从节点,此时Sentinel哨兵会选择一个从节点成为Master主节点,由图4可以看出,不管我们此时选择哪个节点做为Master,刚刚设置的值确实是丢失了,这里就造成了不一致,这种主从集群架构会丢失或不一致主节点宕机的一段时间的数据

AP模型的好处?

但这种模型有什么好处呢?又或者说Redis的主备设计成AP模型有什么考量呢?我们结合上面的CP模型来看,可以发现在接受事务请求(增删改数据)的时候,主Master节点只需要确保自己写入即可立即返回给客户端,复制的过程由于是异步的,客户端延时性上来说影响并不大,相比于CP模型的确保半数提交成功,AP模型的延时性是比较低的,Redis本身的定位就是要快,所以这相当符合Redis的设计初衷。再来看看可用性,如果集群有三个节点,他可以容许宕机两个节点,可以看出来,可用性的容错节点是N-1个,相比于CP模型他的可用性会更高,Redis的定位不就是缓存(快+高可用+数据丢失一部分可以接受)吗?

RedLock

那么有没有办法解决Redis主从这种不一致呢?这就是将要介绍的RedLock所做的事,其思想其实和CP模型一样,基于至少3个独立的Redis实例,获取锁的时候要分别访问三个Redis获取锁,半数以上的Redis返回获取锁成功后才能算获取到锁(这不是和CP一样吗?),保证了数据一致性,但是却带来了额外的延时性(要访问3个或以上的Redis服务,也存在网络开销),额外的后期运维复杂性(要多个独立的Redis实例),笔者个人觉得,非要一致性强的场景,为什么不去用Zookeeper或是Etcd呢。。。?在延时要求高、锁偶尔失效可以接受的场景下才会用Redis吧?RedLock牺牲了延时,带来了额外的复杂度,在某种程度上得不偿失,还不如使用专门做这个的强一致性分布式协议做。

Tips

由于Redis作为分布式锁的话有可能会造成数据的不一致,在分布式锁的场景下有可能会造成两个节点同时获取同一把锁,有可能你需要互斥的资源会同一时间被执行两次,如果你使用分布式锁的场景是为了更好的利用系统资源(CPU、内存),让多节点不做一些重复的工作,并行互斥执行不同的任务,那么不妨将你的任务做成幂等的,这样就算两个节点做同一个任务,任务被执行了两次但是它们是幂等的,其结果也不会被影响,而且大部分时间上来看Redis的这把分布式锁确实能够更好的分配系统资源,让一些节点互斥并行起来。

1.3 总结

  • 在延迟性要求高、客户端响应不能太慢、性能要求高的场景下,允许牺牲小部分时间的锁失效来换取好的性能,那么建议使用AP模型(例如Redis)来实现分布式锁
    • 某些场景可以通过幂等弥补小部分锁失效带来的负面影响
  • 在延迟性要求不高、主要保证锁不能失效、高一致性的场景下,允许牺牲一点性能来换取一致性,那么建议使用CP模型(Zookeeper、Etcd)来实现分布式锁
    • 并发量极高的情况下可能有问题,选型时注意调研考虑考虑这点

2. 宕机锁释放问题

什么是宕机锁释放问题?考虑一种情况:分布式锁拿到锁的节点意外宕机,拿到锁而不释放锁,从而死锁,这就是我们要讨论的一个宕机锁释放问题。

2.1 Redis宕机锁释放问题

在Redis中我们解决宕机锁释放问题通常会在设置锁的同时给他设置一个超时时间,这就有一个问题了,这个超时时间要设置多长?如果这个超时时间太长,那节点宕机没释放锁就只能等待锁超时,死锁时间会变长(服务至少有一段时间的不可用),这是不容许的,那如果超时时间太短,又会造成如果有什么做了很久的业务操作,这边还没执行完,另一个节点却也能获取到锁,造成的锁失效,这确实是一个两难的问题,业界有Redisson这个框架实现的还是比较好的,我们接下来讨论Redis的分布式锁会以他作为例子进行分析

Redisson是怎么做的

Redisson加锁入口在org.redisson.RedissonLock#lock(long, java.util.concurrent.TimeUnit, boolean)

在开始加锁的时候会执行这样一段Lua脚本

不要担心看不懂lua脚本,有很详细的注释,仅阅读文字也能知道大概流程

-- KEYS[1] = lockName
-- ARGV[1] = 锁超时时间
-- ARGV[2] = threadId(为了不误释放锁,下面会提到)

-- 判断锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
-- 这里就是锁不存在,设置锁
redis.call('hset', KEYS[1], ARGV[2], 1);
-- 给一个定义的超时时间,比如60s
redis.call('pexpire', KEYS[1], c);
-- 这里可以看出,返回空的话就可以判断获取锁成功了
return nil;
end;
-- 这里判断重入锁的情况,是否同一个线程获取同一个锁,这里就是threadId发挥作用的地方
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 重入锁,将此threadId获取锁次数 +1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 重新设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
-- 返回空即为告诉客户端获取锁成功了
return nil;
end;
-- 代码执行到这里,已经可以判断获取锁失败了,执行一个ttl指令返回锁还有多久超时
return redis.call('pttl', KEYS[1]);

这里我们可以知道,返回空就是获取锁成功,返回一个数字即为获取锁失败,接下来看看对于Redis返回的值Redisson是如何处理的:

// 这里是lock获取分布式锁方法中的一个核心获取锁方法
private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, long threadId) {
  // ...
  
  // tryLockInnerAsync方法就是刚刚执行的那一段lua脚本
  RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
  // onComplete方法是在指定lua脚本执行成功之后要回调onComplete方法
  ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    if (e != null) {
      return;
    }

    // lock acquired
    // 这里ttlRemaining代表lua脚本返回值,从上面已经可以看出,如果等于空,代表获取锁成功
    if (ttlRemaining == null) {
      // 获取锁成功之后会进入这个方法
      scheduleExpirationRenewal(threadId);
    }
  });
  return ttlRemainingFuture;
}

解决超时时间的秘诀就在scheduleExpirationRenewal方法,其会异步交给后台线程去将刚刚获取到的锁的超时时间定时地续租,那么续租多久呢?继续看

private void scheduleExpirationRenewal(long threadId) {
	  // ...屏蔽无关代码
    // 这里比较关键
    renewExpiration();
  }
}
private void renewExpiration() {
  
  //...
	
  // 设置一个定时任务并提交给后台线程做
  Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
    
    // 定时任务逻辑
    @Override
    public void run(Timeout timeout) throws Exception {
      // ...

      // 执行续租的lua脚本
      RFuture<Boolean> future = renewExpirationAsync(threadId);
      future.onComplete((res, e) -> {
        if (e != null) {
          log.error("Can't update lock " + getName() + " expiration", e);
          return;
        }

        // 从下面的lua脚本分析中,可以看出res如果=true,表示锁还在执行
        // 那么继续递归renewExpiration方法继续续租
        if (res) {
          // reschedule itself
          renewExpiration();
        }
      });
    }
    // internalLockLeaseTime是一个自定义的超时时间
    // 可以看出,比如我们设置分布式锁的超时时间为60s,那么这里定时会 60/3=20s 去续租一次超时时间
  }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

  ee.setTimeout(task);
}

可以看到,每过1/3时间就会续租一次,我们进入renewExpirationAsync方法的lua脚本看看续租的逻辑:

-- KEYS[1] = lockName
-- ARGV[1] = 锁超时时间
-- ARGV[2] = threadId(为了不误释放锁,下面会提到)

-- 查看锁是否存在,返回1即为存在
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 续租一个超时时间
redis.call('pexpire', KEYS[1], ARGV[1]);
-- 返回1就是true,说明锁还在
return 1;
end;
-- 代码执行到这里就说明锁已经不在了,0就是false,在上面的代码来看就相当于告诉客户端不需要续租了
return 0;

看到这里,可以发现Redisson其实解决了超时时间过短,锁失效的问题,虽然有续租,但是不建议超时时间太长,如果超时时间太长还是会造成死锁的时间(如果超时时间设置1小时。。那还是会有1小时锁无法获取的情况),也不建议太短,万一JVM进行GC(Stop The World),整个代码进行停顿,后台线程因此有几秒时间无法续租,锁也会失效被其他节点获取,所以这里建议超时时间设置的不大偏小,3、5分钟左右这样子,自己斟酌吧。

2.2 Zookeeper的宕机锁释放问题

在Zookeeper中的宕机锁释放问题其实相对比较好解决,如果使用Zookeeper作为分布式锁,客户端会在ZK上创建一个临时节点,获取到锁的客户端会与ZK维持一个心跳连接,如果ZK收不到客户端的心跳就说明客户端宕机了,此时临时节点会自动释放,相当于自动释放了锁,也就解决了节点宕机锁得不到释放的问题。

3. 锁等待问题

什么是锁等待问题?试想一个场景,A节点获取到锁执行锁区块的业务逻辑,B节点获取不到锁,那么B节点怎么才能知道自己需要阻塞等待多久?这就需要一个通知机制,在锁释放的时候中间件需要通知等待中的节点来获取锁。

3.1 Redis中的锁等待问题

这里依然以Redisson作为例子来分析

获取锁入口:org.redisson.RedissonLock#lock()

先来看看Redisson中获取锁的逻辑是怎么样的

private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
  long threadId = Thread.currentThread().getId();
  // 这里就是执行2.1节开头展示的lua脚本获取锁了
  // 从上面的lua脚本结论我们可以看出来,如果返回空值,说明获取锁成功了
  // 如果获取锁失败,lua脚本中最后会执行ttl指令,返回一个锁的超时时间
  Long ttl = tryAcquire(leaseTime, unit, threadId);
  // lock acquired
  // 这里 ttl=null 的话说明获取锁成功了
  if (ttl == null) {
    return;
  }

  // 代码走到这里,说明获取锁失败了
  // redisson利用了redis的pubsub订阅通知机制来获取锁释放通知
  // 这里以LockName作为ChannelName来订阅消息
  // 这里不介绍pubsub机制,不了解的读者可以暂时将其看作是消息队列
  // subscribe指定channel表示对Channel做了订阅,表示自己是消息消费者
  // 之后锁释放会在同一个channel发起通知,那么订阅的客户端都会收到通知
  RFuture<RedissonLockEntry> future = subscribe(threadId);
  commandExecutor.syncSubscription(future);

  try {
    // 无限循环直到获取到锁
    while (true) {
      // 再尝试获取一次,很像ReentrantLock中获取不到锁会尝试自旋获取一波,算一个小优化
      // 类比JVM级别的轻量级锁,其获取不到锁也会尝试自旋获取一波
      // 不过redis的锁自旋开销多了一个网络的开销,稍微重了一点,所以只自旋一次
      ttl = tryAcquire(leaseTime, unit, threadId);
      // lock acquired
      // 获取到锁了
      if (ttl == null) {
        break;
      }

      // ...
        
      // 这里利用JUC的java.util.concurrent.Semaphore#tryAcquire方法去阻塞线程
      // 尝试阻塞最长ttl时间
      getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
        
      // ...
      } 
    }
  } finally {
  	// 取消订阅pubsub
    unsubscribe(future, threadId);
  }
}

这里的代码层级较深,为了文章的简洁性长话短说。

  1. 利用Redis的PubSub模式订阅一个LockName关联的channel(一把锁对应一个channel)
  2. 设置一个监听器,监听PubSub中名称为刚刚的那个LockName的channel发出通知(有锁释放),动作为调用Semaphore的release方法释放信号量
  3. 当前获取不到锁的线程调用Semaphore的acquire方法尝试获取信号量,若没有信号量则阻塞ttl个时间
  4. 等待超过ttl个时间或者有锁释放通知之后线程唤醒,继续尝试获取锁
  5. 若获取不到锁,继续调用Semaphore#acquire方法阻塞然后获取锁,无限循环直到获取到锁

简而言之,Redisson利用了PubSub模式完成了一个锁释放的通知机制。锁释放的通知机制是很必要的,我曾经看过有人分布式锁在获取不到锁之后指定 Thread#sleep 一个指定的时间,这种设计是万万不可取的,思考一下就知道吞吐量会被限制。

3.2 Zookeeper中的锁等待问题

在Zookeeper中这种锁等待问题倒是比较容易解决,难度比起Redis来还是比较简单的,但如果使用Curator的话其实也和Redisson差不多,都给你封装好了。

这个问题同样使用通知机制(观察者模式)会比较好解决,在Zookeeper中方案就是Watch机制,监听一个节点是否产生变化,若变化会收到一个通知,当获取不到锁之后监听锁的那个临时节点即可,不过需要注意一个惊群效应,什么是惊群效应呢?假设A节点获取到锁,同时B、C、D、E也在获取锁,此时BCDE节点阻塞等待释放锁,当A节点释放锁之后,BCDE会同时起来争夺锁,但其实只有一个节点会获得到锁,就会浪费N-1个节点的系统资源去获取锁,惊动群而做无用功,解决方案是按顺序来,首先获取锁失败之后注册一个顺序节点,按照自己的顺序,向前一个节点注册Watch,这样一个个来即可解决惊群效应。

4. 误释放锁

为什么会有误释放锁问题?如果使用Redis作为分布式锁,想象两个场景:

  1. A节点获取到锁之后,Redis挂了,重新选举一个从节点的Redis后由于是AP模型,锁信息不在这个从节点上,B节点此时来获取锁成功,B开始执行业务逻辑,A执行完业务逻辑之后来释放锁,就会把B的锁释放掉了…然后C又来获取锁,B执行完又把C的锁释放掉…以此类推
  2. A节点获取到锁之后,因为某种原因(GC停顿或者…发挥想象力)没有续租过期时间,锁不小心释放掉了但是业务逻辑还在跑,B节点此时来获取锁成功,B也在跑业务逻辑,A执行完逻辑之后释放锁,把B的锁也给释放掉了…然后C又来获取锁,B执行完又把C的锁释放掉…以此类推

Zookeeper有误释放锁的情况吗?其实也有,假设A节点获取到锁,此时GC停顿(Stop The World),后台线程无法给Zookeeper发送心跳,ZK以为A节点宕机,把临时节点给删了,这样其他节点也会乘虚而入,然后就会出现上面说的循环释放别人锁的情况。

其实如果说GC停顿(Stop The World)貌似无解,但其实这里想说的是避免循环一直释放别人节点的锁,造成分布式锁一直失效的问题

4.1 解决方案

我们可以借鉴Redisson的方案,在获取锁的时候将一个唯一标识设置为value(貌似是一个UUID+threadId)

这里设置一个threadId还有一个好处,就是可以做可重入锁,当同一个线程再次获取锁的时候就可以以当前threadId作为依据判断是否是重入情况。

这样,在释放锁的时候A节点发现要释放的锁不是自己这个UUID+threadId,就不会释放别人的锁了。

5. 其他小Tips

看到这里,你一定会发现原来一个分布式锁这么复杂,逻辑操作不是一个简单的set key value可以做到的,如果是使用Redis,多步操作一定要用lua脚本,保证这一系列逻辑操作的原子性,不被打断。

想到这里就写到这里了,还有什么需要注意的点可以等待大家来补充…

发布了86 篇原创文章 · 获赞 102 · 访问量 12万+

猜你喜欢

转载自blog.csdn.net/qq_41737716/article/details/105426023