分布式锁服务关键技术和常见解决方案

2017-08-03 11:32:00

来源:

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

  前言

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

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

  目前常见的分布式锁服务,可以分为以下两大类:

  基于分布式缓存实现的锁服务及其变种:典型代表是使用Redis实现的锁服务和基于Redis实现的RedLock方案;

  基于分布式一致性算法实现的锁服务:典型代表为Zookeeper和Chubby。

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

  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的原子操作的特性,其Watch的CAS机制本身基于连接(有其他的分布式缓存产品能够支持这个特性)。因此解锁需要两步,先查锁回来确认Value这把锁是自己加的,然后再发起Del解锁。由于Get和Del操作的非原子性,那么解锁本身也会存在破坏互斥性的情况,考虑如下场景:

  (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)客户端1从Master获取了锁。

  (2)Master宕机了,存储锁的key还没有来得及同步到Slave上。

  (3)Slave升级为Master。

  (4)客户端2从新的Master获取到了对应同一个资源的锁。

  (5)客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破。

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

  1.3 总结

  1、锁服务性能

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

  2、数据一致性和可用性

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

  (1)锁数据写入的时候,没有保证同时写成功多份:任何事后的同步在机制上都是不够安全的,因此在故障时,锁数据存在丢失的可能。解决此类问题,需要在写多份和服务可用性之间找到平衡(典型思想:多数派,详细描述见后面的两类锁服务方案)。

  (2)没有原子性的保证持有者才能解锁:锁服务需要提供一种机制,使得在网络各种乱序以及包重放的时候,保证只有锁当前持有者方能解锁,同时要保证解锁操作的原子性。

  (3)锁服务缺乏和调用方(或者公共资源方)的确认机制:预防死锁等问题,光靠锁服务自身,是不够安全的,只有调用方和公共资源的一同参与,方能全面保证(Chubby提供了一种做法,详细见后面的描述)

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

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

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

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

  2.1 核心架构和流程

 

  图2.RedLock锁服务流程图

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

  (1)获取当前时间(毫秒数)。

  (2)按顺序依次向N个Redis节点执行获取锁的操作(其实可以并发同时向N个Redis获取锁)。这个获取操作跟前面基于单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中,仍然有此问题,考虑如下的时序:假设一共有5个Redis节点:A, B, C, D, E。

  (1)客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。

  (2)节点C时间异常,导致C上的锁数据提前到期,而被释放。

  (3)客户端2此时尝试获取同一把锁:锁住了C, D, E,获取锁成功。

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

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

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

  (1)客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。

  (2)节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。(Redis的AOF持久化方式是每秒写一次磁盘(即执行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数据的不完整的前提下,没有识别和学习机制,使得在各种分布式环境的典型场景下(结点故障、网络丢包、网络乱序),没有完整数据但参与决策,从而破坏数据一致性。

 
索取“此产品”的详细资料,请留言
  • *姓名:
  • *手机:
  • *邮寄地址:
相关阅读
<