当前位置:   article > 正文

分布式锁服务关键技术和常见解决方案_云计算下的分布式锁服务

云计算下的分布式锁服务
| 导语  在单机环境下,由于使用环境简单和通信可靠,锁的可见性和原子性很容易可以保证,可以简单和可靠地实现锁功能。到了分布式的环境下,由于公共资源和使用方之间的分离,以及使用方和使用方之间的分离,相互之间的通信由线程间的内存通信变为网络通信。网络通信的时延和不可靠,加上分布式环境中各种故障的常态化发生,导致实现一个可靠的分布式锁服务需要考虑更多更复杂的问题。

锁,核心是协调各个使用方对公共资源使用的一种机制。当存在多个使用方互斥地使用某一个公共资源时,为了避免并行使用导致的修改结果不可控,需要在某个地方记录一个标记,这个标记能够被所有使用方看到,当标记不存在时,可以设置标记并且获得公共资源的使用权,其余使用者发现标记已经存在时,只能等待标记拥有方释放后,再去尝试设置标记。这个标记即可以理解为锁。

在单机多线程的环境下,由于使用环境简单和通信可靠,锁的可见性和原子性很容易可以保证,所以使用系统提供的互斥锁等方案,可以简单和可靠地实现锁功能。到了分布式的环境下,由于公共资源和使用方之间的分离,以及使用方和使用方之间的分离,相互之间的通信由线程间的内存通信变为网络通信。网络通信的时延和不可靠,加上分布式环境中各种故障的常态化发生,导致实现一个可靠的分布式锁服务需要考虑更多更复杂的问题。

      目前常见的分布式锁服务,可以分为以下两大类:
  •       1、基于分布式缓存实现的锁服务及其变种:典型代表是使用Redis实现的锁服务和基于Redis实现的RedLock方案;
  •       2、基于分布式一致性算法实现的锁服务:典型代表为ZookeeperChubby

本文从上述两大类常见的分布式锁服务实现方案入手,从分布式锁服务的各个核心问题(核心架构、锁数据一致性、锁服务可用性、死锁预防机制、易用性、性能)展开,尝试对比分析各个实现方案的优劣和特点。

1、基于分布式缓存实现的锁服务

基于分布式缓存实现的锁服务,思路最为简单和直观。和单机环境的锁一样,我们把锁数据存放在分布式环境中的一个唯一结点,所有需要获取锁的调用方,都去此结点访问,从而实现对调用方的互斥,而存放锁数据的结点,使用各类分布式缓存产品充当。其核心架构如下(以Redis为例):

1.基于分布式缓存实现的锁服务典型架构

1.1 加解锁流程

基于Redis官方的文档,对于一个尝试获取锁的操作,流程如下:

1、  Redis结点发送命令:

                          SET (key=Lock_Name value=my_random_value) NX PX 30000

其中:

  • 1)、my_random_value是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的,用于唯一标识锁持有方。
  • 2)、NX表示只有当Lock_Name对应的key值不存在的时候才能SET成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  • 3)、PX 30000表示这个锁结点有一个30秒的自动过期时间。(自动过期时间,目的是为了防止持有锁的客户端故障后,锁无法被释放导致死锁而设置,从而要求锁拥有者必须在过期时间之内执行完相关操作并释放锁)。(对于第二、三点的特性,目前应该绝大部分缓存产品都具备)

2、  如果命令返回成功,则代表获取锁成功,否则获取锁失败。

对于一个拥有锁的客户端,释放锁,其流程如下:

1、    Redis 结点发送命令:
                                                            GET (key=Lock_Name)
2、    如果查询回来的 value 和本身 my_random_value 一致,则表示自己是锁的持有者,可以发起解锁操作,发送命令:
                                                           DEL (key=Lock_Name)

1.2 锁安全性分析

基于上述流程,由于Redis结点是单点存在,所以在锁过期时间之内且Redis结点不发生故障的情况下,锁的安全性(即互斥性)可以得到保证。但是仍然有如下几个问题需要考虑:

1、  预防死锁的必要性

考虑如下场景,一个客户端获取锁成功,但是在释放锁之前崩溃了,此时实际上它已经放弃了对公共资源的操作权,但是却没有办法请求解锁,那么它就会一直持有这个锁,而其它客户端永远无法获得锁。因此,对于绝大部分场景,此类死锁场景是应该得到考虑和避免。

2、  引入锁自动过期时间来预防死锁带来的问题

为了预防死锁,利用分布式缓存的结点自动过期特性来定期删除死锁结点,看似可以解决问题。但是其中隐藏的隐患是:实质上,锁自动过期清理是释放了一个不属于自己的锁。那么几乎必然的,会破坏锁的互斥性,考虑如下场景:

  • 1)、客户端1获取锁成功
  • 2)、客户端1在某个操作上阻塞了很长时间
  • 3)、过期时间到,锁自动释放
  • 4)、客户端2获取到了对应同一个资源的锁
  • 5)、客户端1从阻塞中恢复过来,认为自己依旧持有锁,继续操作同一个资源,导致互斥性失效

也许有一个疑问,第五步中,客户端1恢复回来后,可以比较下目前已经持有锁的时间,如果发现已经快过期,则放弃对共享资源的操作即可避免互斥性失效的问题。事实上,客户端1的时间和Redis结点的时间本身就存在偏移的可能性,更极端一点,Redis上的时间还可能发生跳变或者比客户端时间跑得更快,所以,严格来讲,任何依赖两个时间比较的互斥性算法,都存在潜在的隐患。

3、  解锁操作的原子性

引入全局唯一的my_random_value,目的是想保证每次解锁操作,一定是解锁的自己加的锁。由于Redis没有能够提供基于数据版本号来删除Key的原子操作的特性,其WatchCAS机制本身基于连接(有其他的分布式缓存产品能够支持这个特性)。因此解锁需要两步,先查锁回来确认Value这把锁是自己加的,然后再发起Del解锁。由于GetDel操作的非原子性,那么解锁本身也会存在破坏互斥性的情况,考虑如下场景:

  • 1)、客户端1获取锁成功。
  • 2)、客户端1访问共享资源。
  • 3)、客户端1为了释放锁,先执行'GET'操作获取随机字符串的值。
  • 4)、客户端1判断随机字符串的值,与预期的值相等。
  • 5)、客户端1由于某个原因阻塞住了很长时间。
  • 6)、过期时间到了,锁自动释放了。
  • 7)、客户端2获取到了对应同一个资源的锁。
  • 8)、客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。

4、  Redis结点故障后,主备切换的数据一致性

考虑Redis结点宕机,如果长时间无法恢复,则导致锁服务长时间不可用。为了保证锁服务的可用性,通常的方案是给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上。但是由于Redis的主从复制(replication)是异步的,这可能导致在宕机切换过程中丧失锁的安全性。考虑下面的时序:

  • 1)、客户端1Master获取了锁。
  • 2)、Master宕机了,存储锁的key还没有来得及同步到Slave上。
  • 3)、Slave升级为Master
  • 4)、客户端2从新的Master获取到了对应同一个资源的锁。
  • 5)、客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。

         设想下,如果要避免这种情况,只有在写数据的时候,就阻塞地把数据写多份,全部写成功才返回,这样才能保证锁的安全性(分布式缓存的同步主从复制)。但这样就可以即保证数据一致性,又保证服务可用性了吗?其实不然,在锁数据写MasterSlave两份,都写成功才认为加锁成功的情况下,如果Master写成功,Slave写超时(其实写成功了),这个时候认为加锁是失败的,但是主和备的数据产生了不一致,而且Slave自身稳定性以及MasterSlave的通信稳定性还成为了导致服务不可用的额外因素。所以基于分布式缓存实现的锁服务,要想解决分布式系统一致性和可用性的核心问题,并不是简单的主从同步可以搞定(核心还是要靠Paxos这样的分布式一致性协议)。

1.3 总结

1、锁服务性能

由于锁数据基于Redis等分布式缓存保存,基于内存的数据操作特性使得这类锁服务拥有着非常好的性能表现。同时锁服务调用方和锁服务本身只有一次RTT就可以完成交互,使得加锁延迟也很低。所以,高性能、低延迟是基于分布式缓存实现锁服务的一大优势。因此,在对性能要求较高,但是可以容忍极端情况下丢失锁数据安全性的场景下,非常适用。

2、数据一致性和可用性

锁数据一致性基于上述的分析,基于分布式缓存的锁服务受限于通用分布式缓存的定位,无法完全保证锁数据的安全性,核心的问题可以概括为三点:

  • 1)、锁数据写入的时候,没有保证同时写成功多份:任何事后的同步在机制上都是不够安全的,因此在故障时,锁数据存在丢失的可能。解决此类问题,需要在写多份和服务可用性之间找到平衡(典型思想:多数派,详细描述见后面的两类锁服务方案)。
  • 2)、没有原子性的保证持有者才能解锁:锁服务需要提供一种机制,使得在网络各种乱序以及包重放的时候,保证只有锁当前持有者方能解锁,同时要保证解锁操作的原子性。
  • 3)、锁服务缺乏和调用方(或者公共资源方)的确认机制:预防死锁等问题,光靠锁服务自身,是不够安全的,只有调用方和公共资源的一同参与,方能全面保证(Chubby提供了一种做法,详细见后面的描述)

2、基于分布式缓存实现锁服务的变种

基于分布式缓存实现锁服务,在业界还存在各类变种的方案,其核心是利用不同分布式缓存产品的额外特性,来改善基础方案的各类缺点,各类变种方案能提供的安全性和可用性也不尽相同。此处介绍一种业界最出名,同时也是引起过最大争论的一个锁服务变种方案-RedLock

RedLockRedis的作者Antirez提出,算是Redis官方对于实现分布式锁的指导规范。Redlock的算法描述就放在Redis的官网上(https://redis.io/topics/distlock)。

         选择对比分析RedLock,第一是因为它作为Redis官方的锁服务指导规范,在提出的时候业内也对其进行过很多争议和讨论;第二是RedLock的算法中,已经有了分布式一致性算法中最核心的概念-多数派的思想。因此我们在众多变种中选择RedLock来进行介绍和分析。

2.1 核心架构和流程

2.RedLock锁服务流程图

对于一个客户端,依次执行下面各个步骤,来完成获取锁的操作:

  • 1)、获取当前时间(毫秒数)。
  • 2)、按顺序依次向NRedis节点执行获取锁的操作(其实可以并发同时向NRedis获取锁)。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含随机字符串my_random_value,也包含过期时间。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间,它要远小于锁的有效时间。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有。
  • 3)、计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间,那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
  • 4)、如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
  • 5)、如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作(同基于单Redis节点的释放一致)。释放锁的过程比较简单,客户端向所有Redis节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。

2.2 锁安全性分析

RedLock算法的最核心也是最有价值之处,是引入了多数派思想,来解决单点故障对数据安全性和服务可用性的影响。由于加锁成功需要所有Redis结点中的多数结点同意,因此只要集群中结点有一半能够提供服务时,服务的可用性就能够保证。同时对于数据的一致性,只要对于一把锁,其多数派结点的数据不丢,那么锁就不可能被另外的调用方同时获得(不够多数派),所以锁的安全性也可以得到保证。所以从核心算法来说,多数派的思想是对数据一致性的保证下,向保证服务可用性又进了一大步。

但是,多数派仅仅是算法最核心的理论保证。要实现一个工程上完全保证锁数据安全性,同时高可用的锁服务,RedLock还有很远的距离,这也是RedLock在业界引起很多争议的地方,核心的问题见下面的分析。

1、  RedLock的安全性依旧强依赖于系统时间

在之前单点Redis锁服务的时候已经分析过,由于为了预防死锁,使用了过期自动删除锁的机制,所以导致安全性依赖于单机Redis上的时间服务不能异常,从而存在隐患(本质是违反了锁持有者才能删除锁的原则)。同样的,到了RedLock中,仍然有此问题,考虑如下的时序:假设一共有5Redis节点:A, B, C, D, E

  • 1)、客户端1成功锁住了A, B, C,获取锁成功(但DE没有锁住)。
  • 2)、节点C时间异常,导致C上的锁数据提前到期,而被释放。
  • 3)、客户端2此时尝试获取同一把锁:锁住了C, D, E,获取锁成功。

         所以一个安全的算法,是不应该依赖于系统时间的。消息可能在网络中延迟任意长的时间,甚至丢失,系统时钟也可能以任意方式出错。一个好的分布式算法,这些因素不应该影响它的安全性,只可能影响到它的有效性,也就是说,即使在非常极端的情况下(比如系统时钟严重错误),算法顶多是不能在有限的时间内给出结果而已,而不应该给出错误的结果。

2、  缺乏锁数据丢失的识别机制和恢复机制

假设一共有5Redis节点:A, B, C, D, E。见如下的事件序列:

  • 1)、客户端1成功锁住了A, B, C,获取锁成功(但DE没有锁住)。
  • 2)、节点C崩溃重启了,但客户端1C上加的锁没有持久化下来,丢失了。(RedisAOF持久化方式是每秒写一次磁盘(即执行fsync),因此最坏情况下可能丢失1秒的数据。为了尽可能不丢数据,Redis允许设置成每次修改数据都进行fsync,但这会降低性能。当然,即使执行了fsync也仍然有可能丢失数据(这取决于系统而不是Redis的实现))。
  • 3)、节点C重启后,客户端2锁住了C, D, E,获取锁成功。
  • 4)、客户端1和客户端2现在都认为自己持有了锁。

此类问题的本质,是作为多数派数据的一个结点,数据丢失之后(比如故障未落地、超时被清理等等),首先没有能够区分丢失了哪些数据的能力,其次还没有恢复丢失数据的能力。这两种能力都缺乏的情况下,数据结点就继续正常地参与投票,从而导致的数据一致性被破坏。

RedLock也意识到了这个问题,所以在其中有一个延迟重启(delayed restarts)的概念。也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于其上所有锁的有效时间的最大值。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。这个方案,是在缺乏丢失数据识别的能力下,实现的较“悲观”的一个替代方案,首先其方案依旧依赖于时间,其次如何确定最大过期时间,也是一个麻烦的事情,因为最大过期时间很可能也一起丢失了(未持久化),再有延迟重启使得故障结点恢复的时间延长,增加了集群服务可用性的隐患。怎么来看,都不算一个优雅的方案。

2.3 总结

1、锁服务性能

由于RedLock锁数据仍然基于Redis保存,所以和基于单点的Redis锁一样,具有高性能和低延迟的特性,不过由于引入多数派的思想,加锁和解锁时的并发写,所以在流量消耗来说,比基于单点的Redis锁消耗要大。从资源角度来说,是用流量换取了比单点Redis稍高的数据一致性和服务可用性。

2、数据一致性和可用性

RedLock的核心价值,在于多数派思想。不过根据上面的分析,它依然不是一个工程上可以完全保证锁数据一致性的锁服务。相比于基于单点Redis的锁服务,RedLock解决了锁数据写入时多份的问题,从而可以克服单点故障下的数据一致性问题,但是还是受限于通用存储的定位,其锁服务整体机制上的不完备,使得无法完全保证锁数据的安全性。在继承自基于单点的Redis锁服务缺陷(解锁不具备原子性;锁服务、调用方、资源方缺乏确认机制)的基础上,其核心的问题为:缺乏锁数据丢失的识别和学习机制。

RedLock中的每台Redis,充当的仍旧只是存储锁数据的功能,每台Redis之间各自独立,单台Redis缺乏全局的信息,自然也不知道自己的锁数据是否是完整的。在单台Redis数据的不完整的前提下,没有识别和学习机制,使得在各种分布式环境的典型场景下(结点故障、网络丢包、网络乱序),没有完整数据但参与决策,从而破坏数据一致性。

3、基于分布式一致性算法实现的锁服务

分析完上述的锁服务方案,可以看到,各种方案核心还是在一致性和可用性之间做取舍。对于锁服务本身的定位和用途而言,其是一个相对中心化,对数据一致性有严格要求的场景。所以在分布式环境下,把数据严格一致性作为第一要求的情况下,Paxos算法是绕不开的一个算法(“all working protocols for asynchronous consensus we have so far encountered have Paxos at their core”)。于是就有了ChubbyZookeeper这类,基于分布式一致性算法(核心是Paxos和相关变种)实现的锁服务。

Chubby是由Google开发实现,在其内部使用的一个分布式锁服务,其核心设计Google通过论文的形式开源出来。而Zookeeper作为Chubby的开源实现版本,由开源社区开发,目前也广泛应用在各种场景下。由于ZookeeperChubby之间的关系,两者在绝大部分的设计上都十分相似,因此此部分以Chubby为例,来分析此类锁服务的相关特点,关于ZookeeperChubby设计上的差异,在本节最后简要分析。

3.1 Chubby设计细节

Chubby核心架构

3.Chubby的系统结构

如上图,一个典型的Chubby集群,或者叫Chubby Cell,通常由5台服务器(奇数台)组成。这些服务器之间采用Paxos协议,通过投票方式决定一个服务器作为Master。一旦一个服务器成为MasterChubby会保证一段时间其他服务器不会成为Master,这段时间被称为租期。在运行过程中,Master服务器会不断续租,如果Master服务器发生故障,余下的服务器会选举新的Master产生新的Master服务器。

Chubby客户端通过DNS发现Chubby集群的地址,然后Chubby客户端会向Chubby集群询问Master服务器IP,在询问过程,那些非Master服务器会将Master服务器标识反馈给客户端,可以非常快的定位到Master

在实际运行中,所有的读写请求都发给Master。针对写请求,Chubby Master会采用一致性协议将其广播到所有副本服务器,并且在过半机器接受请求后,再响应客户端。对于读请求,Master服务器直接处理返回。

Chubby的一致性协议是Paxos算法的工程实现,对于Paxos协议本身,由于不是此文的重点,所以此处不展开详细介绍。总体上,可以理解为Chubby的一致性协议,可以保证通过Master写成功之后的数据,最终会扩散到集群内的所有机器上,同时对于多次的写操作,Chubby可以严格保证时序(无论是Master挂掉重新选举产生新Master,还是其中非Master机器的故障或者是被替换),另外从Master读取的数据也是最新的数据。而满足这一切要求的前提,只需要Chubby集群中的大部分机器可以正常提供服务即可。

每台Chubby服务器的基本架构大致分三层:

4.Chubby结点的基本架构

  • 1、最底层是容错日志系统,通过Paxos协议保证集群上的日志完全一致。
  • 2、日志之上是KV类型的容错数据库,通过下层的日志来保证一致性和容错性。
  • 3、最上层是对外提供的分布式锁服务和小文件存储服务。

Chubby数据组织形式

Chubby作为分布式锁服务,提供的数据操作接口是类似于Unix文件系统接口风格的接口,这样设计的初衷据说是文件系统操作风格的接口在Google内部更加符合使用者的习惯。Chubby中所有的数据都是以文件结点的形式提供给调用者访问,Chubby中典型的结点如下:/ls/foo/wombat/pouch

结点分为永久结点和临时结点,临时结点在没有客户端打开或者其子目录下已经为空的情况下自动删除。每个结点都可以设置访问控制权限(ACL),同时结点的原数据(MeteData)中有四个递增的64位数,用于区分结点在各个方面的修改时序:1、实体编号:区分同名结点的先后;2、文件内容编号:文件内容修改时自增;3、锁编号:锁结点被获取时自增;4ACL编号:ACL变化时自增。

基于文件结点的组织形式,Chubby提供的数据操作API如下:

  • 1Open():打开文件结点;
  • 2Close():关闭文件结点;
  • 3GetContentsAndStat():获取文件内容;
  • 4SetContents():写文件,可以同时提供文件内容编号,Chubby验证文件目前的序号和提供的一致,方能修改;
  • 5Delete():删除文件(需要文件无子节点);
  • 6 Acquire(), TryAcquire(), Release():获取和释放锁
  • 7 GetSequencer():加锁成功后,获取锁序号;
  • 8 CheckSequencer():检验锁序号是否有效。

Chubby事件机制

为了避免大量客户端轮询服务器带来的压力,Chubby提供了事件通知机制。Chubby客户端可以向Chubby注册事件通知,当触发了这些事件后服务端就会向客户端发送事件通知。Chubby支持的事件类型包括不限于:

  • 1、文件内容变化:通常用于监控文件是否被其他客户端修改;
  • 2、子节点新增、删除、变化:用于监控文件结点的变化;
  • 3Master故障:警告客户端可能已经无法再收到其他事件了,需要重新检查数据;
  • 4、文件句柄已经失效:通知客户端目前使用的文件句柄已经失效,通常是网络问题。

3.2 Chubby加锁流程

结合上述Chubby的设计细节,Chubby中客户端完成加锁的操作序列如下:

  • 1、  所有客户端打开锁文件(Open),尝试获取锁(Acquire)。
  • 2、  只有一个客户端成功,其他失败。
  • 3、  成功的客户端获得了锁,可以写自己的相关信息到文件(SetContent),其他客户端可以读取到锁持有者的信息(GetContentsAndStat(可以通过订阅事件,也可以通过加锁失败后去读取结果)
  • 4、  获取锁失败的客户端,可以随后轮询再试,也可以通过订阅事件,等待锁释放后Chubby的主动通知。
  • 5、  加锁成功的客户端从Master获取一个序列号(GetSequencer),然后在与后端资源服务器的通信的时候带上此序列号,后端资源服务器处理请求前,调用CheckSequence去检查锁是否依然有效。有效则认为此客户端依旧是锁的持有者,可以为其提供服务。

Chubby的加锁流程看起来十分简单,我们来详细分析下,Chubby如何解决之前几种方案碰到的问题:

结点故障,数据的一致性保证

  • 1、  Master故障后,Chubby集群内部会通过一致性协议重新发起选主流程,在新Master产生之前,Chubby集群无法对外提供服务,一致性协议保证Chubby集群内可以选出唯一的Master
  • 2、  新当选的Master结点,由于之前已经在集群中,所以其上已经有绝大部分的数据。而对于暂未同步的数据(即新Master原来作为普通结点,未参与投票的那部分数据),新Master通过一致性协议从集群内其他结点学习得到。学习完毕后,新Master开始对外提供服务。整个过程,耗时在6-30S之间。
  • 3、  对于一台普通结点故障,如果在短时间内恢复,那么其使用本地的一致性日志恢复数据,再用一致性协议和Master学习未同步的数据,学习完毕后,参与投票。
  • 4、  对于一台普通结点故障,如果在长时间都无法恢复,那么使用新的空闲结点替换,替换时使用集群其他结点的一致性日志文件恢复绝大部分数据,剩余的再用一致性协议和Master学习,学习完毕后,参与投票。

因此,Chubby通过一致性协议,解决了单点Redis数据没有多份的问题,同时解决了RedLock无法识别缺失数据和学习缺失数据的问题。在可用性方面,只要集群大部分机器正常工作,Chubby就能保持正常对外提供服务。在数据一致性和可用性方面,Chubby这类方案明显优于前两种方案(这本身就是Paxos协议的长处)。

预防死锁

  • 1、  ClientMaster之间通过保活包(KeepLive)维护会话(Session)。保活包由Client定时发起给MasterMaster收到KeepLive包之后,可以立即回复(有事件需要下发时),也可以等待一段时间,等到Session快超时前回复,Client收到回复后,再立刻发起另外一个新的保活包。保活包承担了延长Session租约和通知事件的功能。
  • 2、  当锁持有方A发生故障,Session无法维护。Master会在Session租约到期后,自动删除该Client持有的锁,以避免锁长时间无法释放而导致死锁。
  • 3、  另外一个客户端B发现锁已经被释放,发起获取锁操作,成功获取到锁;
  • 4、  对于同一把锁,每一次获取锁操作,都会使得锁的序列号(GetSequencer)自增。锁序号是一个64位自增的整形。
  • 5、  对于锁持有方,每次去访问后续的资源服务器时,都会带上自己的锁序号;而资源服务器在处理请求之前,会去Master请求验证当前锁序号是否是最新(CheckSequencer)。
  • 6、  所以考虑这样一种情况,如果在B获取锁成功之后,A恢复了,A认为自己仍旧持有锁,而发起修改资源的请求,会因为锁序号已经过期而失败,从而保障了锁的安全性。

总结起来,Chubby引入了资源方和锁服务的验证,来避免了锁服务本身孤立地做预防死锁机制而导致的破坏锁安全性的风险。同时依靠Session来维持锁的持有状态,在正常情况下,客户端可以持有锁任意长的时间,这可以确保它做完所有需要的资源访问操作之后再释放锁。这避免了基于Redis的锁对于有效时间(lock validity time)到底设置多长的两难问题。

不过引入的代价是资源方需要作对应修改,对于资源方不方便作修改的场景,Chubby提供了一种替代的机制Lock-Delay,来尽量避免由于预防死锁而导致的锁安全性被破坏。Chubby允许客户端为持有的锁指定一个Lock-Delay的时间值(默认是1分钟)。当Chubby发现客户端被动失去联系的时候,并不会立即释放锁,而是会在Lock-Delay指定的时间内阻止其它客户端获得这个锁。这是为了在把锁分配给新的客户端之前,让之前持有锁的客户端有充分的时间把请求队列排空(draining the queue),尽量防止出现延迟到达的未处理请求。

可见,为了应对锁失效问题,Chubby提供的两种处理方式:CheckSequencer()检查和Lock-Delay,它们对于安全性的保证是从强到弱的。但是Chubby确实提供了单调递增的锁序号,以及资源服务器和Chubby的沟通渠道,这就允许资源服务器在需要的时候,利用它提供更强的安全性保障。

ZookeeperChubby的设计差异

1、  对于服务读负载的取舍

Chubby设计为所有的读写都经过Master处理,这必然导致Master的负载过高,因此ChubbyClient端实现了缓存机制。Client端在本地有文件内容的CacheClient端对Cache的维护只是负责让Cache失效,而不持续更新Cache,失效后的Cache,在Client下一次访问Master之后重新创建。每次修改后,Master通过Client的保活包(所以保活包除了有延长Session租约和通知事件的功能外,还有一个功能是Cacha失效),通知每个拥有此CacheClientMaster维护了每个Client可能拥有的Cache信息),让他们的Cache失效,Client收到保活包之后,删除本地Cache。如果Client未收到本次保活包,那么只有两种可能,后续的保活包学习到Cache失效的内容,或者Session超时,清空所有Cache重新建立Session。所以对Cache机制而言,不能够保证Cache数据的随时最新,但是可以保证最终的Cache数据一致性,同时可以大量避免每次向Master读带来的网络流量开销和Master的高负载。

Zookeeper设计采取了另外一个思路,其中Client可以连接集群中任意一个节点,而不是必须要连接MasterClient的所有写请求必须转给Master处理,而读请求,可以由普通结点直接处理返回,从而分担了Master的负载。同样的,读数据不能保证时刻的一致性,但是可以保证最终一致性。

2、  预防死锁方面

Chubby提供了CheckSequencer()检查和Lock-Delay两种方式来避免锁失效带来的问题,引入了资源方和锁服务方的交互来保证锁数据安全性。不过Zookeeper目前还没有类似于CheckSequencer的机制,而只有类似于Lock-Delay的等待机制来尽量避免锁失效带来的安全性问题。所以在锁失效方面的安全性来说,Chubby提供了更好的保证。

3、  锁使用便利性方面的差异

ChubbyZookeeper都提供了事件机制,这个机制可以这样来使用,比如当客户端试图创建/lock的时候,发现它已经存在了,这时候创建失败,但客户端不一定就此对外宣告获取锁失败。客户端可以进入一种等待状态,等待当/lock节点被释放的时候,锁服务通过事件机制通知它,这样它就可以继续完成创建操作(获取锁)。这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。

但是考虑这样一个问题,当有大量的客户端都阻塞在/lock结点上时,一旦之前的持有者释放锁,那么阻塞的潜在调用方都会被激活,但是大量客户端被激活,重新发起加锁操作时,又只有一个客户端能成功,造成所谓的“惊群”效应。

考虑到这一点,Zookeeper上实现了一个“有序临时结点”的功能,来避免惊群。对于一个临时锁结点,Zookeeper支持每次创建都可以成功,但是每次创建的结点通过一个自增的序号来区别。创建成功最小结点的客户端表明获得了锁,而其他调用方创建的结点序号表明其处于锁等待队列中的位置。所以,对于获取锁失败的客户端,其只需要监听序号比其小的最大结点的释放情况,就可以判断何时自己有机会竞争锁。而不是每次一旦有锁释放,都去尝试重新加锁,从而避免“惊群”效应产生。

4、 结语

本文通过分析三类分布式锁服务,基本涵盖了所有分布式锁服务中涉及到的关键技术,以及对应具体的工程实现方案。

基于分布式存储实现的锁服务,由于其内存数据存储的特性,所以具有结构简单,高性能和低延迟的优点。但是受限于通用存储的定位,其在锁数据一致性上缺乏严格保证,同时

其在解锁验证、故障切换、死锁处理等方面,存在各种问题。所以其适用于在对性能要求较高,但是可以容忍极端情况下丢失锁数据安全性的场景下。

基于分布式一致性算法实现的锁服务,其使用Paxos协议保证了锁数据的严格一致性,同时又具备高可用性。在要求锁数据严格一致的场景下,此类锁服务几乎是唯一的选择。但是由于其结构和分布式一致性协议的复杂性,其在性能和加锁延迟上,比基于分布式存储实现的锁服务要逊色。

所以实际应用场景下,需要根据具体需求出发,权衡各种考虑因素,选择合适的锁服务实现模型。无论选择哪一种模型,需要我们清楚地知道它在安全性上有哪些不足,以及它会带来什么后果。更特别的,如果是对锁数据安全性要求十分严格的应用场景,那么需要更加慎之又慎。在本文的讨论中,我们看到,在分布式锁的正确性上走得最远的,是基于Paxos实现,同时引入分布式资源进行验证的方案。下一篇,我们来介绍欢乐游戏基于实际业务场景,结合三类方案各自的特点,实现的一个严格保证锁数据安全性的高可用高性能锁服务方案。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家自动化/article/detail/776286
推荐阅读
  

闽ICP备14008679号