超长解析:一文带你分析与解决分布式系统互斥性与幂等性问题

超长解析:一文带你分析与解决分布式系统互斥性与幂等性问题

随着互联网信息技术的飞速发展,数据量不断增大,业务逻辑也日趋复杂,对系统的高并发访问、海量数据处理的场景也愈来愈多。如何用较低成本实现系统的高可用、易伸缩、可扩展等目标就显得愈加重要。html

为了解决这一系列问题,系统架构也在不断演进。传统的集中式系统已经逐渐没法知足要求,分布式系统被使用在更多的场景中。前端

分布式系统由独立的服务器经过网络松散耦合组成。在这个系统中每一个服务器都是一台独立的主机,服务器之间经过内部网络链接。分布式系统有如下几个特色:node

  • 可扩展性:可经过横向水平扩展提升系统的性能和吞吐量。
  • 高可靠性:高容错,即便系统中一台或几台故障,系统仍可提供服务。
  • 高并发性:各机器并行独立处理和计算。
  • 廉价高效:多台小型机而非单台高性能机。

然而,在分布式系统中,其环境的复杂度、网络的不肯定性会形成诸如时钟不一致、“拜占庭将军问题”(Byzantine failure)等。存在于集中式系统中的机器宕机、消息丢失等问题也会在分布式环境中变得更加复杂。mysql

基于分布式系统的这些特征,有两种问题逐渐成为了分布式环境中须要重点关注和解决的典型问题:git

  • 互斥性问题。
  • 幂等性问题。

今天咱们就针对这两个问题来进行分析。github

互斥性问题

先看两个常见的例子:面试

例1:某服务记录关键数据X,当前值为100。A请求须要将X增长200;同时,B请求须要将X减100。redis

在理想的状况下,A先读取到X=100,而后X增长200,最后写入X=300。B请求接着从读取X=300,减小100,最后写入X=200。sql

然而在真实状况下,若是不作任何处理,则可能会出现:A和B同时读取到X=100;A写入以前B读取到X;B比A先写入等状况。数据库

例2:某服务提供一组任务,A请求随机从任务组中获取一个任务;B请求随机从任务组中获取一个任务。

在理想的状况下,A从任务组中挑选一个任务,任务组删除该任务,B从剩下的的任务中再挑一个,任务组删除该任务。

一样的,在真实状况下,若是不作任何处理,可能会出现A和B挑中了同一个任务的状况。

以上的两个例子,都存在操做互斥性的问题。互斥性问题用通俗的话来说,就是对共享资源的抢占问题。若是不一样的请求对同一个或者同一组资源读取并修改时,没法保证按序执行,没法保证一个操做的原子性,那么就颇有可能会出现预期外的状况。所以操做的互斥性问题,也能够理解为一个须要保证时序性、原子性的问题。

在传统的基于数据库的架构中,对于数据的抢占问题每每是经过数据库事务(ACID)来保证的。在分布式环境中,出于对性能以及一致性敏感度的要求,使得分布式锁成为了一种比较常见而高效的解决方案。

事实上,操做互斥性问题也并不是分布式环境所独有,在传统的多线程、多进程状况下已经有了很好的解决方案。所以在研究分布式锁以前,咱们先来分析下这两种状况的解决方案,以期可以对分布式锁的解决方案提供一些实现思路。

多线程环境解决方案及原理

解决方案

《Thinking in Java》书中写到:

基本上全部的并发模式在解决线程冲突问题的时候,都是采用序列化访问共享资源的方案。

在多线程环境中,线程之间由于公用一些存储空间,冲突问题时有发生。解决冲突问题最广泛的方式就是用互斥锁把该资源或对该资源的操做保护起来。

Java JDK中提供了两种互斥锁Lock和synchronized。不一样的线程之间对同一资源进行抢占,该资源一般表现为某个类的普通成员变量。所以,利用ReentrantLock或者synchronized将共享的变量及其操做锁住,便可基本解决资源抢占的问题。

下面来简单聊一聊二者的实现原理。

原理

ReentrantLock

ReentrantLock主要利用CAS+CLH队列来实现。它支持公平锁和非公平锁,二者的实现相似。

  • CAS:Compare and Swap,比较并交换。CAS有3个操做数:内存值V、预期值A、要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改成B,不然什么都不作。该操做是一个原子操做,被普遍的应用在Java的底层实现中。在Java中,CAS主要是由sun.misc.Unsafe这个类经过JNI调用CPU底层指令实现。

  • CLH队列:带头结点的双向非循环链表(以下图所示):

超长解析:一文带你分析与解决分布式系统互斥性与幂等性问题

ReentrantLock的基本实现能够归纳为:先经过CAS尝试获取锁。若是此时已经有线程占据了锁,那就加入CLH队列而且被挂起。当锁被释放以后,排在CLH队列队首的线程会被唤醒,而后CAS再次尝试获取锁。在这个时候,若是:

  • 非公平锁:若是同时还有另外一个线程进来尝试获取,那么有可能会让这个线程抢先获取;

  • 公平锁:若是同时还有另外一个线程进来尝试获取,当它发现本身不是在队首的话,就会排到队尾,由队首的线程获取到锁。

下面分析下两个片断:

final boolean nonfairTryAcquire(int acquires) {
   final Thread current = Thread.currentThread();
   int c = getState();
   if (c == 0) {
       if (compareAndSetState(0, acquires)) {
           setExclusiveOwnerThread(current);
           return true;
       }
   }
   else if (current == getExclusiveOwnerThread()) {
       int nextc = c + acquires;
       if (nextc < 0) // overflow
           throw new Error("Maximum lock count exceeded");
       setState(nextc);
       return true;
   }
   return false;
}

在尝试获取锁的时候,会先调用上面的方法。若是状态为0,则代表此时无人占有锁。此时尝试进行set,一旦成功,则成功占有锁。若是状态不为0,再判断是不是当前线程获取到锁。若是是的话,将状态+1,由于此时就是当前线程,因此不用CAS。这也就是可重入锁的实现原理。

final boolean acquireQueued(final Node node, int arg) {
   boolean failed = true;
   try {
       boolean interrupted = false;
       for (;;) {
           final Node p = node.predecessor();
           if (p == head && tryAcquire(arg)) {
               setHead(node);
               p.next = null; // help GC
               failed = false;
               return interrupted;
           }
           if (shouldParkAfterFailedAcquire(p, node) &&
               parkAndCheckInterrupt())
               interrupted = true;
       }
   } finally {
       if (failed)
           cancelAcquire(node);
   }
}
private final boolean parkAndCheckInterrupt() {
   LockSupport.park(this);
   return Thread.interrupted();
}

该方法是在尝试获取锁失败加入CHL队尾以后,若是发现前序节点是head,则CAS再尝试获取一次。不然,则会根据前序节点的状态判断是否须要阻塞。若是须要阻塞,则调用LockSupport的park方法阻塞该线程。

synchronized

在Java语言中存在两种内建的synchronized语法:synchronized语句、synchronized方法。

  • synchronized语句:当源代码被编译成字节码的时候,会在同步块的入口位置和退出位置分别插入monitorenter和monitorexit字节码指令;

  • synchronized方法:在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1。这个在specification中没有明确说明。

在Java虚拟机的specification中,有关于monitorenter和monitorexit字节码指令的详细描述:

http://docs.oracle.com/Javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.monitorenter

monitorenter

The objectref must be of type reference.

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:

If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.

If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.

If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

每一个对象都有一个锁,也就是监视器(monitor)。当monitor被占有时就表示它被锁定。线程执行monitorenter指令时尝试获取对象所对应的monitor的全部权,过程以下:

  • 若是monitor的进入数为0,则该线程进入monitor,而后将进入数设置为1,该线程即为monitor的全部者;
  • 若是线程已经拥有了该monitor,只是从新进入,则进入monitor的进入数加1;
  • 若是其余线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再从新尝试获取monitor的全部权。

monitorexit

The objectref must be of type reference.

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.

The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

执行monitorexit的线程必须是相应的monitor的全部者。

指令执行时,monitor的进入数减1,若是减1后进入数为0,那线程退出monitor,再也不是这个monitor的全部者。其余被这个monitor阻塞的线程能够尝试去获取这个monitor的全部权。

在JDK1.6及其以前的版本中monitorenter和monitorexit字节码依赖于底层的操做系统的Mutex Lock来实现的,可是因为使用Mutex Lock须要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是很是昂贵的。然而在现实中的大部分状况下,同步方法是运行在单线程环境(无锁竞争环境)。若是每次都调用Mutex Lock将严重的影响程序的性能。所以在JDK 1.6以后的版本中对锁的实现作了大量的优化,这些优化在很大程度上减小或避免了Mutex Lock的使用。

多进程的解决方案

在多道程序系统中存在许多进程,它们共享各类资源,然而有不少资源一次只能供一个进程使用,这即是临界资源。多进程中的临界资源大体上能够分为两类,一类是物理上的真实资源,如打印机;一类是硬盘或内存中的共享数据,如共享内存等。而进程内互斥访问临界资源的代码被称为临界区。

针对临界资源的互斥访问,JVM层面的锁就已经失去效力了。在多进程的状况下,主要仍是利用操做系统层面的进程间通讯原理来解决临界资源的抢占问题。比较常见的一种方法即是使用信号量(Semaphores)。

信号量在POSIX标准下有两种,分别为有名信号量和无名信号量。无名信号量一般保存在共享内存中,而有名信号量是与一个特定的文件名称相关联。信号量是一个整数变量,有计数信号量和二值信号量两种。对信号量的操做,主要是P操做(wait)和V操做(signal)。

  • P操做:先检查信号量的大小,若值大于零,则将信号量减1,同时进程得到共享资源的访问权限,继续执行;若小于或者等于零,则该进程被阻塞后,进入等待队列。

  • V操做:该操做将信号量的值加1,若是有进程阻塞着等待该信号量,那么其中一个进程将被唤醒。

举个例子,设信号量为1,当一个进程A在进入临界区以前,先进行P操做。发现值大于零,那么就将信号量减为0,进入临界区执行。此时,若另外一个进程B也要进去临界区,进行P操做,发现信号量等于0,则会被阻塞。当进程A退出临界区时,会进行V操做,将信号量的值加1,并唤醒阻塞的进程B。此时B就能够进入临界区了。

这种方式,其实和多线程环境下的加解锁很是相似。所以用信号量处理临界资源抢占,也能够简单地理解为对临界区进行加锁。

经过上面的一些了解,咱们能够归纳出解决互斥性问题,即资源抢占的基本方式为:

对共享资源的操做先后(进入退出临界区)加解锁,保证不一样线程或进程能够互斥有序的操做资源

加解锁方式,有显式的加解锁,如ReentrantLock或信号量;也有隐式的加解锁,如synchronized。那么在分布式环境中,为了保证不一样JVM不一样主机间不会出现资源抢占,那么一样只要对临界区加解锁就能够了。

然而在多线程和多进程中,锁已经有比较完善的实现,直接使用便可。可是在分布式环境下,就须要咱们本身来实现分布式锁。

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

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

分布式锁条件

基本条件

再回顾下多线程和多进程环境下的锁,能够发现锁的实现有不少共通之处,它们都须要知足一些最基本的条件:

  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形成更大的负荷。

保留以上全部问题和条件,咱们接下来看一些比较典型的实现方案。搜索Java知音公众号,回复“后端面试”,送你一份Java面试题宝典

典型实现

ZooKeeper的实现

ZooKeeper(如下简称“ZK”)中有一种节点叫作顺序节点,假如咱们在/lock/目录下建立3个节点,ZK集群会按照发起建立的顺序来建立节点,节点分别为/lock/000000000一、/lock/000000000二、/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个版本,前后在美团点评多个项目中稳定运行。搜索Java知音公众号,回复“后端面试”,送你一份Java面试题宝典

幂等性问题

所谓幂等,简单地说,就是对接口的屡次调用所产生的结果和调用一次是一致的。扩展一下,这里的接口,能够理解为对外发布的HTTP接口或者Thrift接口,也能够是接收消息的内部接口,甚至是一个内部方法或操做。

那么咱们为何须要接口具备幂等性呢?设想一下如下情形:

  1. 在App中下订单的时候,点击确认以后,没反应,就又点击了几回。在这种状况下,若是没法保证该接口的幂等性,那么将会出现重复下单问题。
  2. 在接收消息的时候,消息推送重复。若是处理消息的接口没法保证幂等,那么重复消费消息产生的影响可能会很是大。

在分布式环境中,网络环境更加复杂,因前端操做抖动、网络故障、消息重复、响应速度慢等缘由,对接口的重复调用几率会比集中式环境下更大,尤为是重复消息在分布式环境中很难避免。Tyler Treat也在《You Cannot Have Exactly-Once Delivery》一文中提到:

Within the context of a distributed system, you cannot have exactly-once message delivery.

分布式环境中,有些接口是自然保证幂等性的,如查询操做。有些对数据的修改是一个常量,而且无其余记录和操做,那也能够说是具备幂等性的。其余状况下,全部涉及对数据的修改、状态的变动就都有必要防止重复性操做的发生。经过间接的实现接口的幂等性来防止重复操做所带来的影响,成为了一种有效的解决方案。

GTIS

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的内部实现流程为:

  1. 业务方在业务操做以前,生成一个可以惟一标识该操做的transContents,传入GTIS;
  2. GTIS根据传入的transContents,用MD5生成全局ID;
  3. GTIS将全局ID做为key,current_timestamp+transContents做为value放入Tair进行setNx,将结果返回给业务方;
  4. 业务方根据返回结果肯定可否开始进行业务操做;
  5. 若能,开始进行操做;若不能,则结束当前操做;
  6. 业务方将操做结果和请求结果传入GTIS,系统进行一次请求结果的检验;
  7. 若该次操做成功,GTIS根据key取出value值,跟传入的返回结果进行比对,若是二者相等,则将该全局ID的过时时间改成较长时间;
  8. GTIS返回最终结果

实现难点

GTIS的实现难点在于如何保证其判断重复的可靠性。因为分布式环境的复杂度和业务操做的不肯定性,在上一章节分布式锁的实现中考虑的网络断开或主机宕机等问题,一样须要在GTIS中设法解决。这里列出几个典型的场景:

  1. 若是操做执行失败,理想的状况应该是另外一个相同的操做能够当即进行。所以,须要对业务方的操做结果进行判断,若是操做失败,那么就须要当即删除该全局ID;
  2. 若是操做超时或主机宕机,当前的操做没法告知GTIS操做是否成功。那么咱们必须引入超时机制,一旦长时间获取不到业务方的操做反馈,那么也须要该全局ID失效;
  3. 结合上两个场景,既然全局ID会失效而且可能会被删除,那就须要保证删除的不是另外一个相同操做的全局ID。这就须要将特殊的标识记录下来,并由此来判断。这里所用的标识为当前时间戳。

能够看到,解决这些问题的思路,也和上一章节中的实现有不少相似的地方。除此之外,还有更多的场景须要考虑和解决,全部分支流程以下:

超长解析:一文带你分析与解决分布式系统互斥性与幂等性问题

使用说明

使用时,业务方只须要在操做的先后调用GTIS的前置方法和后置方法,以下图所示。若是前置方法返回可进行操做,则说明此时无重复操做,能够进行。不然则直接结束操做。

超长解析:一文带你分析与解决分布式系统互斥性与幂等性问题

使用方须要考虑的主要是下面两个参数:

  1. 空间全局性:业务方输入的可以标志操做惟一性的内容特性,能够是惟一性的String类型的ID,也能够是map、POJO等形式。如订单ID等
  2. 时间全局性:肯定在多长时间内不容许重复,1小时内仍是一个月内亦或是永久。

此外,GTIS还提供了不一样的故障处理策略和重试机制,以此来下降外部存储引擎异常对系统形成的影响。

目前,GTIS已经持续迭代了7个版本,距离第一个版本有近1年之久,前后在美团点评多个项目中稳定运行。

结语

在分布式环境中,操做互斥性问题和幂等性问题很是广泛。通过分析,咱们找出了解决这两个问题的基本思路和实现原理,给出了具体的解决方案。

针对操做互斥性问题,常见的作法即是经过分布式锁来处理对共享资源的抢占。分布式锁的实现,很大程度借鉴了多线程和多进程环境中的互斥锁的实现原理。只要知足一些存储方面的基本条件,而且可以解决如网络断开等异常状况,那么就能够实现一个分布式锁。

目前已经有基于Zookeeper和Redis等存储引擎的比较典型的分布式锁实现。可是因为单存储引擎的局限,咱们开发了基于ZooKeeper和Tair的多引擎分布式锁Cerberus,它具备使用灵活方便等诸多优势,还提供了完善的一键降级方案。

针对操做幂等性问题,咱们能够经过防止重复操做来间接的实现接口的幂等性。GTIS提供了一套可靠的解决方法:依赖于存储引擎,经过对不一样操做所对应的惟一的内容特性生成一个惟一的全局ID来防止操做重复。

目前Cerberus分布式锁、GTIS都已应用在生产环境并平稳运行。二者提供的解决方案已经可以解决大多数分布式环境中的操做互斥性和幂等性的问题。值得一提的是,分布式锁和GTIS都不是万能的,它们对外部存储系统的强依赖使得在环境不那么稳定的状况下,对可靠性会形成必定的影响。在并发量太高的状况下,若是不能很好的控制锁的粒度,那么使用分布式锁也是不太合适的。

总的来讲,分布式环境下的业务场景纷繁复杂,要解决互斥性和幂等性问题还须要结合当前系统架构、业务需求和将来演进综合考虑。Cerberus分布式锁和GTIS也会持续不断地迭代更新,提供更多的引擎选择、更高效可靠的实现方式、更简捷的接入流程,以期知足更复杂的使用场景和业务需求。

写在最后

欢迎你们关注个人公众号【风平浪静如码】,海量Java相关文章,学习资料都会在里面更新,整理的资料也会放在里面。

以为写的还不错的就点个赞,加个关注呗!点关注,不迷路,持续更新!!!

相关文章
相关标签/搜索