庆祝中国共产党成立100周年
已有60人围观 来源:架构师 发布于:2021-09-26 22:59:06
架构师(JiaGouX)
我们都是架构师!
架构未来,你来不来?



随着互联网信息技巧的飞速发展,数据量不断增大,业务逻辑也日趋庞杂,对体系的高并发拜访、海量数据处置的场景也越来越多。如何用较低成本实现体系的高可用、易伸缩、可扩大等目的就显得越发主要。

为懂得决这一系列问题,体系架构也在不断演进。传统的集中式体系已经逐渐无法满足要求,散布式体系被应用在更多的场景中。

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

  • 可扩大性:可通过横向水平扩大进步体系的性能和吞吐量。

  • 高可靠性:高容错,即使体系中一台或几台故障,体系仍可供给服务。

  • 高并发性:各机器并行独立处置和盘算。

  • 便宜高效:多台小型机而非单台高性能机。


然而,在散布式体系中,其环境的庞杂度、网络的不肯定性会造成诸如时钟不一致、“拜占庭将军问题”(Byzantine failure)等。存在于集中式体系中的机器宕机、资讯丧失等问题也会在散布式环境中变得更加庞杂。

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

  • 互斥性问题。

  • 幂等性问题。


今天我们就针对这两个问题来进行剖析。

互斥性问题

先看两个常见的例子:

例1:某服务记载症结数据X,当前值为100。A要求须要将X增长200;同时,B要求须要将X减100。

在幻想的情形下,A先读取到X=100,然后X增长200,最后写入X=300。B要求接着从读取X=300,减少100,最后写入X=200。

然而在真实情形下,如果不做任何处置,则可能会涌现: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造成更大的负荷。

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

典范实现

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接口,也可以是吸收资讯的内部接口,甚至是一个内部办法或操作。

那么我们为什么须要接口具有幂等性呢?假想一下以下情形:

  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也会连续不断地迭代更新,供给更多的引擎选择、更高效可靠的实现方法、更简捷的接入流程,以期满足更庞杂的应用处景和业务需求。

如爱好本文,请点击右上角,把文章分享到朋友圈
如有想懂得学习的技巧点,请留言给若飞支配分享

·END·

作者:zdy0_2004

起源:blog.csdn.net/zdy0_2004/article/details/52760404

版权声名:内容起源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告诉,我们会立即删除并表示歉意。谢谢!

架构师

我们都是架构师!



关注架构师(JiaGouX),添加“星标”

获取每天技巧干货,一起成为牛逼架构师

技巧群请加若飞:1321113940 进架构师群

投稿、合作、版权等邮箱:admin@137x.com