主頁 > 後端開發 > 老大難的分布式鎖與冪等性問題,如何解決?長文干貨!

老大難的分布式鎖與冪等性問題,如何解決?長文干貨!

2020-10-08 17:43:11 後端開發

作者: 蔣谞
https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html

隨著互聯網資訊技術的飛速發展,資料量不斷增大,業務邏輯也日趨復雜,對系統的高并發訪問、海量資料處理的場景也越來越多,如何用較低成本實作系統的高可用、易伸縮、可擴展等目標就顯得越發重要,

為了解決這一系列問題,系統架構也在不斷演進,傳統的集中式系統已經逐漸無法滿足要求,分布式系統被使用在更多的場景中,

分布式系統由獨立的服務器通過網路松散耦合組成,在這個系統中每個服務器都是一臺獨立的主機,服務器之間通過內部網路連接,分布式系統有以下幾個特點:

  • 可擴展性:可通過橫向水平擴展提高系統的性能和吞吐量,

  • 高可靠性:高容錯,即使系統中一臺或幾臺故障,系統仍可提供服務,

  • 高并發性:各機器并行獨立處理和計算,

  • 廉價高效:多臺小型機而非單臺高性能機,

然而,在分布式系統中,其環境的復雜度、網路的不確定性會造成諸如時鐘不一致、“拜占庭將軍問題”(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,Synchronized有幾種用法?不同的執行緒之間對同一資源進行搶占,該資源通常表現為某個類的普通成員變數,因此,利用ReentrantLock或者synchronized將共享的變數及其操作鎖住,即可基本解決資源搶占的問題,關注Java技術堆疊微信公眾號,在后臺回復關鍵字:多執行緒,可以獲取更多堆疊長整理的多執行緒系列技術干貨,多執行緒通信的三大法器,你真的會用嗎?

下面來簡單聊一聊兩者的實作原理,

原理

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為臨時順序節點,Zookeeper 集群安裝配置!

根據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可以公平的進行喚醒,配合回圈判斷前序節點,實作了一個公平鎖,關注Java技術堆疊微信公眾號,在后臺回復關鍵字:zookeeper,可以獲取更多堆疊長整理的zk系列技術干貨,

這種實作方式非常類似于ReentrantLock的CHL佇列,而且zk的臨時節點可以直接避免網路斷開或主機宕機,鎖狀態無法清除的問題,順序節點可以避免驚群效應,這些特性都使得利用ZK實作分布式鎖成為了最普遍的方案之一,

Redis的實作

Redis的分布式快取特性使其成為了分布式鎖的一種基礎實作,通過Redis中是否存在某個鎖ID,則可以判斷是否上鎖,為了保證判斷鎖是否存在的原子性,保證只有一個執行緒獲取同一把鎖,Redis有SETNX(即SET if Not
eXists)和GETSET(先寫新值,回傳舊值,原子性操作,可以用于分辨是不是首次操作)操作,在Java技術堆疊微信公眾號后臺回復關鍵字:redis,可以獲取更多堆疊長整理的redis系列技術干貨,

為了防止主機宕機或網路斷開之后的死鎖,Redis沒有ZK那種天然的實作方式,只能依賴設定超時時間來規避,這可能是史上最全 Redis 高可用解決方案總結,

以下是一種比較普遍但不太完善的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.

分布式環境中,有些介面是天然保證冪等性的,如查詢操作,有些對資料的修改是一個常量,并且無其他記錄和操作,那也可以說是具有冪等性的,其他情況下,所有涉及對資料的修改、狀態的變更就都有必要防止重復性操作的發生,通過間接的實作介面的冪等性來防止重復操作所帶來的影響,成為了一種有效的解決方案,關注Java技術堆疊微信公眾號,在后臺回復關鍵字:分布式,可以獲取更多堆疊長整理的分布式架構系列技術干貨,

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技術堆疊微信公眾號后臺回復關鍵字:分布式,可以獲取更多堆疊長整理的 分布式架構系列技術干貨,

推薦去我的博客閱讀更多:

1.Java JVM、集合、多執行緒、新特性系列教程

2.Spring MVC、Spring Boot、Spring Cloud 系列教程

3.Maven、Git、Eclipse、Intellij IDEA 系列工具教程

4.Java、后端、架構、阿里巴巴等大廠最新面試題

覺得不錯,別忘了點贊+轉發哦!

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/163260.html

標籤:Java

上一篇:最后一面掛在volatile關鍵字上,面試官:重新學學Java吧!

下一篇:學歷不夠技術來湊,大專生逆襲進阿里拿百萬年薪

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 【C++】Microsoft C++、C 和匯編程式檔案

    ......

    uj5u.com 2020-09-10 00:57:23 more
  • 例外宣告

    相比于斷言適用于排除邏輯上不可能存在的狀態,例外通常是用于邏輯上可能發生的錯誤。 例外宣告 Item 1:當函式不可能拋出例外或不能接受拋出例外時,使用noexcept 理由 如果不打算拋出例外的話,程式就會認為無法處理這種錯誤,并且應當盡早終止,如此可以有效地阻止例外的傳播與擴散。 示例 //不可 ......

    uj5u.com 2020-09-10 00:57:27 more
  • Codeforces 1400E Clear the Multiset(貪心 + 分治)

    鏈接:https://codeforces.com/problemset/problem/1400/E 來源:Codeforces 思路:給你一個陣列,現在你可以進行兩種操作,操作1:將一段沒有 0 的區間進行減一的操作,操作2:將 i 位置上的元素歸零。最終問:將這個陣列的全部元素歸零后操作的最少 ......

    uj5u.com 2020-09-10 00:57:30 more
  • UVA11610 【Reverse Prime】

    本人看到此題沒有翻譯,就附帶了一個自己的翻譯版本 思考 這一題,它的第一個要求是找出所有 $7$ 位反向質數及其質因數的個數。 我們應該需要質數篩篩選1~$10^{7}$的所有數,這里就不慢慢介紹了。但是,重讀題,我們突然發現反向質數都是 $7$ 位,而將它反過來后的數字卻是 $6$ 位數,這就說明 ......

    uj5u.com 2020-09-10 00:57:36 more
  • 統計區間素數數量

    1 #pragma GCC optimize(2) 2 #include <bits/stdc++.h> 3 using namespace std; 4 bool isprime[1000000010]; 5 vector<int> prime; 6 inline int getlist(int ......

    uj5u.com 2020-09-10 00:57:47 more
  • C/C++編程筆記:C++中的 const 變數詳解,教你正確認識const用法

    1、C中的const 1、區域const變數存放在堆疊區中,會分配記憶體(也就是說可以通過地址間接修改變數的值)。測驗代碼如下: 運行結果: 2、全域const變數存放在只讀資料段(不能通過地址修改,會發生寫入錯誤), 默認為外部聯編,可以給其他源檔案使用(需要用extern關鍵字修飾) 運行結果: ......

    uj5u.com 2020-09-10 00:58:04 more
  • 【C++犯錯記錄】VS2019 MFC添加資源不懂如何修改資源宏ID

    1. 首先在資源視圖中,添加資源 2. 點擊新添加的資源,復制自動生成的ID 3. 在解決方案資源管理器中找到Resource.h檔案,編輯,使用整個專案搜索和替換的方式快速替換 宏宣告 4. Ctrl+Shift+F 全域搜索,點擊查找全部,然后逐個替換 5. 為什么使用搜索替換而不使用屬性視窗直 ......

    uj5u.com 2020-09-10 00:59:11 more
  • 【C++犯錯記錄】VS2019 MFC不懂的批量添加資源

    1. 打開資源頭檔案Resource.h,在其中預先定義好宏 ID(不清楚其實ID值應該設定多少,可以先新建一個相同的資源項,再在這個資源的ID值的基礎上遞增即可) 2. 在資源視圖中選中專案資源,按F7編輯資源檔案,按 ID 型別 相對路徑的形式添加 資源。(別忘了先把檔案拷貝到專案中的res檔案 ......

    uj5u.com 2020-09-10 01:00:19 more
  • C/C++編程筆記:關于C++的參考型別,專供新手入門使用

    今天要講的是C++中我最喜歡的一個用法——參考,也叫別名。 參考就是給一個變數名取一個變數名,方便我們間接地使用這個變數。我們可以給一個變數創建N個參考,這N + 1個變數共享了同一塊記憶體區域。(參考型別的變數會占用記憶體空間,占用的記憶體空間的大小和指標型別的大小是相同的。雖然參考是一個物件的別名,但 ......

    uj5u.com 2020-09-10 01:00:22 more
  • 【C/C++編程筆記】從頭開始學習C ++:初學者完整指南

    眾所周知,C ++的學習曲線陡峭,但是花時間學習這種語言將為您的職業帶來奇跡,并使您與其他開發人員區分開。您會更輕松地學習新語言,形成真正的解決問題的技能,并在編程的基礎上打下堅實的基礎。 C ++將幫助您養成良好的編程習慣(即清晰一致的編碼風格,在撰寫代碼時注釋代碼,并限制類內部的可見性),并且由 ......

    uj5u.com 2020-09-10 01:00:41 more
最新发布
  • Rust中的智能指標:Box<T> Rc<T> Arc<T> Cell<T> RefCell<T> Weak

    Rust中的智能指標是什么 智能指標(smart pointers)是一類資料結構,是擁有資料所有權和額外功能的指標。是指標的進一步發展 指標(pointer)是一個包含記憶體地址的變數的通用概念。這個地址參考,或 ” 指向”(points at)一些其 他資料 。參考以 & 符號為標志并借用了他們所 ......

    uj5u.com 2023-04-20 07:24:10 more
  • Java的值傳遞和參考傳遞

    值傳遞不會改變本身,參考傳遞(如果傳遞的值需要實體化到堆里)如果發生修改了會改變本身。 1.基本資料型別都是值傳遞 package com.example.basic; public class Test { public static void main(String[] args) { int ......

    uj5u.com 2023-04-20 07:24:04 more
  • [2]SpinalHDL教程——Scala簡單入門

    第一個 Scala 程式 shell里面輸入 $ scala scala> 1 + 1 res0: Int = 2 scala> println("Hello World!") Hello World! 檔案形式 object HelloWorld { /* 這是我的第一個 Scala 程式 * 以 ......

    uj5u.com 2023-04-20 07:23:58 more
  • 理解函式指標和回呼函式

    理解 函式指標 指向函式的指標。比如: 理解函式指標的偽代碼 void (*p)(int type, char *data); // 定義一個函式指標p void func(int type, char *data); // 宣告一個函式func p = func; // 將指標p指向函式func ......

    uj5u.com 2023-04-20 07:23:52 more
  • Django筆記二十五之資料庫函式之日期函式

    本文首發于公眾號:Hunter后端 原文鏈接:Django筆記二十五之資料庫函式之日期函式 日期函式主要介紹兩個大類,Extract() 和 Trunc() Extract() 函式作用是提取日期,比如我們可以提取一個日期欄位的年份,月份,日等資料 Trunc() 的作用則是截取,比如 2022-0 ......

    uj5u.com 2023-04-20 07:23:45 more
  • 一天吃透JVM面試八股文

    什么是JVM? JVM,全稱Java Virtual Machine(Java虛擬機),是通過在實際的計算機上仿真模擬各種計算機功能來實作的。由一套位元組碼指令集、一組暫存器、一個堆疊、一個垃圾回收堆和一個存盤方法域等組成。JVM屏蔽了與作業系統平臺相關的資訊,使得Java程式只需要生成在Java虛擬機 ......

    uj5u.com 2023-04-20 07:23:31 more
  • 使用Java接入小程式訂閱訊息!

    更新完微信服務號的模板訊息之后,我又趕緊把微信小程式的訂閱訊息給實作了!之前我一直以為微信小程式也是要企業才能申請,沒想到小程式個人就能申請。 訊息推送平臺🔥推送下發【郵件】【短信】【微信服務號】【微信小程式】【企業微信】【釘釘】等訊息型別。 https://gitee.com/zhongfuch ......

    uj5u.com 2023-04-20 07:22:59 more
  • java -- 緩沖流、轉換流、序列化流

    緩沖流 緩沖流, 也叫高效流, 按照資料型別分類: 位元組緩沖流:BufferedInputStream,BufferedOutputStream 字符緩沖流:BufferedReader,BufferedWriter 緩沖流的基本原理,是在創建流物件時,會創建一個內置的默認大小的緩沖區陣列,通過緩沖 ......

    uj5u.com 2023-04-20 07:22:49 more
  • Java-SpringBoot-Range請求頭設定實作視頻分段傳輸

    老實說,人太懶了,現在基本都不喜歡寫筆記了,但是網上有關Range請求頭的文章都太水了 下面是抄的一段StackOverflow的代碼...自己大修改過的,寫的注釋挺全的,應該直接看得懂,就不解釋了 寫的不好...只是希望能給視頻網站開發的新手一點點幫助吧. 業務場景:視頻分段傳輸、視頻多段傳輸(理 ......

    uj5u.com 2023-04-20 07:22:42 more
  • Windows 10開發教程_編程入門自學教程_菜鳥教程-免費教程分享

    教程簡介 Windows 10開發入門教程 - 從簡單的步驟了解Windows 10開發,從基本到高級概念,包括簡介,UWP,第一個應用程式,商店,XAML控制元件,資料系結,XAML性能,自適應設計,自適應UI,自適應代碼,檔案管理,SQLite資料庫,應用程式到應用程式通信,應用程式本地化,應用程式 ......

    uj5u.com 2023-04-20 07:22:35 more