(轉)分佈式系統互斥性與冪等性問題的分析與解決

背景:在面試的時候常常被問到分佈式鎖 冪等性相關的知識點。本身竟然徹底不瞭解,還在簡歷中寫本身熟悉集羣,面試的時候可想而知。html

本文基於 美團技術團隊的總結。前端

分佈式系統互斥性與冪等性問題的分析與解決node

ps:這裏只作重點標識mysql

 

隨着互聯網信息技術的飛速發展,數據量不斷增大,業務邏輯也日趨複雜,對系統的高併發訪問、海量數據處理的場景也愈來愈多。如何用較低成本實現系統的高可用、易伸縮、可擴展等目標就顯得愈加重要。爲了解決這一系列問題,系統架構也在不斷演進。傳統的集中式系統已經逐漸沒法知足要求,分佈式系統被使用在更多的場景中。git

分佈式系統由獨立的服務器經過網絡鬆散耦合組成。在這個系統中每一個服務器都是一臺獨立的主機,服務器之間經過內部網絡鏈接。分佈式系統有如下幾個特色github

  • 可擴展性:可經過橫向水平擴展提升系統的性能和吞吐量。
  • 高可靠性:高容錯,即便系統中一臺或幾臺故障,系統仍可提供服務。
  • 高併發性:各機器並行獨立處理和計算。
  • 廉價高效:多臺小型機而非單臺高性能機。

然而,在分佈式系統中,其環境的複雜度、網絡的不肯定性會形成諸如時鐘不一致、「拜占庭將軍問題」(Byzantine failure)等。存在於集中式系統中的機器宕機、消息丟失等問題也會在分佈式環境中變得更加複雜。面試

基於分佈式系統的這些特徵,有兩種問題逐漸成爲了分佈式環境中須要重點關注和解決的典型問題:redis

  • 互斥性問題
  • 冪等性問題

今天咱們就針對這兩個問題來進行分析。sql

先看兩個常見的例子:數據庫

例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的使用

ps:優化措施:線程自旋和適應性自旋(自旋代替線程掛起,形成切換的開銷)、鎖消除(沒必要要的同步在編譯階段消除)、鎖粗化(加大鎖的粒度)、偏向鎖(消除沒必要要的cas)、輕量級鎖(cas替代)

多進程的解決方案

在多道程序系統中存在許多進程,它們共享各類資源,然而有不少資源一次只能供一個進程使用,這即是臨界資源。多進程中的臨界資源大體上能夠分爲兩類,一類是物理上的真實資源,如打印機;一類是硬盤或內存中的共享數據,如共享內存等。而進程內互斥訪問臨界資源的代碼被稱爲臨界區。

針對臨界資源的互斥訪問,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/000000000一、/lock/000000000二、/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(先寫新值,返回舊值,原子性操做,能夠用於分辨是否是首次操做)操做。

ps:

SETNX:key 不存在時,爲 key 設置指定的值,不然失敗。 

GETSET:返回給定 key 的舊值。 當 key 沒有舊值時,即 key 不存在時,返回 nil 。當 key 存在但不是字符串類型時,返回一個錯誤

爲了防止主機宕機或網絡斷開以後的死鎖,Redis沒有ZK那種自然的實現方式,只能依賴設置超時時間來規避。

如下是一種比較廣泛但不太完善的Redis分佈式鎖的實現步驟(與下圖一一對應): 1. 線程A發送SETNX lock.orderid 嘗試得到鎖,若是鎖不存在,則set並得到鎖。 2. 若是鎖存在,則再判斷鎖的值(時間戳)是否大於當前時間(GET?),若是沒有超時,則等待一下再重試。 3. 若是已經超時了,在用GETSET lock.{orderid} 來嘗試獲取鎖,若是這時候拿到的時間戳仍舊超時,則說明已經得到鎖了(GETSET獲取的舊值)。 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分佈式鎖將不一樣引擎的接口抽象爲一套,屏蔽了不一樣引擎的實現細節。使得使用方能夠專一於業務邏輯,也能夠任意選擇並切換引擎而沒必要更改任何的業務代碼。

若是使用方選擇了一種以上的引擎,那麼以配置順序來區分主副引擎。如下是使用主引擎的推薦:

功能需求 Tair ZK
併發量高  
響應時間敏感  
臨界區執行時間長  
公平鎖  
非公平鎖  
讀寫鎖  

特色二:使用靈活、學習成本低

下面是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

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中設法解決。這裏列出幾個典型的場景:

  • 若是操做執行失敗,理想的狀況應該是另外一個相同的操做能夠當即進行。所以,須要對業務方的操做結果進行判斷,若是操做失敗,那麼就須要當即刪除該全局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也會持續不斷地迭代更新,提供更多的引擎選擇、更高效可靠的實現方式、更簡捷的接入流程,以期知足更復雜的使用場景和業務需求。

相關文章
相關標籤/搜索