赞
踩
锁,核心是协调各个使用方对公共资源使用的一种机制。当存在多个使用方互斥地使用某一个公共资源时,为了避免并行使用导致的修改结果不可控,需要在某个地方记录一个标记,这个标记能够被所有使用方看到,当标记不存在时,可以设置标记并且获得公共资源的使用权,其余使用者发现标记已经存在时,只能等待标记拥有方释放后,再去尝试设置标记。这个标记即可以理解为锁。
在单机多线程的环境下,由于使用环境简单和通信可靠,锁的可见性和原子性很容易可以保证,所以使用系统提供的互斥锁等方案,可以简单和可靠地实现锁功能。到了分布式的环境下,由于公共资源和使用方之间的分离,以及使用方和使用方之间的分离,相互之间的通信由线程间的内存通信变为网络通信。网络通信的时延和不可靠,加上分布式环境中各种故障的常态化发生,导致实现一个可靠的分布式锁服务需要考虑更多更复杂的问题。
目前常见的分布式锁服务,可以分为以下两大类:本文从上述两大类常见的分布式锁服务实现方案入手,从分布式锁服务的各个核心问题(核心架构、锁数据一致性、锁服务可用性、死锁预防机制、易用性、性能)展开,尝试对比分析各个实现方案的优劣和特点。
基于分布式缓存实现的锁服务,思路最为简单和直观。和单机环境的锁一样,我们把锁数据存放在分布式环境中的一个唯一结点,所有需要获取锁的调用方,都去此结点访问,从而实现对调用方的互斥,而存放锁数据的结点,使用各类分布式缓存产品充当。其核心架构如下(以Redis为例):
图1.基于分布式缓存实现的锁服务典型架构
基于Redis官方的文档,对于一个尝试获取锁的操作,流程如下:
1、 向Redis结点发送命令:
SET (key=Lock_Name , value=my_random_value) NX PX 30000其中:
2、 如果命令返回成功,则代表获取锁成功,否则获取锁失败。
对于一个拥有锁的客户端,释放锁,其流程如下:
1、 向 Redis 结点发送命令:基于上述流程,由于Redis结点是单点存在,所以在锁过期时间之内且Redis结点不发生故障的情况下,锁的安全性(即互斥性)可以得到保证。但是仍然有如下几个问题需要考虑:
1、 预防死锁的必要性
考虑如下场景,一个客户端获取锁成功,但是在释放锁之前崩溃了,此时实际上它已经放弃了对公共资源的操作权,但是却没有办法请求解锁,那么它就会一直持有这个锁,而其它客户端永远无法获得锁。因此,对于绝大部分场景,此类死锁场景是应该得到考虑和避免。
2、 引入锁自动过期时间来预防死锁带来的问题
为了预防死锁,利用分布式缓存的结点自动过期特性来定期删除死锁结点,看似可以解决问题。但是其中隐藏的隐患是:实质上,锁自动过期清理是释放了一个不属于自己的锁。那么几乎必然的,会破坏锁的互斥性,考虑如下场景:
也许有一个疑问,第五步中,客户端1恢复回来后,可以比较下目前已经持有锁的时间,如果发现已经快过期,则放弃对共享资源的操作即可避免互斥性失效的问题。事实上,客户端1的时间和Redis结点的时间本身就存在偏移的可能性,更极端一点,Redis上的时间还可能发生跳变或者比客户端时间跑得更快,所以,严格来讲,任何依赖两个时间比较的互斥性算法,都存在潜在的隐患。
3、 解锁操作的原子性
引入全局唯一的my_random_value,目的是想保证每次解锁操作,一定是解锁的自己加的锁。由于Redis没有能够提供基于数据版本号来删除Key的原子操作的特性,其Watch的CAS机制本身基于连接(有其他的分布式缓存产品能够支持这个特性)。因此解锁需要两步,先查锁回来确认Value这把锁是自己加的,然后再发起Del解锁。由于Get和Del操作的非原子性,那么解锁本身也会存在破坏互斥性的情况,考虑如下场景:
4、 Redis结点故障后,主备切换的数据一致性
考虑Redis结点宕机,如果长时间无法恢复,则导致锁服务长时间不可用。为了保证锁服务的可用性,通常的方案是给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上。但是由于Redis的主从复制(replication)是异步的,这可能导致在宕机切换过程中丧失锁的安全性。考虑下面的时序:
设想下,如果要避免这种情况,只有在写数据的时候,就阻塞地把数据写多份,全部写成功才返回,这样才能保证锁的安全性(分布式缓存的同步主从复制)。但这样就可以即保证数据一致性,又保证服务可用性了吗?其实不然,在锁数据写Master和Slave两份,都写成功才认为加锁成功的情况下,如果Master写成功,Slave写超时(其实写成功了),这个时候认为加锁是失败的,但是主和备的数据产生了不一致,而且Slave自身稳定性以及Master和Slave的通信稳定性还成为了导致服务不可用的额外因素。所以基于分布式缓存实现的锁服务,要想解决分布式系统一致性和可用性的核心问题,并不是简单的主从同步可以搞定(核心还是要靠Paxos这样的分布式一致性协议)。
1、锁服务性能
由于锁数据基于Redis等分布式缓存保存,基于内存的数据操作特性使得这类锁服务拥有着非常好的性能表现。同时锁服务调用方和锁服务本身只有一次RTT就可以完成交互,使得加锁延迟也很低。所以,高性能、低延迟是基于分布式缓存实现锁服务的一大优势。因此,在对性能要求较高,但是可以容忍极端情况下丢失锁数据安全性的场景下,非常适用。
2、数据一致性和可用性
锁数据一致性基于上述的分析,基于分布式缓存的锁服务受限于通用分布式缓存的定位,无法完全保证锁数据的安全性,核心的问题可以概括为三点:
基于分布式缓存实现锁服务,在业界还存在各类变种的方案,其核心是利用不同分布式缓存产品的额外特性,来改善基础方案的各类缺点,各类变种方案能提供的安全性和可用性也不尽相同。此处介绍一种业界最出名,同时也是引起过最大争论的一个锁服务变种方案-RedLock。
RedLock由Redis的作者Antirez提出,算是Redis官方对于实现分布式锁的指导规范。Redlock的算法描述就放在Redis的官网上(https://redis.io/topics/distlock)。
选择对比分析RedLock,第一是因为它作为Redis官方的锁服务指导规范,在提出的时候业内也对其进行过很多争议和讨论;第二是RedLock的算法中,已经有了分布式一致性算法中最核心的概念-多数派的思想。因此我们在众多变种中选择RedLock来进行介绍和分析。
图2.RedLock锁服务流程图
对于一个客户端,依次执行下面各个步骤,来完成获取锁的操作:
RedLock算法的最核心也是最有价值之处,是引入了多数派思想,来解决单点故障对数据安全性和服务可用性的影响。由于加锁成功需要所有Redis结点中的多数结点同意,因此只要集群中结点有一半能够提供服务时,服务的可用性就能够保证。同时对于数据的一致性,只要对于一把锁,其多数派结点的数据不丢,那么锁就不可能被另外的调用方同时获得(不够多数派),所以锁的安全性也可以得到保证。所以从核心算法来说,多数派的思想是对数据一致性的保证下,向保证服务可用性又进了一大步。
但是,多数派仅仅是算法最核心的理论保证。要实现一个工程上完全保证锁数据安全性,同时高可用的锁服务,RedLock还有很远的距离,这也是RedLock在业界引起很多争议的地方,核心的问题见下面的分析。
1、 RedLock的安全性依旧强依赖于系统时间
在之前单点Redis锁服务的时候已经分析过,由于为了预防死锁,使用了过期自动删除锁的机制,所以导致安全性依赖于单机Redis上的时间服务不能异常,从而存在隐患(本质是违反了锁持有者才能删除锁的原则)。同样的,到了RedLock中,仍然有此问题,考虑如下的时序:假设一共有5个Redis节点:A, B, C, D, E。
所以一个安全的算法,是不应该依赖于系统时间的。消息可能在网络中延迟任意长的时间,甚至丢失,系统时钟也可能以任意方式出错。一个好的分布式算法,这些因素不应该影响它的安全性,只可能影响到它的有效性,也就是说,即使在非常极端的情况下(比如系统时钟严重错误),算法顶多是不能在有限的时间内给出结果而已,而不应该给出错误的结果。
2、 缺乏锁数据丢失的识别机制和恢复机制
假设一共有5个Redis节点:A, B, C, D, E。见如下的事件序列:
此类问题的本质,是作为多数派数据的一个结点,数据丢失之后(比如故障未落地、超时被清理等等),首先没有能够区分丢失了哪些数据的能力,其次还没有恢复丢失数据的能力。这两种能力都缺乏的情况下,数据结点就继续正常地参与投票,从而导致的数据一致性被破坏。
RedLock也意识到了这个问题,所以在其中有一个延迟重启(delayed restarts)的概念。也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于其上所有锁的有效时间的最大值。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。这个方案,是在缺乏丢失数据识别的能力下,实现的较“悲观”的一个替代方案,首先其方案依旧依赖于时间,其次如何确定最大过期时间,也是一个麻烦的事情,因为最大过期时间很可能也一起丢失了(未持久化),再有延迟重启使得故障结点恢复的时间延长,增加了集群服务可用性的隐患。怎么来看,都不算一个优雅的方案。
1、锁服务性能
由于RedLock锁数据仍然基于Redis保存,所以和基于单点的Redis锁一样,具有高性能和低延迟的特性,不过由于引入多数派的思想,加锁和解锁时的并发写,所以在流量消耗来说,比基于单点的Redis锁消耗要大。从资源角度来说,是用流量换取了比单点Redis稍高的数据一致性和服务可用性。
2、数据一致性和可用性
RedLock的核心价值,在于多数派思想。不过根据上面的分析,它依然不是一个工程上可以完全保证锁数据一致性的锁服务。相比于基于单点Redis的锁服务,RedLock解决了锁数据写入时多份的问题,从而可以克服单点故障下的数据一致性问题,但是还是受限于通用存储的定位,其锁服务整体机制上的不完备,使得无法完全保证锁数据的安全性。在继承自基于单点的Redis锁服务缺陷(解锁不具备原子性;锁服务、调用方、资源方缺乏确认机制)的基础上,其核心的问题为:缺乏锁数据丢失的识别和学习机制。
RedLock中的每台Redis,充当的仍旧只是存储锁数据的功能,每台Redis之间各自独立,单台Redis缺乏全局的信息,自然也不知道自己的锁数据是否是完整的。在单台Redis数据的不完整的前提下,没有识别和学习机制,使得在各种分布式环境的典型场景下(结点故障、网络丢包、网络乱序),没有完整数据但参与决策,从而破坏数据一致性。
分析完上述的锁服务方案,可以看到,各种方案核心还是在一致性和可用性之间做取舍。对于锁服务本身的定位和用途而言,其是一个相对中心化,对数据一致性有严格要求的场景。所以在分布式环境下,把数据严格一致性作为第一要求的情况下,Paxos算法是绕不开的一个算法(“all working protocols for asynchronous consensus we have so far encountered have Paxos at their core”)。于是就有了Chubby和Zookeeper这类,基于分布式一致性算法(核心是Paxos和相关变种)实现的锁服务。
Chubby是由Google开发实现,在其内部使用的一个分布式锁服务,其核心设计Google通过论文的形式开源出来。而Zookeeper作为Chubby的开源实现版本,由开源社区开发,目前也广泛应用在各种场景下。由于Zookeeper和Chubby之间的关系,两者在绝大部分的设计上都十分相似,因此此部分以Chubby为例,来分析此类锁服务的相关特点,关于Zookeeper和Chubby设计上的差异,在本节最后简要分析。
图3.Chubby的系统结构
如上图,一个典型的Chubby集群,或者叫Chubby Cell,通常由5台服务器(奇数台)组成。这些服务器之间采用Paxos协议,通过投票方式决定一个服务器作为Master。一旦一个服务器成为Master,Chubby会保证一段时间其他服务器不会成为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结点的基本架构
Chubby作为分布式锁服务,提供的数据操作接口是类似于Unix文件系统接口风格的接口,这样设计的初衷据说是文件系统操作风格的接口在Google内部更加符合使用者的习惯。Chubby中所有的数据都是以文件结点的形式提供给调用者访问,Chubby中典型的结点如下:/ls/foo/wombat/pouch。
结点分为永久结点和临时结点,临时结点在没有客户端打开或者其子目录下已经为空的情况下自动删除。每个结点都可以设置访问控制权限(ACL),同时结点的原数据(MeteData)中有四个递增的64位数,用于区分结点在各个方面的修改时序:1、实体编号:区分同名结点的先后;2、文件内容编号:文件内容修改时自增;3、锁编号:锁结点被获取时自增;4、ACL编号:ACL变化时自增。
基于文件结点的组织形式,Chubby提供的数据操作API如下:
为了避免大量客户端轮询服务器带来的压力,Chubby提供了事件通知机制。Chubby客户端可以向Chubby注册事件通知,当触发了这些事件后服务端就会向客户端发送事件通知。Chubby支持的事件类型包括不限于:
结合上述Chubby的设计细节,Chubby中客户端完成加锁的操作序列如下:
Chubby的加锁流程看起来十分简单,我们来详细分析下,Chubby如何解决之前几种方案碰到的问题:
因此,Chubby通过一致性协议,解决了单点Redis数据没有多份的问题,同时解决了RedLock无法识别缺失数据和学习缺失数据的问题。在可用性方面,只要集群大部分机器正常工作,Chubby就能保持正常对外提供服务。在数据一致性和可用性方面,Chubby这类方案明显优于前两种方案(这本身就是Paxos协议的长处)。
总结起来,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的沟通渠道,这就允许资源服务器在需要的时候,利用它提供更强的安全性保障。
1、 对于服务读负载的取舍
Chubby设计为所有的读写都经过Master处理,这必然导致Master的负载过高,因此Chubby在Client端实现了缓存机制。Client端在本地有文件内容的Cache,Client端对Cache的维护只是负责让Cache失效,而不持续更新Cache,失效后的Cache,在Client下一次访问Master之后重新创建。每次修改后,Master通过Client的保活包(所以保活包除了有延长Session租约和通知事件的功能外,还有一个功能是Cacha失效),通知每个拥有此Cache的Client(Master维护了每个Client可能拥有的Cache信息),让他们的Cache失效,Client收到保活包之后,删除本地Cache。如果Client未收到本次保活包,那么只有两种可能,后续的保活包学习到Cache失效的内容,或者Session超时,清空所有Cache重新建立Session。所以对Cache机制而言,不能够保证Cache数据的随时最新,但是可以保证最终的Cache数据一致性,同时可以大量避免每次向Master读带来的网络流量开销和Master的高负载。
Zookeeper设计采取了另外一个思路,其中Client可以连接集群中任意一个节点,而不是必须要连接Master。Client的所有写请求必须转给Master处理,而读请求,可以由普通结点直接处理返回,从而分担了Master的负载。同样的,读数据不能保证时刻的一致性,但是可以保证最终一致性。
2、 预防死锁方面
Chubby提供了CheckSequencer()检查和Lock-Delay两种方式来避免锁失效带来的问题,引入了资源方和锁服务方的交互来保证锁数据安全性。不过Zookeeper目前还没有类似于CheckSequencer的机制,而只有类似于Lock-Delay的等待机制来尽量避免锁失效带来的安全性问题。所以在锁失效方面的安全性来说,Chubby提供了更好的保证。
3、 锁使用便利性方面的差异
Chubby和Zookeeper都提供了事件机制,这个机制可以这样来使用,比如当客户端试图创建/lock的时候,发现它已经存在了,这时候创建失败,但客户端不一定就此对外宣告获取锁失败。客户端可以进入一种等待状态,等待当/lock节点被释放的时候,锁服务通过事件机制通知它,这样它就可以继续完成创建操作(获取锁)。这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。
但是考虑这样一个问题,当有大量的客户端都阻塞在/lock结点上时,一旦之前的持有者释放锁,那么阻塞的潜在调用方都会被激活,但是大量客户端被激活,重新发起加锁操作时,又只有一个客户端能成功,造成所谓的“惊群”效应。
考虑到这一点,Zookeeper上实现了一个“有序临时结点”的功能,来避免惊群。对于一个临时锁结点,Zookeeper支持每次创建都可以成功,但是每次创建的结点通过一个自增的序号来区别。创建成功最小结点的客户端表明获得了锁,而其他调用方创建的结点序号表明其处于锁等待队列中的位置。所以,对于获取锁失败的客户端,其只需要监听序号比其小的最大结点的释放情况,就可以判断何时自己有机会竞争锁。而不是每次一旦有锁释放,都去尝试重新加锁,从而避免“惊群”效应产生。
本文通过分析三类分布式锁服务,基本涵盖了所有分布式锁服务中涉及到的关键技术,以及对应具体的工程实现方案。
基于分布式存储实现的锁服务,由于其内存数据存储的特性,所以具有结构简单,高性能和低延迟的优点。但是受限于通用存储的定位,其在锁数据一致性上缺乏严格保证,同时
其在解锁验证、故障切换、死锁处理等方面,存在各种问题。所以其适用于在对性能要求较高,但是可以容忍极端情况下丢失锁数据安全性的场景下。
基于分布式一致性算法实现的锁服务,其使用Paxos协议保证了锁数据的严格一致性,同时又具备高可用性。在要求锁数据严格一致的场景下,此类锁服务几乎是唯一的选择。但是由于其结构和分布式一致性协议的复杂性,其在性能和加锁延迟上,比基于分布式存储实现的锁服务要逊色。
所以实际应用场景下,需要根据具体需求出发,权衡各种考虑因素,选择合适的锁服务实现模型。无论选择哪一种模型,需要我们清楚地知道它在安全性上有哪些不足,以及它会带来什么后果。更特别的,如果是对锁数据安全性要求十分严格的应用场景,那么需要更加慎之又慎。在本文的讨论中,我们看到,在分布式锁的正确性上走得最远的,是基于Paxos实现,同时引入分布式资源进行验证的方案。下一篇,我们来介绍欢乐游戏基于实际业务场景,结合三类方案各自的特点,实现的一个严格保证锁数据安全性的高可用高性能锁服务方案。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。