欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

浅谈分布式锁

程序员文章站 2022-05-07 18:33:15
...

引言

我们知道在单机多线程情况下,如果要保证临界区同一时刻只有一个线程访问,我们的做法就是使得多个线程的临界区在CPU看来串行执行,那么如何做到如上操作呢?两个方法,一个是使用外部组件保证同步,常见的有自旋锁,互斥锁,条件变量,信号量等常见的工具,在一些语言内还支持其他比较有特色的工具,比如golang中的channel也可以用来达到如上目的,C++,java中的future等等。当然还有一种方法,即无锁编程,常见的比如使用atomic原语,使得运行时以往的多条指令变成原子指令,从而保证同步。还有C++中的内存序,通过在编码时显式的指定运行顺序,从而达到一个happens-before的关系。

当然说了这么多还是为了引出我们今天的主角,即分布式锁。不管是单机环境还是分布式环境,这类问题都可以看做是另外一个问题,即“在同一时刻一个临界资源如何保证只能被一个程序访问”。那么如何解决这个问题呢?从单机来看,以互斥锁举例,能够使得多个线程达到同步的原因是因为这些线程之间都能够访问同一个仲裁者,即互斥锁实例,且这个仲裁者可以看做不会崩溃,即不会某一时刻无法提供服务,所以我们可以使用一个互斥锁来解决问题。到了分布式环境中当然也一样,与普通锁不同的是,分布式锁是指分布式环境下,系统部署在多个机器中,实现多进程分布式互斥的一种锁。为了保证多个进程能看到锁,锁被存在公共存储(比如 Redis、Memcache、数据库等三方存储)中,以实现多个进程并发访问同一个临界资源,同一时刻只有一个进程可访问共享资源,确保数据的一致性。当然通过以上的解释,我们可以看出其实分布式锁就是解决分布式互斥的一种方法,那么我们如何实现一个分布式锁呢?

解决方案

1. 关系型数据库作为锁

我们在上面说到锁一般被放置在一个公共存储区,即想要获取锁的节点都可以访问的一个节点(集群)中,这样的话我们可以把一个数据库当做一个锁。当要请求加锁的时候,在数据库中创建一个表,为申请者在锁表里建立一条记录,记录建立成功则获得锁,消除记录则释放锁。这样的话我们就实现了一个简易的分布式锁。当然其中有很大的问题,如下:

  1. 死锁:因为表没有提供超时机制。可能持有锁的进程宕机,无法向数据库提交解锁请求,导致表一直存放在数据库中,其他节点无法获取锁,这就造成了死锁。
  2. 单点故障问题:仅有一个服务器在提供服务,如果宕机,整个服务都会不可用,导致可用性较差,可以配置几个slave,用于故障恢复。
  3. 性能:每步操作都要落盘,效率相对来说较低。

2.1 Redis做分布式锁

这应该是最为广为人知的一种方式了,但是人无完人,Redis也不例外,它并不是适用于所有场景的。Redis如何作为一个分布式锁呢?原理很简单,就是每一个节点可以向Redis请求插入一个提前约定好的key,如果插入成功获取锁,插入失败的话则获取失败,对应的从Redis中删除key就是解锁,此时其他的节点就可以获取锁了。

从目前看来使用Redis和使用数据库作为锁是几乎是一样的,只是因为Redis作为缓存的特点,操作相比于一般数据库要快,除此之外并没有解决其他问题。

首先来说说死锁问题,Redis中支持超时机制,即对于一个key可以设置在一段时间后进行删除,指令为expire。所以我们可以在每次set成功之后设置超时,这样就可以避免节点持有锁后宕机导致整个服务不可用了,因为这个key会在提前设定的超时时间后自动delete。

写成代码大概是这样:

if(setnx(key,1== 1{
	expire(key,30try {
		do something ......
	} finally {
		del(key)
	}
}

这样的写法看似没有问题,实则漏洞百出。

  1. setnx和expire不是原子操作,可能刚执行完setnx机器宕机,导致出现死锁。
  2. del可能出现误删,即得到锁的进程(A进程)因为某些原因执行时间很长(程序优先度较低,GC),服务器已经删除了这个key,但是客户端并不清楚,并把这个key分配给了另外一个进程(B进程),此时A的程序才姗姗来迟,执行了del,但是此次删除的key并不是它本身的key,而是其他节点的key。
  3. 当然还有一种情况,就是就算我们解决了第二点中的问题,仍然出现了同一时刻有两个节点在临界区内,这有时看可能会造成严重问题,比如电商系统中库存仅有1确卖出了两个。

当然以上问题也各自对应着解决方案,我们来看看:

  1. 对于setnx和expire均为独立操作这个问题,还好Redis 2.6.12以上版本为set指令增加了可选参数,伪代码如下:SET key value [EX seconds] [PX milliseconds] [NX|XX]
  2. 对于出现误删的问题,我们可以在key所对应的value中设置一个全局唯一可以标识一个使用分布式节点的标识符,然后在删除前判断现在key中的value是否与自己相同,伪码大概是这样:
加锁:
String threadId = Thread.currentThread().getId()
set key threadId NX 10

解锁:
if(threadId.equals(redisClient.get(key)){ 
   del(key)
}

当然这里使用线程ID在不同的机器上可能会出现重复,这里可以设置为UUID。

但是有一个问题,即getdel各自均为独立操作,那么会导致上面问题呢?可能在get时Redis存储的还是本线程ID,在del之前Redis因为超时删除了key并分配给了其他结点,此时我们可以使用Redis中附带的另一个操作,即lua脚本,伪码如下:

String luaScript = 
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

redisClient.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));

当然就算使用lua脚本,这些语句本质上也是分开执行的,不过对于客户端来说,它们是同一个请求中被Redis服务器执行的,所以可以看做原子操作。

  1. 那么如何防止一个程序实际执行时长超过了提前设置的超时时间,导致可能同一时间有两个进程持锁呢?解决的方案是使用一个守护进程,在固定时间后如果没有显式关闭这个守护进程的话就由守护进程向Redis服务器发送一个延长超时时间的请求。当然这种方法有很大的漏洞,因为网络是不确定的,守护进程选择一个合适的时间发送请求呢?距离超时时间近了,可能请求到服务器是key已经删除,距离超时时间远了,后面有可能又恢复了。其实都不用考虑后一种情况,因为网络的原因,不管选择时间为多长都可能无法及时到达服务器,这个问题目前是无法完全解决的[5]。

如此看来我们好像使用Redis解决了这个问题,但是单机Redis和Redis的主从复制模型在CAP中选择了AP,这也导致了一个问题,即当我们使用Redis主从模型作为分布式锁服务器的时候,主服务器宕机,但此时数据还未同步到从服务器,哨兵使得一个数据“最新”的从服务器作为主服务器,但最新是相对于其他从服务器而言,它的数据仍然落后于主服务器,这会导致可能同一时刻两个进程持有锁。出现这个问题的原因是Redis的设计问题,它在可用性和一致性之间选择了可用性,所以在同步数据时选择了异步,这也是导致如上问题的原因。

有时候程序就是这么巧,如果你可以接受这种极小概率错误,那用这个基于复制的方案就完全没有问题。否则的话,官方为我们提供了一种安全的算法,即Redlock,它如何做到保证安全的呢?

2.2 RedLock

RedLock是Redis官方设计的一个针对于使用Redis主从模型可能出现的安全性问题设计的一个使用Redis实现的分布式锁算法。

首先我们需要N个节点,假设N为5,这5个节点上各自运行着一个Redis服务器,那么如何可以获取一个锁呢?

  1. 获取当前时间,精度最好高一点。
  2. 对5个节点分别使用同样的key和随机值获取锁,同时要设置网络的超时时间,设为T,即在T时间内没有得到回复视为该服务器出现故障,立刻尝试下一个服务器,这样可以避免服务器宕机的情况下,客户端还在傻傻的等待。
  3. 当服务器在至少获取了N / 2 + 1个服务器的回应,且花费时间小于提前设置key的超时时间,在本例子中是3,使用当前时间减去初始请求的时间(步骤一中记录的时间),所剩的时间就是改进程实际的持有锁的时间。
  4. 如果获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),此时释放掉所有已获取的锁。

和大多数“少数服从多数”的共识算法一样,当出现获取失败的时候都应该设置一个随机的重试时长,防止多客户端同一时间竞争锁,导致类似于活锁的问题。释放锁也比较有意思,直接向所有的客户端发送删除即可,这也意味着我们在客户端不需要维护关于服务端的状态。

安全性争议

对于Redlock安全性的讨论很有意思,我们先来看看如何证明这个算法在理想状态下是安全的,然后在给出一个两个大神对于Redlock安全性的争辩。

我们假设使用Redlock的客户端已经获取到了大多数锁,且因为获取锁时使用的命令相同,所以每个服务器中的超时时间是相同的,但是因为不可控的网络原因,所有的请求命令到达每一个服务器的时间几乎肯定是不相同的,所以它们的过期时间也不尽相同。我们假设第一个设置的key时间是T1(开始向第一个server发送命令前记录的时间),最后一个设置的key时间是T2(得到最后一台server(大多数中的最后一个)的答复后的时间),所以我们可以确定,第一个收到请求的Redis服务器中key的存活时间至少为MIN_ALIVETIME = TTL - (T2 - T1) - CLOCK_DRIFT,其中的CLOCK_DRIFT为时钟偏移,这一点看似不起眼,实则十分重要,有兴趣的读者可以了解下这篇文章《构建可靠分布式系统的挑战》,因为在不同机器中时钟的差异可能会非常巨大,这是Redlock安全性的一个隐患,也是Martin Kleppmann(《Designing Data-Intensive Application》这个神作的作者)对Redlock安全性持反对观点的信心来源。

如果客户端在获取到大多数Redis实例锁,使用的时间接近或者已经大于失效时间,客户端将认为锁是失效的锁,并且将释放掉已经获取到的锁,相反的,在MIN_ALIVETIME的时间内将不会有第二个客户端获取锁。从此看来只有一种情况可能会使得两个客户端同时获取大于等于N/2+1的锁,即其中一边获取大多数锁的时间已经超过了TTL,导致已经有锁释放,而这种情况被视为无效的获取锁。

还有一种情况,假设我们现在有5台服务器,此时一个客户端已经获取了三个服务器的响应,但是在客户端持有锁期间这三台服务器有一台崩溃,并在崩溃后重启,假设我们设置的AOF持久化策略为每秒一次,那么还可能会出现在一个客户端持有锁期间其他客户端获取锁。我们希望在任何情况在都保证算法的安全性,所以看起来我们必须修改配置为fsync = always,那么Redlock的相比其他CP的分布式锁的实现将损失性能上的优势。所以我们需要考虑其他方法,一个有效的方法是在重启后的新节点在TTL时间内在集群内不具备投票权,这样仍可保证算法的安全性。

所以我们可以设定一个重启的Redis服务器在TTL时间内对客户端不可用,这样即可保证算法的安全性。当然极端情况下大多数Redis崩溃会导致系统在TTL时间内彻底不可用,不过相比于错误的结果,我们可以接收这种情况。

存在的问题

在Redis文档的末尾有这样一段话很有意思,如下:

Martin Kleppmann analyzed Redlock here. I disagree with the analysis and posted my reply to his analysis here.

Martin Kleppmann对Redlock的分析在这里,我不同意他的说法,并且给出了我的回复(这里

我们来看看Martin对Redlock提出了什么样的批评:

Martin上来就提出问题,你要分布式锁到底想干什么?并给出自己的回答,即为了提升效率保证正确性,并告诉我们从失败的代价来看待这两个例子:

  1. 提升效率:即我们用锁来保证一个同样的计算任务不会被两个节点运行,失败的代价是我们付出了额外的代价(同一任务执行两次)或者是带来不变(同一个用户收到两封电子邮件)。
  2. 保证正确性:此时用锁是为了保证临界资源不被不同的进程同时访问,从而导致文件损坏,数据丢失。

Martin给出了他的想法,他认为,如果使用分布式锁来提升效率,单个Redis实例完全可以胜任这个任务,我们不必引入Redlock带来额外的开销和复杂性,最多还可以给单个的Redis配备一个从服务器。当然这种情况可能会在一些时候出现问题,即上文中提到的安全性问题,但是如果你只用分布式锁来提升效率,且这种失效的情况并不多见的话并没有什么问题。

但是如果将分布式做使用在一些对正确性要求极高的场合的话,Redlock并不能保证安全性。

那么Redlock到底有哪些缺陷呢?我们来细细分析一下:

首先Martin给出了一张图片:

浅谈分布式锁
当Client1获取锁的时候,可能发生了一段很长时间的GC,导致当Client继续运行的时候已经超过了持有锁的时间,这其实就是我们在将数据库作为锁时提到的第三点,此时因为服务器已经释放了Client1持有的锁,显然可能有其他Client占有锁,比如说Client2,这个问题Martin指出可以使用一个fence令牌来解决,如下:

浅谈分布式锁
即Redis维护一个递增的令牌,比如Client得到一个值为33的token,而在把锁分配给Clinet2以后服务端最新的token是34,此时如果收到了姗姗来迟的Client1的消息,就会拒绝(当然这里的服务端指的是提供得到锁以后执行服务的服务端,如果redis不就可以使用一个lua脚本搞定了)。

并且Martin还指出一个对Redlock来说致命的问题,即Redlock是一个严重依赖于时钟的算法,而[3]分布式系统中时钟是不可靠的

问题还是出现在超时时间上,我们回想一下一个成功获取锁的客户端中锁的有效时间为MIN_ALIVETIME = TTL - (T2 - T1) - CLOCK_DRIFT,而其中的CLOCK_DRIFT是无法估计的,所以当对时钟偏移错误估计的时候实际计算出的MIN_ALIVETIME其实是无效的,即可能出现这样的情况:

  1. Client 1 从 A、B、D、E五个节点中,获取了 A、B、C三个节点获取到锁,我们认为他持有了锁
  2. 这个时候,由于 B 的系统时间比别的系统走得快,B就会先于其他两个节点优先释放锁。
  3. Clinet 2 可以从 B、D、E三个节点获取到锁。在整个分布式系统就造成 两个 Client 同时持有锁了。

Martin指出好的分布式系统应该是不依赖于时间的,在极端的情况下,系统可以不给出结果,但不能给出错误的结果,综上,可以总结出Martin对Redlock的批评如下:

  1. 用作保证效率的话Redlock太笨重,相比于主从复制带来了不必要的开销和复杂性。
  2. 用作安全性保证的话,Redlock无法保证正确性。

看了Martin的观点,心中只有一个想法:有理有据,让人无法反驳。

但显然Antirez也不是一般人,脾气像Linus一样火爆,立马针对于Martin的观点给予反击,理由如下:

显然Martin从两个方面做出了批评:

  1. 锁的互斥性只在客户端限定锁定时间内有效,过期后可能会造成多个客户端持有锁。
  2. Redlock的算法正确性严重依赖于时间,而在真实的分布式环境中时间是不可靠的,以此抨击Redlock的正确性。

Antirez分别对两个问题进行了回应,对于第一个问题,显然在Redlock获取锁之前,即获取大多数服务端的回应之前,如果发生阻塞超过TTL的阻塞,那么已获取的锁就会释放,不影响正确性,如果在已获取锁以后阻塞,给出的答案是:所有自动释放锁的分布式锁都无法完全解决这个问题

而第二种情况,Antirez认为时间出现问题主要是两个方面:

  1. 人为因素。
  2. 从NTP服务器收到了一个跳跃时钟更新。

前者无法解决,后者就靠运维了,把一个长的时钟进行更新的时候改为多次短的更新,从而一定程度上避免这种情况。

具体的内容可参考[5]。

总之高手过招,让人眼花缭乱,无法判定谁对谁错。分布式领域没有银弹,一切都是均衡的结果,对于所有的方案我们都应该明白其优缺点,思考其优点带来的价值是否抵得上其缺点带来的局限,所以没有什么是完美的,避重就轻,取其所长,为我所用,这才是王道。

3. Zookeeper与Chubby

包括以前的我在内,很多人认为Zookeeper是Chubby的开源版,其实不是这样的,不可否认Zookeeper在实现上肯定在一定程度上参考了Chubby,比如zookeeper仿照linux文件路径的设计就和chubby相同,但要因此说它们是同一个东西就不太准确了,我们来看看它们分别的定义:

  • Chubby:provide coarse-grained locking as well as reliable storage for a loosely-coupled distributed system.
  • Zookeeper:provide a simple and high performance kernel for building more complex coordination primitives at the client.

可以看出Chubby就是实现了一个特化的分布式锁,这就是它的使命。而Zookeeper则定位为一个提供分布式协调服务的内核,将具体的操作空间留给了客户端。

当然除此之外它们还有以下不同点:

  1. Chubby采用paxos算法作为一致性协议,而Zookeeper采用ZAB(Zookeeper Atomic Broadcast)协议。
  2. Chubby提供了精确的操作,即lock和release。而在Zookeeper中需要像Redis一样采用基础操作完成请求分布式锁的过程。
  3. Chubby中为了更高的效率,客户端和服务端是有耦合的,在客户端内部维护了cache[8]。而Zookeeper是一个分布式协调中心,客户端需要自己实现。
  4. 一致性不同。这一点非常重要,首先Chubby和zk都采用一主多从的架构,且复制策略均采用半同步半异步,即大于一般的节点成功写入则记为成功。但是Chubby的一致性为读写一致性,即分布式中*别的一致性,保证每次读写都可以看到之前的成功操作。而zk则为写一致性客户端有序(FIFO client order),即写操作一定可以看到之前的成功操作,客户端的读写都可以看到之前的成功操作。为什么近乎相同的数据复制策略导致不同的一致性呢?原因是Chubby的读写均经过主节点(论文2.2),而zk则只有写操作经过主节点,读操作是于客户端连接的server执行,当然要实现客户端有序也并不困难,只要在Read之前加一个Write操作就可以了,这里zk提供Sync命令实现。这样一个一致性损失换来的是集群高性能读请求,而zk恰好又是针对于读多写少的场合,这也可以体现出zk精妙的设计。

3.1 Zookeeper

我们知道Zookeeper的数据结构类似于一棵文件树,Zookeeper实现分布式锁实际上就是对临时顺序节点的一个请求,即对树上一个的请求,因为节点唯一,显然多个请求会落在同一个节点上,第一个请求得到锁,后面则在请求的node下创建一个顺序节点,删除就是删除掉那个临时节点。因为zk支持监听机制,会在锁结束时通知最新的顺序节点加锁,这样就成了一个完整的分布式锁服务。当然ZAB协议在保证主节点向从节点同步信息时采用两阶段提交,当半数从服务器写入成功并返回ACK给主服务器时主服务器才对请求响应,同时ZAB协议保证选出的leader是数据最新的一个,这也意味着zk在CAP中选择的CP,所以使用zk作为分布式锁是安全的,没有使用Redis带来的安全性问题。

但是值得一提的是ZAB协议的网络通信较为频繁,每次广播都会在一个主从复制模型中造成n*(n-1)个消息的通信,可能会造成信令风暴[11]问题,当然一般zk的节点也没那么多。

对于Zookeeper如何实现分布式锁,[9]中有一个使用的例子。

3.2 Chubby

Chubby的存在就是为了协助MapReduce,BigTable的leader的选举,它生来就是为了实现一个分布式锁的服务。

因为是谷歌并没有开源Chubby,所以我们对于Chubby还是了解其设计理念即可,可参考[8],[13]

总结

分布式锁是一个经典的分布式互斥问题,不但可以用来保证分布式环境下临界资源的安全,而且可以在某些情况下提高程序的效率,同时也可以提供多节点的选举,是一个常用且重要的问题,值得我们仔细学习。且学习对比的过程可以发现不同的实现之间其实各有优缺,不能对于所有的场景一概的使用某一种方法,而是应该针对具体场景具体分析,才能得到一个相对最优的结论。同时学习的过程也可以看出一个问题,即分布式领域没有银弹,有的只是针对于具体业务的取舍而已。

参考

相关标签: 分布式