分布式锁

分布式环境下的解决方案——分布式锁

首先,我们来看看分布式锁的基本条件。

分布式锁条件

基本条件

再回顾下多线程和多进程环境下的锁,可以发现锁的实现有很多共通之处,它们都需要满足一些最基本的条件:

  1. 需要有存储锁的空间,并且锁的空间是可以访问到的。
  2. 锁需要被唯一标识。
  3. 锁要有至少两种状态。

仔细分析这三个条件:

  • 存储空间

锁是一个抽象的概念,锁的实现,需要依存于一个可以存储锁的空间。在多线程中是内存,在多进程中是内存或者磁盘。更重要的是,这个空间是可以被访问到的。多线程中,不同的线程都可以访问到堆中的成员变量;在多进程中,不同的进程可以访问到共享内存中的数据或者存储在磁盘中的文件。但是在分布式环境中,不同的主机很难访问对方的内存或磁盘。这就需要一个都能访问到的外部空间来作为存储空间。

最普遍的外部存储空间就是数据库了,事实上也确实有基于数据库做分布式锁(行锁、version乐观锁),如quartz集群架构中就有所使用。除此以外,还有各式缓存如Redis、Tair、Memcached、Mongodb,当然还有专门的分布式协调服务Zookeeper,甚至是另一台主机。只要可以存储数据、锁在其中可以被多主机访问到,那就可以作为分布式锁的存储空间。

  • 唯一标识

不同的共享资源,必然需要用不同的锁进行保护,因此相应的锁必须有唯一的标识。在多线程环境中,锁可以是一个对象,那么对这个对象的引用便是这个唯一标识。多进程环境中,信号量在共享内存中也是由引用来作为唯一的标识。但是如果不在内存中,失去了对锁的引用,如何唯一标识它呢?上文提到的有名信号量,便是用硬盘中的文件名作为唯一标识。因此,在分布式环境中,只要给这个锁设定一个名称,并且保证这个名称是全局唯一的,那么就可以作为唯一标识。

  • 至少两种状态

为了给临界区加锁和解锁,需要存储两种不同的状态。如ReentrantLock中的status,0表示没有线程竞争,大于0表示有线程竞争;信号量大于0表示可以进入临界区,小于等于0则表示需要被阻塞。因此只要在分布式环境中,锁的状态有两种或以上:如有锁、没锁;存在、不存在等,均可以实现。

有了这三个条件,基本就可以实现一个简单的分布式锁了。下面以数据库为例,实现一个简单的分布式锁: 
数据库表,字段为锁的ID(唯一标识),锁的状态(0表示没有被锁,1表示被锁)。

伪代码为:

lock = mysql.get(id);
while(lock.status == 1) {
    sleep(100);
}
mysql.update(lock.status = 1);
doSomething();
mysql.update(lock.status = 0);

问题

以上的方式即可以实现一个粗糙的分布式锁,但是这样的实现,有没有什么问题呢?

  • 问题1:锁状态判断原子性无法保证 
    从读取锁的状态,到判断该状态是否为被锁,需要经历两步操作。如果不能保证这两步的原子性,就可能导致不止一个请求获取到了锁,这显然是不行的。因此,我们需要保证锁状态判断的原子性。

  • 问题2:网络断开或主机宕机,锁状态无法清除 
    假设在主机已经获取到锁的情况下,突然出现了网络断开或者主机宕机,如果不做任何处理该锁将仍然处于被锁定的状态。那么之后所有的请求都无法再成功抢占到这个锁。因此,我们需要在持有锁的主机宕机或者网络断开的时候,及时的释放掉这把锁。

  • 问题3:无法保证释放的是自己上锁的那把锁 
    在解决了问题2的情况下再设想一下,假设持有锁的主机A在临界区遇到网络抖动导致网络断开,分布式锁及时的释放掉了这把锁。之后,另一个主机B占有了这把锁,但是此时主机A网络恢复,退出临界区时解锁。由于都是同一把锁,所以A就会将B的锁解开。此时如果有第三个主机尝试抢占这把锁,也将会成功获得。因此,我们需要在解锁时,确定自己解的这个锁正是自己锁上的。

进阶条件

如果分布式锁的实现,还能再解决上面的三个问题,那么就可以算是一个相对完整的分布式锁了。然而,在实际的系统环境中,还会对分布式锁有更高级的要求。

  1. 可重入:线程中的可重入,指的是外层函数获得锁之后,内层也可以获得锁,ReentrantLock和synchronized都是可重入锁;衍生到分布式环境中,一般仍然指的是线程的可重入,在绝大多数分布式环境中,都要求分布式锁是可重入的。

  2. 惊群效应(Herd Effect):在分布式锁中,惊群效应指的是,在有多个请求等待获取锁的时候,一旦占有锁的线程释放之后,如果所有等待的方都同时被唤醒,尝试抢占锁。但是这样的情况会造成比较大的开销,那么在实现分布式锁的时候,应该尽量避免惊群效应的产生。

  3. 公平锁和非公平锁:不同的需求,可能需要不同的分布式锁。非公平锁普遍比公平锁开销小。但是业务需求如果必须要锁的竞争者按顺序获得锁,那么就需要实现公平锁。

  4. 阻塞锁和自旋锁:针对不同的使用场景,阻塞锁和自旋锁的效率也会有所不同。阻塞锁会有上下文切换,如果并发量比较高且临界区的操作耗时比较短,那么造成的性能开销就比较大了。但是如果临界区操作耗时比较长,一直保持自旋,也会对CPU造成更大的负荷。

保留以上所有问题和条件,我们接下来看一些比较典型的实现方案。

典型实现

ZooKeeper的实现

ZooKeeper(以下简称“ZK”)中有一种节点叫做顺序节点,假如我们在/lock/目录下创建3个节点,ZK集群会按照发起创建的顺序来创建节点,节点分别为/lock/0000000001、/lock/0000000002、/lock/0000000003。

ZK中还有一种名为临时节点的节点,临时节点由某个客户端创建,当客户端与ZK集群断开连接,则该节点自动被删除。EPHEMERAL_SEQUENTIAL为临时顺序节点。

根据ZK中节点是否存在,可以作为分布式锁的锁状态,以此来实现一个分布式锁,下面是分布式锁的基本逻辑:

  1. 客户端调用create()方法创建名为“/dlm-locks/lockname/lock-”的临时顺序节点。
  2. 客户端调用getChildren(“lockname”)方法来获取所有已经创建的子节点。
  3. 客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点是所有节点中序号最小的,那么就认为这个客户端获得了锁。
  4. 如果创建的节点不是所有节点中需要最小的,那么则监视比自己创建节点的序列号小的最大的节点,进入等待。直到下次监视的子节点变更的时候,再进行子节点的获取,判断是否获取锁。

释放锁的过程相对比较简单,就是删除自己创建的那个子节点即可,不过也仍需要考虑删除节点失败等异常情况。

开源的基于ZK的Menagerie的源码就是一个典型的例子:https://github.com/sfines/menagerie 。

Menagerie中的lock首先实现了可重入锁,利用ThreadLocal存储进入的次数,每次加锁次数加1,每次解锁次数减1。如果判断出是当前线程持有锁,就不用走获取锁的流程。

通过tryAcquireDistributed方法尝试获取锁,循环判断前序节点是否存在,如果存在则监视该节点并且返回获取失败。如果前序节点不存在,则再判断更前一个节点。如果判断出自己是第一个节点,则返回获取成功。

为了在别的线程占有锁的时候阻塞,代码中使用JUC的condition来完成。如果获取尝试锁失败,则进入等待且放弃localLock,等待前序节点唤醒。而localLock是一个本地的公平锁,使得condition可以公平的进行唤醒,配合循环判断前序节点,实现了一个公平锁。

这种实现方式非常类似于ReentrantLock的CHL队列,而且zk的临时节点可以直接避免网络断开或主机宕机,锁状态无法清除的问题,顺序节点可以避免惊群效应。这些特性都使得利用ZK实现分布式锁成为了最普遍的方案之一。

Redis的实现

Redis的分布式缓存特性使其成为了分布式锁的一种基础实现。通过Redis中是否存在某个锁ID,则可以判断是否上锁。为了保证判断锁是否存在的原子性,保证只有一个线程获取同一把锁,Redis有SETNX(即SET if Not 
eXists)和GETSET(先写新值,返回旧值,原子性操作,可以用于分辨是不是首次操作)操作。

为了防止主机宕机或网络断开之后的死锁,Redis没有ZK那种天然的实现方式,只能依赖设置超时时间来规避。

以下是一种比较普遍但不太完善的Redis分布式锁的实现步骤(与下图一一对应):

  1. 线程A发送SETNX lock.orderid尝试获得锁,如果锁不存在,则set并获得锁。
  2. 如果锁存在,则再判断锁的值(时间戳)是否大于当前时间,如果没有超时,则等待一下再重试。
  3. 如果已经超时了,在用GETSET lock.{orderid}来尝试获取锁,如果这时候拿到的时间戳仍旧超时,则说明已经获得锁了。
  4. 如果在此之前,另一个线程C快一步执行了上面的操作,那么A拿到的时间戳是个未超时的值,这时A没有如期获得锁,需要再次等待或重试。

图片描述

该实现还有一个需要考虑的问题是全局时钟问题,由于生产环境主机时钟不能保证完全同步,对时间戳的判断也可能会产生误差。

以上是Redis的一种常见的实现方式,除此以外还可以用SETNX+EXPIRE来实现。Redisson是一个官方推荐的Redis客户端并且实现了很多分布式的功能。它的分布式锁就提供了一种更完善的解决方案,源码:https://github.com/mrniko/redisson

Tair的实现

Tair和Redis的实现类似,Tair客户端封装了一个expireLock的方法:通过锁状态和过期时间戳来共同判断锁是否存在,只有锁已经存在且没有过期的状态才判定为有锁状态。在有锁状态下,不能加锁,能通过大于或等于过期时间的时间戳进行解锁。

采用这样的方式,可以不用在Value中存储时间戳,并且保证了判断是否有锁的原子性。更值得注意的是,由于超时时间是由Tair判断,所以避免了不同主机时钟不一致的情况。

以上的几种分布式锁实现方式,都是比较常见且有些已经在生产环境中应用。随着应用环境越来越复杂,这些实现可能仍然会遇到一些挑战。

  • 强依赖于外部组件:分布式锁的实现都需要依赖于外部数据存储如ZK、Redis等,因此一旦这些外部组件出现故障,那么分布式锁就不可用了。
  • 无法完全满足需求:不同分布式锁的实现,都有相应的特点,对于一些需求并不能很好的满足,如实现公平锁、给等待锁加超时时间等。

基于以上问题,结合多种实现方式,我们开发了Cerberus(得名自希腊神话里守卫地狱的猛犬),致力于提供灵活可靠的分布式锁。

Cerberus分布式锁

Cerberus有以下几个特点。

特点一:一套接口多种引擎

Cerberus分布式锁使用了多种引擎实现方式(Tair、ZK、未来支持Redis),支持使用方自主选择所需的一种或多种引擎。这样可以结合引擎特点,选择符合实际业务需求和系统架构的方式。

Cerberus分布式锁将不同引擎的接口抽象为一套,屏蔽了不同引擎的实现细节。使得使用方可以专注于业务逻辑,也可以任意选择并切换引擎而不必更改任何的业务代码。

如果使用方选择了一种以上的引擎,那么以配置顺序来区分主副引擎。以下是使用主引擎的推荐:

功能需求TairZK
并发量高 
响应时间敏感 
临界区执行时间长 
公平锁 
非公平锁 
读写锁 

特点二:使用灵活、学习成本低

下面是Cerberus的lock方法,这些方法和JUC的ReentrantLock的方式保持一致,使用非常灵活且不需要额外的学习时间。

  • void lock(); 
    获取锁,如果锁被占用,将禁用当前线程,并且在获得锁之前,该线程将一直处于阻塞状态。

  • boolean tryLock(); 
    仅在调用时锁为空闲状态才获取该锁。 
    如果锁可用,则获取锁,并立即返回值true。如果锁不可用,则此方法将立即返回值false。

  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException; 
    如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁。 
    如果在给定时间内锁可用,则获取锁,并立即返回值true。如果在给定时间内锁一直不可用,则此方法将立即返回值false。

  • void lockInterruptibly() throws InterruptedException; 
    获取锁,如果锁被占用,则一直等待直到线程被中断或者获取到锁。

  • void unlock(); 
    释放当前持有的锁。

特点三:支持一键降级

Cerberus提供了实时切换引擎的接口:

  • String switchEngine() 
    转换分布式锁引擎,按配置的引擎的顺序循环转换。 
    返回值:返回当前的engine名字,如:”zk”。

  • String switchEngine(String engineName) 
    转换分布式锁引擎,切换为指定的引擎。 
    参数:engineName - 引擎的名字,同配置bean的名字,”zk”/”tair”。 
    返回值:返回当前的engine名字,如:”zk”。

当使用方选择了两种引擎,平时分布式锁会工作在主引擎上。一旦所依赖的主引擎出现故障,那么使用方可以通过自动或者手动方式调用该切换引擎接口,平滑的将分布式锁切换到另一个引擎上以将风险降到最低。自动切换方式可以利用Hystrix实现。手动切换推荐的一个方案则是使用美团点评基于Zookeeper的基础组件MCC,通过监听MCC配置项更改,来达到手动将分布式系统所有主机同步切换引擎的目的。需要注意的是,切换引擎目前并不会迁移原引擎已有的锁。这样做的目的是出于必要性、系统复杂度和可靠性的综合考虑。在实际情况下,引擎故障到切换引擎,尤其是手动切换引擎的时间,要远大于分布式锁的存活时间。作为较轻量级的Cerberus来说,迁移锁会带来不必要的开销以及较高的系统复杂度。鉴于此,如果想要保证在引擎故障后的绝对可靠,那么则需要结合其他方案来进行处理。

除此以外,Cerberus还提供了内置公用集群,免去搭建和配置集群的烦恼。Cerberus也有一套完善的应用授权机制,以此防止业务方未经评估使用,对集群造成影响。

目前,Cerberus分布式锁已经持续迭代了8个版本,先后在美团点评多个项目中稳定运行。


  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值