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的锁解开。此时如果有第三个主机尝试抢占这把锁,也将会成功获得。因此,我们需要在解锁时,确定自己解的这个锁正是自己锁上的。
如果分布式锁的实现,还能再解决上面的三个问题,那么就可以算是一个相对完整的分布式锁了。然而,在实际的系统环境中,还会对分布式锁有更高级的要求。
可重入:线程中的可重入,指的是外层函数获得锁之后,内层也可以获得锁,ReentrantLock和synchronized都是可重入锁;衍生到分布式环境中,一般仍然指的是线程的可重入,在绝大多数分布式环境中,都要求分布式锁是可重入的。
惊群效应(Herd Effect):在分布式锁中,惊群效应指的是,在有多个请求等待获取锁的时候,一旦占有锁的线程释放之后,如果所有等待的方都同时被唤醒,尝试抢占锁。但是这样的情况会造成比较大的开销,那么在实现分布式锁的时候,应该尽量避免惊群效应的产生。
公平锁和非公平锁:不同的需求,可能需要不同的分布式锁。非公平锁普遍比公平锁开销小。但是业务需求如果必须要锁的竞争者按顺序获得锁,那么就需要实现公平锁。
阻塞锁和自旋锁:针对不同的使用场景,阻塞锁和自旋锁的效率也会有所不同。阻塞锁会有上下文切换,如果并发量比较高且临界区的操作耗时比较短,那么造成的性能开销就比较大了。但是如果临界区操作耗时比较长,一直保持自旋,也会对CPU造成更大的负荷。
保留以上所有问题和条件,我们接下来看一些比较典型的实现方案。
ZooKeeper的实现
ZooKeeper(以下简称“ZK”)中有一种节点叫做顺序节点,假如我们在/lock/目录下创建3个节点,ZK集群会按照发起创建的顺序来创建节点,节点分别为/lock/0000000001、/lock/0000000002、/lock/0000000003。
ZK中还有一种名为临时节点的节点,临时节点由某个客户端创建,当客户端与ZK集群断开连接,则该节点自动被删除。EPHEMERAL_SEQUENTIAL为临时顺序节点。
根据ZK中节点是否存在,可以作为分布式锁的锁状态,以此来实现一个分布式锁,下面是分布式锁的基本逻辑:
客户端调用create()方法创建名为“/dlm-locks/lockname/lock-”的临时顺序节点。
客户端调用getChildren(“lockname”)方法来获取所有已经创建的子节点。
客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点是所有节点中序号最小的,那么就认为这个客户端获得了锁。
如果创建的节点不是所有节点中需要最小的,那么则监视比自己创建节点的序列号小的最大的节点,进入等待。直到下次监视的子节点变更的时候,再进行子节点的获取,判断是否获取锁。
释放锁的过程相对比较简单,就是删除自己创建的那个子节点即可,不过也仍需要考虑删除节点失败等异常情况。
开源的基于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分布式锁的实现步骤(与下图一一对应):
线程A发送SETNX lock.orderid 尝试获得锁,如果锁不存在,则set并获得锁。
如果锁存在,则再判断锁的值(时间戳)是否大于当前时间,如果没有超时,则等待一下再重试。
如果已经超时了,在用GETSET lock.{orderid} 来尝试获取锁,如果这时候拿到的时间戳仍旧超时,则说明已经获得锁了。
如果在此之前,另一个线程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分布式锁将不同引擎的接口抽象为一套,屏蔽了不同引擎的实现细节。使得使用方可以专注于业务逻辑,也可以任意选择并切换引擎而不必更改任何的业务代码。
如果使用方选择了一种以上的引擎,那么以配置顺序来区分主副引擎。以下是使用主引擎的推荐:
下面是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个版本,先后在美团点评多个项目中稳定运行。
幂等性问题
所谓幂等,简单地说,就是对接口的多次调用所产生的结果和调用一次是一致的。扩展一下,这里的接口,可以理解为对外发布的HTTP接口或者Thrift接口,也可以是接收消息的内部接口,甚至是一个内部方法或操作。
那么我们为什么需要接口具有幂等性呢?设想一下以下情形:
在App中下订单的时候,点击确认之后,没反应,就又点击了几次。在这种情况下,如果无法保证该接口的幂等性,那么将会出现重复下单问题。
在接收消息的时候,消息推送重复。如果处理消息的接口无法保证幂等,那么重复消费消息产生的影响可能会非常大。
在分布式环境中,网络环境更加复杂,因前端操作抖动、网络故障、消息重复、响应速度慢等原因,对接口的重复调用概率会比集中式环境下更大,尤其是重复消息在分布式环境中很难避免。Tyler Treat也在《You Cannot Have Exactly-Once Delivery》一文中提到:
Within the context of a distributed system, you cannot have exactly-once message delivery.
分布式环境中,有些接口是天然保证幂等性的,如查询操作。有些对数据的修改是一个常量,并且无其他记录和操作,那也可以说是具有幂等性的。其他情况下,所有涉及对数据的修改、状态的变更就都有必要防止重复性操作的发生。通过间接的实现接口的幂等性来防止重复操作所带来的影响,成为了一种有效的解决方案。
GTIS就是这样的一个解决方案。它是一个轻量的重复操作关卡系统,它能够确保在分布式环境中操作的唯一性。我们可以用它来间接保证每个操作的幂等性。它具有如下特点:
高效:低延时,单个方法平均响应时间在2ms内,几乎不会对业务造成影响;
可靠:提供降级策略,以应对外部存储引擎故障所造成的影响;提供应用鉴权,提供集群配置自定义,降低不同业务之间的干扰;
简单:接入简捷方便,学习成本低。只需简单的配置,在代码中进行两个方法的调用即可完成所有的接入工作;
灵活:提供多种接口参数、使用策略,以满足不同的业务需求。
GTIS的实现思路是将每一个不同的业务操作赋予其唯一性。这个唯一性是通过对不同操作所对应的唯一的内容特性生成一个唯一的全局ID来实现的。基本原则为:相同的操作生成相同的全局ID;不同的操作生成不同的全局ID。
生成的全局ID需要存储在外部存储引擎中,数据库、Redis亦或是Tair等等均可实现。考虑到Tair天生分布式和持久化的优势,目前的GTIS存储在Tair中。其相应的key和value如下:
key:将对于不同的业务,采用APP_KEY+业务操作内容特性生成一个唯一标识trans_contents。然后对唯一标识进行加密生成全局ID作为Key。
value:current_timestamp + trans_contents,current_timestamp用于标识当前的操作线程。
判断是否重复,主要利用Tair的SETNX方法,如果原来没有值则set且返回成功,如果已经有值则返回失败。
GTIS的内部实现流程为:
业务方在业务操作之前,生成一个能够唯一标识该操作的transContents,传入GTIS;
GTIS根据传入的transContents,用MD5生成全局ID;
GTIS将全局ID作为key,current_timestamp+transContents作为value放入Tair进行setNx,将结果返回给业务方;
业务方根据返回结果确定能否开始进行业务操作;
若能,开始进行操作;若不能,则结束当前操作;
业务方将操作结果和请求结果传入GTIS,系统进行一次请求结果的检验;
若该次操作成功,GTIS根据key取出value值,跟传入的返回结果进行比对,如果两者相等,则将该全局ID的过期时间改为较长时间;
GTIS返回最终结果。
GTIS的实现难点在于如何保证其判断重复的可靠性。由于分布式环境的复杂度和业务操作的不确定性,在上一章节分布式锁的实现中考虑的网络断开或主机宕机等等问题,同样需要在GTIS中设法解决。这里列出几个典型的场景:
如果操作执行失败,理想的情况应该是另一个相同的操作可以立即进行。因此,需要对业务方的操作结果进行判断,如果操作失败,那么就需要立即删除该全局ID;
如果操作超时或主机宕机,当前的操作无法告知GTIS操作是否成功。那么我们必须引入超时机制,一旦长时间获取不到业务方的操作反馈,那么也需要该全局ID失效;
结合上两个场景,既然全局ID会失效并且可能会被删除,那就需要保证删除的不是另一个相同操作的全局ID。这就需要将特殊的标识记录下来,并由此来判断。这里所用的标识为当前时间戳。
可以看到,解决这些问题的思路,也和上一章节中的实现有很多类似的地方。除此以外,还有更多的场景需要考虑和解决,所有分支流程如下:

使用时,业务方只需要在操作的前后调用GTIS的前置方法和后置方法,如下图所示。如果前置方法返回可进行操作,则说明此时无重复操作,可以进行。否则则直接结束操作。
使用方需要考虑的主要是下面两个参数:
空间全局性:业务方输入的能够标志操作唯一性的内容特性,可以是唯一性的String类型的ID,也可以是map、POJO等形式。如订单ID等
时间全局性:确定在多长时间内不允许重复,1小时内还是一个月内亦或是永久。
此外,GTIS还提供了不同的故障处理策略和重试机制,以此来降低外部存储引擎异常对系统造成的影响。
目前,GTIS已经持续迭代了7个版本,距离第一个版本有近1年之久,先后在美团点评多个项目中稳定运行。
在分布式环境中,操作互斥性问题和幂等性问题非常普遍。经过分析,我们找出了解决这两个问题的基本思路和实现原理,给出了具体的解决方案。
针对操作互斥性问题,常见的做法便是通过分布式锁来处理对共享资源的抢占。分布式锁的实现,很大程度借鉴了多线程和多进程环境中的互斥锁的实现原理。只要满足一些存储方面的基本条件,并且能够解决如网络断开等异常情况,那么就可以实现一个分布式锁。目前已经有基于Zookeeper和Redis等存储引擎的比较典型的分布式锁实现。但是由于单存储引擎的局限,我们开发了基于ZooKeeper和Tair的多引擎分布式锁Cerberus,它具有使用灵活方便等诸多优点,还提供了完善的一键降级方案。
针对操作幂等性问题,我们可以通过防止重复操作来间接的实现接口的幂等性。GTIS提供了一套可靠的解决方法:依赖于存储引擎,通过对不同操作所对应的唯一的内容特性生成一个唯一的全局ID来防止操作重复。
目前Cerberus分布式锁、GTIS都已应用在生产环境并平稳运行。两者提供的解决方案已经能够解决大多数分布式环境中的操作互斥性和幂等性的问题。值得一提的是,分布式锁和GTIS都不是万能的,它们对外部存储系统的强依赖使得在环境不那么稳定的情况下,对可靠性会造成一定的影响。在并发量过高的情况下,如果不能很好的控制锁的粒度,那么使用分布式锁也是不太合适的。总的来说,分布式环境下的业务场景纷繁复杂,要解决互斥性和幂等性问题还需要结合当前系统架构、业务需求和未来演进综合考虑。Cerberus分布式锁和GTIS也会持续不断地迭代更新,提供更多的引擎选择、更高效可靠的实现方式、更简捷的接入流程,以期满足更复杂的使用场景和业务需求。
回答“思考题”、发现文章有错误、对内容有疑问,都可以来微信公众号(美团点评技术团队)后台给我们留言。我们每周会挑选出一位“优秀回答者”,赠送一份精美的小礼品。快来扫码关注我们吧!
