超長解析:一文帶你分析與解決分布式系統(tǒng)互斥性與冪等性問題

隨著互聯(lián)網信息技術的飛速發(fā)展颗祝,數據量不斷增大,業(yè)務邏輯也日趨復雜泌枪,對系統(tǒng)的高并發(fā)訪問概荷、海量數據處理的場景也越來越多。如何用較低成本實現(xiàn)系統(tǒng)的高可用碌燕、易伸縮误证、可擴展等目標就顯得越發(fā)重要。

為了解決這一系列問題陆蟆,系統(tǒng)架構也在不斷演進雷厂。傳統(tǒng)的集中式系統(tǒng)已經逐漸無法滿足要求惋增,分布式系統(tǒng)被使用在更多的場景中叠殷。

分布式系統(tǒng)由獨立的服務器通過網絡松散耦合組成。在這個系統(tǒng)中每個服務器都是一臺獨立的主機诈皿,服務器之間通過內部網絡連接林束。分布式系統(tǒng)有以下幾個特點:

  • 可擴展性:可通過橫向水平擴展提高系統(tǒng)的性能和吞吐量。
  • 高可靠性:高容錯稽亏,即使系統(tǒng)中一臺或幾臺故障壶冒,系統(tǒng)仍可提供服務。
  • 高并發(fā)性:各機器并行獨立處理和計算截歉。
  • 廉價高效:多臺小型機而非單臺高性能機胖腾。

然而,在分布式系統(tǒng)中瘪松,其環(huán)境的復雜度咸作、網絡的不確定性會造成諸如時鐘不一致、“拜占庭將軍問題”(Byzantine failure)等宵睦。存在于集中式系統(tǒng)中的機器宕機记罚、消息丟失等問題也會在分布式環(huán)境中變得更加復雜。

基于分布式系統(tǒng)的這些特征壳嚎,有兩種問題逐漸成為了分布式環(huán)境中需要重點關注和解決的典型問題:

  • 互斥性問題桐智。
  • 冪等性問題。

今天我們就針對這兩個問題來進行分析烟馅。

互斥性問題

先看兩個常見的例子:

例1:某服務記錄關鍵數據X说庭,當前值為100。A請求需要將X增加200郑趁;同時刊驴,B請求需要將X減100。

在理想的情況下穿撮,A先讀取到X=100缺脉,然后X增加200痪欲,最后寫入X=300。B請求接著從讀取X=300攻礼,減少100业踢,最后寫入X=200。

然而在真實情況下礁扮,如果不做任何處理知举,則可能會出現(xiàn):A和B同時讀取到X=100;A寫入之前B讀取到X太伊;B比A先寫入等情況雇锡。

例2:某服務提供一組任務,A請求隨機從任務組中獲取一個任務僚焦;B請求隨機從任務組中獲取一個任務锰提。

在理想的情況下,A從任務組中挑選一個任務芳悲,任務組刪除該任務立肘,B從剩下的的任務中再挑一個,任務組刪除該任務名扛。

同樣的谅年,在真實情況下,如果不做任何處理肮韧,可能會出現(xiàn)A和B挑中了同一個任務的情況融蹂。

以上的兩個例子,都存在操作互斥性的問題弄企〕迹互斥性問題用通俗的話來講,就是對共享資源的搶占問題桩蓉。如果不同的請求對同一個或者同一組資源讀取并修改時淋纲,無法保證按序執(zhí)行,無法保證一個操作的原子性院究,那么就很有可能會出現(xiàn)預期外的情況洽瞬。因此操作的互斥性問題,也可以理解為一個需要保證時序性业汰、原子性的問題伙窃。

在傳統(tǒng)的基于數據庫的架構中,對于數據的搶占問題往往是通過數據庫事務(ACID)來保證的样漆。在分布式環(huán)境中为障,出于對性能以及一致性敏感度的要求,使得分布式鎖成為了一種比較常見而高效的解決方案。

事實上鳍怨,操作互斥性問題也并非分布式環(huán)境所獨有呻右,在傳統(tǒng)的多線程、多進程情況下已經有了很好的解決方案鞋喇。因此在研究分布式鎖之前声滥,我們先來分析下這兩種情況的解決方案,以期能夠對分布式鎖的解決方案提供一些實現(xiàn)思路侦香。

多線程環(huán)境解決方案及原理

解決方案

《Thinking in Java》書中寫到:

基本上所有的并發(fā)模式在解決線程沖突問題的時候落塑,都是采用序列化訪問共享資源的方案。

在多線程環(huán)境中罐韩,線程之間因為公用一些存儲空間憾赁,沖突問題時有發(fā)生。解決沖突問題最普遍的方式就是用互斥鎖把該資源或對該資源的操作保護起來散吵。

Java JDK中提供了兩種互斥鎖Lock和synchronized龙考。不同的線程之間對同一資源進行搶占,該資源通常表現(xiàn)為某個類的普通成員變量错蝴。因此洲愤,利用ReentrantLock或者synchronized將共享的變量及其操作鎖住颓芭,即可基本解決資源搶占的問題顷锰。

下面來簡單聊一聊兩者的實現(xiàn)原理。

原理

ReentrantLock

ReentrantLock主要利用CAS+CLH隊列來實現(xiàn)亡问。它支持公平鎖和非公平鎖官紫,兩者的實現(xiàn)類似。

  • CAS:Compare and Swap州藕,比較并交換束世。CAS有3個操作數:內存值V、預期值A床玻、要修改的新值B毁涉。當且僅當預期值A和內存值V相同時,將內存值V修改為B锈死,否則什么都不做贫堰。該操作是一個原子操作,被廣泛的應用在Java的底層實現(xiàn)中待牵。在Java中其屏,CAS主要是由sun.misc.Unsafe這個類通過JNI調用CPU底層指令實現(xiàn)。

  • CLH隊列:帶頭結點的雙向非循環(huán)鏈表(如下圖所示):

ReentrantLock的基本實現(xiàn)可以概括為:先通過CAS嘗試獲取鎖缨该。如果此時已經有線程占據了鎖偎行,那就加入CLH隊列并且被掛起。當鎖被釋放之后,排在CLH隊列隊首的線程會被喚醒蛤袒,然后CAS再次嘗試獲取鎖熄云。在這個時候,如果:

  • 非公平鎖:如果同時還有另一個線程進來嘗試獲取妙真,那么有可能會讓這個線程搶先獲戎宓狻;

  • 公平鎖:如果同時還有另一個線程進來嘗試獲取隐孽,當它發(fā)現(xiàn)自己不是在隊首的話癌椿,就會排到隊尾,由隊首的線程獲取到鎖菱阵。

下面分析下兩個片段:

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;
}

在嘗試獲取鎖的時候踢俄,會先調用上面的方法。如果狀態(tài)為0晴及,則表明此時無人占有鎖都办。此時嘗試進行set,一旦成功虑稼,則成功占有鎖琳钉。如果狀態(tài)不為0,再判斷是否是當前線程獲取到鎖蛛倦。如果是的話歌懒,將狀態(tài)+1,因為此時就是當前線程溯壶,所以不用CAS及皂。這也就是可重入鎖的實現(xiàn)原理。

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隊尾之后且改,如果發(fā)現(xiàn)前序節(jié)點是head验烧,則CAS再嘗試獲取一次。否則又跛,則會根據前序節(jié)點的狀態(tài)判斷是否需要阻塞宪塔。如果需要阻塞甘畅,則調用LockSupport的park方法阻塞該線程瘦黑。

synchronized

在Java語言中存在兩種內建的synchronized語法:synchronized語句竹伸、synchronized方法。

  • synchronized語句:當源代碼被編譯成字節(jié)碼的時候菌仁,會在同步塊的入口位置和退出位置分別插入monitorenter和monitorexit字節(jié)碼指令;

  • synchronized方法:在Class文件的方法表中將該方法的access_flags字段中的synchronized標志位置1浩习。這個在specification中沒有明確說明。

在Java虛擬機的specification中济丘,有關于monitorenter和monitorexit字節(jié)碼指令的詳細描述:

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.

每個對象都有一個鎖洽蛀,也就是監(jiān)視器(monitor)。當monitor被占有時就表示它被鎖定疟赊。線程執(zhí)行monitorenter指令時嘗試獲取對象所對應的monitor的所有權郊供,過程如下:

  • 如果monitor的進入數為0,則該線程進入monitor近哟,然后將進入數設置為1驮审,該線程即為monitor的所有者;
  • 如果線程已經擁有了該monitor,只是重新進入吉执,則進入monitor的進入數加1;
  • 如果其他線程已經占用了monitor疯淫,則該線程進入阻塞狀態(tài),直到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.

執(zhí)行monitorexit的線程必須是相應的monitor的所有者。

指令執(zhí)行時咕宿,monitor的進入數減1币绩,如果減1后進入數為0,那線程退出monitor府阀,不再是這個monitor的所有者缆镣。其他被這個monitor阻塞的線程可以嘗試去獲取這個monitor的所有權。

在JDK1.6及其之前的版本中monitorenter和monitorexit字節(jié)碼依賴于底層的操作系統(tǒng)的Mutex Lock來實現(xiàn)的试浙,但是由于使用Mutex Lock需要將當前線程掛起并從用戶態(tài)切換到內核態(tài)來執(zhí)行董瞻,這種切換的代價是非常昂貴的。然而在現(xiàn)實中的大部分情況下川队,同步方法是運行在單線程環(huán)境(無鎖競爭環(huán)境)力细。如果每次都調用Mutex Lock將嚴重的影響程序的性能。因此在JDK 1.6之后的版本中對鎖的實現(xiàn)做了大量的優(yōu)化固额,這些優(yōu)化在很大程度上減少或避免了Mutex Lock的使用。

多進程的解決方案

在多道程序系統(tǒng)中存在許多進程煞聪,它們共享各種資源斗躏,然而有很多資源一次只能供一個進程使用,這便是臨界資源昔脯。多進程中的臨界資源大致上可以分為兩類啄糙,一類是物理上的真實資源,如打印機云稚;一類是硬盤或內存中的共享數據隧饼,如共享內存等。而進程內互斥訪問臨界資源的代碼被稱為臨界區(qū)静陈。

針對臨界資源的互斥訪問燕雁,JVM層面的鎖就已經失去效力了诞丽。在多進程的情況下,主要還是利用操作系統(tǒng)層面的進程間通信原理來解決臨界資源的搶占問題拐格。比較常見的一種方法便是使用信號量(Semaphores)僧免。

信號量在POSIX標準下有兩種,分別為有名信號量和無名信號量捏浊。無名信號量通常保存在共享內存中懂衩,而有名信號量是與一個特定的文件名稱相關聯(lián)。信號量是一個整數變量金踪,有計數信號量和二值信號量兩種浊洞。對信號量的操作,主要是P操作(wait)和V操作(signal)胡岔。

  • P操作:先檢查信號量的大小沛申,若值大于零,則將信號量減1姐军,同時進程獲得共享資源的訪問權限铁材,繼續(xù)執(zhí)行;若小于或者等于零奕锌,則該進程被阻塞后著觉,進入等待隊列。

  • V操作:該操作將信號量的值加1惊暴,如果有進程阻塞著等待該信號量饼丘,那么其中一個進程將被喚醒。

舉個例子辽话,設信號量為1肄鸽,當一個進程A在進入臨界區(qū)之前,先進行P操作油啤。發(fā)現(xiàn)值大于零典徘,那么就將信號量減為0,進入臨界區(qū)執(zhí)行益咬。此時逮诲,若另一個進程B也要進去臨界區(qū),進行P操作幽告,發(fā)現(xiàn)信號量等于0梅鹦,則會被阻塞。當進程A退出臨界區(qū)時冗锁,會進行V操作齐唆,將信號量的值加1,并喚醒阻塞的進程B冻河。此時B就可以進入臨界區(qū)了箍邮。

這種方式茉帅,其實和多線程環(huán)境下的加解鎖非常類似。因此用信號量處理臨界資源搶占媒殉,也可以簡單地理解為對臨界區(qū)進行加鎖担敌。

通過上面的一些了解,我們可以概括出解決互斥性問題廷蓉,即資源搶占的基本方式為:

對共享資源的操作前后(進入退出臨界區(qū))加解鎖全封,保證不同線程或進程可以互斥有序的操作資源

加解鎖方式桃犬,有顯式的加解鎖刹悴,如ReentrantLock或信號量;也有隱式的加解鎖攒暇,如synchronized土匀。那么在分布式環(huán)境中,為了保證不同JVM不同主機間不會出現(xiàn)資源搶占形用,那么同樣只要對臨界區(qū)加解鎖就可以了就轧。

然而在多線程和多進程中,鎖已經有比較完善的實現(xiàn)田度,直接使用即可妒御。但是在分布式環(huán)境下,就需要我們自己來實現(xiàn)分布式鎖镇饺。

分布式環(huán)境下的解決方案——分布式鎖

首先乎莉,我們來看看分布式鎖的基本條件。

分布式鎖條件

基本條件

再回顧下多線程和多進程環(huán)境下的鎖奸笤,可以發(fā)現(xiàn)鎖的實現(xiàn)有很多共通之處惋啃,它們都需要滿足一些最基本的條件:

  1. 需要有存儲鎖的空間,并且鎖的空間是可以訪問到的监右。
  2. 鎖需要被唯一標識边灭。
  3. 鎖要有至少兩種狀態(tài)。

仔細分析這三個條件:

存儲空間

鎖是一個抽象的概念秸侣,鎖的實現(xiàn)存筏,需要依存于一個可以存儲鎖的空間。在多線程中是內存味榛,在多進程中是內存或者磁盤。更重要的是予跌,這個空間是可以被訪問到的搏色。多線程中,不同的線程都可以訪問到堆中的成員變量券册;在多進程中频轿,不同的進程可以訪問到共享內存中的數據或者存儲在磁盤中的文件垂涯。但是在分布式環(huán)境中,不同的主機很難訪問對方的內存或磁盤航邢。這就需要一個都能訪問到的外部空間來作為存儲空間耕赘。

最普遍的外部存儲空間就是數據庫了,事實上也確實有基于數據庫做分布式鎖(行鎖膳殷、version樂觀鎖)操骡,如quartz集群架構中就有所使用。除此以外赚窃,還有各式緩存如Redis册招、Tair、Memcached勒极、MongoDB是掰,當然還有專門的分布式協(xié)調服務Zookeeper,甚至是另一臺主機辱匿。只要可以存儲數據键痛、鎖在其中可以被多主機訪問到,那就可以作為分布式鎖的存儲空間匾七。

唯一標識

不同的共享資源絮短,必然需要用不同的鎖進行保護,因此相應的鎖必須有唯一的標識乐尊。在多線程環(huán)境中戚丸,鎖可以是一個對象,那么對這個對象的引用便是這個唯一標識扔嵌。多進程環(huán)境中限府,信號量在共享內存中也是由引用來作為唯一的標識。但是如果不在內存中痢缎,失去了對鎖的引用胁勺,如何唯一標識它呢?上文提到的有名信號量独旷,便是用硬盤中的文件名作為唯一標識署穗。因此,在分布式環(huán)境中嵌洼,只要給這個鎖設定一個名稱案疲,并且保證這個名稱是全局唯一的,那么就可以作為唯一標識麻养。

至少兩種狀態(tài)

為了給臨界區(qū)加鎖和解鎖褐啡,需要存儲兩種不同的狀態(tài)。如ReentrantLock中的status鳖昌,0表示沒有線程競爭备畦,大于0表示有線程競爭低飒;信號量大于0表示可以進入臨界區(qū),小于等于0則表示需要被阻塞懂盐。因此只要在分布式環(huán)境中褥赊,鎖的狀態(tài)有兩種或以上:如有鎖、沒鎖莉恼;存在拌喉、不存在等,均可以實現(xiàn)类垫。

有了這三個條件司光,基本就可以實現(xiàn)一個簡單的分布式鎖了。下面以數據庫為例悉患,實現(xiàn)一個簡單的分布式鎖:
數據庫表残家,字段為鎖的ID(唯一標識),鎖的狀態(tài)(0表示沒有被鎖售躁,1表示被鎖)坞淮。

偽代碼為:

lock = mysql.get(id);
while(lock.status == 1) {
   sleep(100);
}
mysql.update(lock.status = 1);
doSomething();
mysql.update(lock.status = 0);

問題

以上的方式即可以實現(xiàn)一個粗糙的分布式鎖,但是這樣的實現(xiàn)陪捷,有沒有什么問題呢回窘?

問題1:鎖狀態(tài)判斷原子性無法保證

從讀取鎖的狀態(tài),到判斷該狀態(tài)是否為被鎖市袖,需要經歷兩步操作啡直。如果不能保證這兩步的原子性,就可能導致不止一個請求獲取到了鎖苍碟,這顯然是不行的酒觅。因此,我們需要保證鎖狀態(tài)判斷的原子性微峰。

問題2:網絡斷開或主機宕機舷丹,鎖狀態(tài)無法清除

假設在主機已經獲取到鎖的情況下,突然出現(xiàn)了網絡斷開或者主機宕機蜓肆,如果不做任何處理該鎖將仍然處于被鎖定的狀態(tài)颜凯。那么之后所有的請求都無法再成功搶占到這個鎖。因此仗扬,我們需要在持有鎖的主機宕機或者網絡斷開的時候症概,及時的釋放掉這把鎖。

問題3:無法保證釋放的是自己上鎖的那把鎖

在解決了問題2的情況下再設想一下早芭,假設持有鎖的主機A在臨界區(qū)遇到網絡抖動導致網絡斷開穴豫,分布式鎖及時的釋放掉了這把鎖。之后逼友,另一個主機B占有了這把鎖精肃,但是此時主機A網絡恢復,退出臨界區(qū)時解鎖帜乞。由于都是同一把鎖司抱,所以A就會將B的鎖解開。此時如果有第三個主機嘗試搶占這把鎖黎烈,也將會成功獲得习柠。因此,我們需要在解鎖時照棋,確定自己解的這個鎖正是自己鎖上的资溃。

進階條件

如果分布式鎖的實現(xiàn),還能再解決上面的三個問題烈炭,那么就可以算是一個相對完整的分布式鎖了溶锭。然而井辆,在實際的系統(tǒng)環(huán)境中惜纸,還會對分布式鎖有更高級的要求。

  1. 可重入:線程中的可重入卡辰,指的是外層函數獲得鎖之后霹疫,內層也可以獲得鎖拱绑,ReentrantLock和synchronized都是可重入鎖;衍生到分布式環(huán)境中丽蝎,一般仍然指的是線程的可重入猎拨,在絕大多數分布式環(huán)境中,都要求分布式鎖是可重入的屠阻。
  2. 驚群效應(Herd Effect):在分布式鎖中红省,驚群效應指的是,在有多個請求等待獲取鎖的時候栏笆,一旦占有鎖的線程釋放之后类腮,如果所有等待的方都同時被喚醒,嘗試搶占鎖蛉加。但是這樣的情況會造成比較大的開銷蚜枢,那么在實現(xiàn)分布式鎖的時候,應該盡量避免驚群效應的產生针饥。
  3. 公平鎖和非公平鎖:不同的需求厂抽,可能需要不同的分布式鎖。非公平鎖普遍比公平鎖開銷小丁眼。但是業(yè)務需求如果必須要鎖的競爭者按順序獲得鎖筷凤,那么就需要實現(xiàn)公平鎖。
  4. 阻塞鎖和自旋鎖:針對不同的使用場景,阻塞鎖和自旋鎖的效率也會有所不同藐守。阻塞鎖會有上下文切換挪丢,如果并發(fā)量比較高且臨界區(qū)的操作耗時比較短,那么造成的性能開銷就比較大了卢厂。但是如果臨界區(qū)操作耗時比較長乾蓬,一直保持自旋,也會對CPU造成更大的負荷慎恒。

保留以上所有問題和條件任内,我們接下來看一些比較典型的實現(xiàn)方案。搜索Java知音公眾號融柬,回復“后端面試”死嗦,送你一份Java面試題寶典

典型實現(xiàn)

ZooKeeper的實現(xiàn)

ZooKeeper(以下簡稱“ZK”)中有一種節(jié)點叫做順序節(jié)點,假如我們在/lock/目錄下創(chuàng)建3個節(jié)點粒氧,ZK集群會按照發(fā)起創(chuàng)建的順序來創(chuàng)建節(jié)點越除,節(jié)點分別為/lock/0000000001、/lock/0000000002靠欢、/lock/0000000003廊敌。

ZK中還有一種名為臨時節(jié)點的節(jié)點,臨時節(jié)點由某個客戶端創(chuàng)建门怪,當客戶端與ZK集群斷開連接骡澈,則該節(jié)點自動被刪除。EPHEMERAL_SEQUENTIAL為臨時順序節(jié)點掷空。

根據ZK中節(jié)點是否存在肋殴,可以作為分布式鎖的鎖狀態(tài),以此來實現(xiàn)一個分布式鎖坦弟,下面是分布式鎖的基本邏輯:

  • 客戶端調用create()方法創(chuàng)建名為“/dlm-locks/lockname/lock-”的臨時順序節(jié)點护锤。

  • 客戶端調用getChildren(“l(fā)ockname”)方法來獲取所有已經創(chuàng)建的子節(jié)點。

  • 客戶端獲取到所有子節(jié)點path之后酿傍,如果發(fā)現(xiàn)自己在步驟1中創(chuàng)建的節(jié)點是所有節(jié)點中序號最小的烙懦,那么就認為這個客戶端獲得了鎖。

  • 如果創(chuàng)建的節(jié)點不是所有節(jié)點中需要最小的赤炒,那么則監(jiān)視比自己創(chuàng)建節(jié)點的序列號小的最大的節(jié)點氯析,進入等待。直到下次監(jiān)視的子節(jié)點變更的時候莺褒,再進行子節(jié)點的獲取掩缓,判斷是否獲取鎖。

釋放鎖的過程相對比較簡單遵岩,就是刪除自己創(chuàng)建的那個子節(jié)點即可你辣,不過也仍需要考慮刪除節(jié)點失敗等異常情況。

開源的基于ZK的Menagerie的源碼就是一個典型的例子:

https://github.com/sfines/menagerie

Menagerie中的lock首先實現(xiàn)了可重入鎖舍哄,利用ThreadLocal存儲進入的次數宴凉,每次加鎖次數加1,每次解鎖次數減1蠢熄。如果判斷出是當前線程持有鎖跪解,就不用走獲取鎖的流程。

通過tryAcquireDistributed方法嘗試獲取鎖签孔,循環(huán)判斷前序節(jié)點是否存在,如果存在則監(jiān)視該節(jié)點并且返回獲取失敗窘行。如果前序節(jié)點不存在饥追,則再判斷更前一個節(jié)點。如果判斷出自己是第一個節(jié)點罐盔,則返回獲取成功但绕。

為了在別的線程占有鎖的時候阻塞,代碼中使用JUC的condition來完成惶看。如果獲取嘗試鎖失敗捏顺,則進入等待且放棄localLock,等待前序節(jié)點喚醒纬黎。而localLock是一個本地的公平鎖幅骄,使得condition可以公平的進行喚醒,配合循環(huán)判斷前序節(jié)點本今,實現(xiàn)了一個公平鎖拆座。

這種實現(xiàn)方式非常類似于ReentrantLock的CHL隊列,而且zk的臨時節(jié)點可以直接避免網絡斷開或主機宕機冠息,鎖狀態(tài)無法清除的問題挪凑,順序節(jié)點可以避免驚群效應。這些特性都使得利用ZK實現(xiàn)分布式鎖成為了最普遍的方案之一逛艰。

Redis的實現(xiàn)

Redis的分布式緩存特性使其成為了分布式鎖的一種基礎實現(xiàn)躏碳。通過Redis中是否存在某個鎖ID,則可以判斷是否上鎖散怖。為了保證判斷鎖是否存在的原子性菇绵,保證只有一個線程獲取同一把鎖,Redis有SETNX(即SET if Not
eXists)和GETSET(先寫新值杭抠,返回舊值脸甘,原子性操作,可以用于分辨是不是首次操作)操作偏灿。

為了防止主機宕機或網絡斷開之后的死鎖丹诀,Redis沒有ZK那種天然的實現(xiàn)方式,只能依賴設置超時時間來規(guī)避。

以下是一種比較普遍但不太完善的Redis分布式鎖的實現(xiàn)步驟(與下圖一一對應):

  • 線程A發(fā)送SETNX lock.orderid嘗試獲得鎖铆遭,如果鎖不存在硝桩,則set并獲得鎖。

  • 如果鎖存在枚荣,則再判斷鎖的值(時間戳)是否大于當前時間碗脊,如果沒有超時,則等待一下再重試橄妆。

  • 如果已經超時了衙伶,在用GETSET lock.{orderid}來嘗試獲取鎖,如果這時候拿到的時間戳仍舊超時害碾,則說明已經獲得鎖了矢劲。

  • 如果在此之前,另一個線程C快一步執(zhí)行了上面的操作慌随,那么A拿到的時間戳是個未超時的值芬沉,這時A沒有如期獲得鎖,需要再次等待或重試阁猜。

該實現(xiàn)還有一個需要考慮的問題是全局時鐘問題丸逸,由于生產環(huán)境主機時鐘不能保證完全同步,對時間戳的判斷也可能會產生誤差剃袍。

以上是Redis的一種常見的實現(xiàn)方式黄刚,除此以外還可以用SETNX+EXPIRE來實現(xiàn)。Redisson是一個官方推薦的Redis客戶端并且實現(xiàn)了很多分布式的功能笛园。它的分布式鎖就提供了一種更完善的解決方案隘击,源碼:

https://github.com/mrniko/redisson

Tair的實現(xiàn)

Tair和Redis的實現(xiàn)類似研铆,Tair客戶端封裝了一個expireLock的方法:通過鎖狀態(tài)和過期時間戳來共同判斷鎖是否存在埋同,只有鎖已經存在且沒有過期的狀態(tài)才判定為有鎖狀態(tài)。在有鎖狀態(tài)下棵红,不能加鎖凶赁,能通過大于或等于過期時間的時間戳進行解鎖。

采用這樣的方式逆甜,可以不用在Value中存儲時間戳虱肄,并且保證了判斷是否有鎖的原子性。更值得注意的是交煞,由于超時時間是由Tair判斷咏窿,所以避免了不同主機時鐘不一致的情況。

以上的幾種分布式鎖實現(xiàn)方式素征,都是比較常見且有些已經在生產環(huán)境中應用集嵌。隨著應用環(huán)境越來越復雜萝挤,這些實現(xiàn)可能仍然會遇到一些挑戰(zhàn)。

強依賴于外部組件:分布式鎖的實現(xiàn)都需要依賴于外部數據存儲如ZK根欧、Redis等怜珍,因此一旦這些外部組件出現(xiàn)故障,那么分布式鎖就不可用了凤粗。

無法完全滿足需求:不同分布式鎖的實現(xiàn)酥泛,都有相應的特點,對于一些需求并不能很好的滿足嫌拣,如實現(xiàn)公平鎖柔袁、給等待鎖加超時時間等。

基于以上問題亭罪,結合多種實現(xiàn)方式瘦馍,我們開發(fā)了Cerberus(得名自希臘神話里守衛(wèi)地獄的猛犬),致力于提供靈活可靠的分布式鎖应役。

Cerberus分布式鎖

Cerberus有以下幾個特點。

特點一:一套接口多種引擎

Cerberus分布式鎖使用了多種引擎實現(xiàn)方式(Tair燥筷、ZK箩祥、未來支持Redis),支持使用方自主選擇所需的一種或多種引擎肆氓。這樣可以結合引擎特點袍祖,選擇符合實際業(yè)務需求和系統(tǒng)架構的方式。

Cerberus分布式鎖將不同引擎的接口抽象為一套谢揪,屏蔽了不同引擎的實現(xiàn)細節(jié)蕉陋。使得使用方可以專注于業(yè)務邏輯,也可以任意選擇并切換引擎而不必更改任何的業(yè)務代碼拨扶。

如果使用方選擇了一種以上的引擎凳鬓,那么以配置順序來區(qū)分主副引擎。以下是使用主引擎的推薦:

特點二:使用靈活患民、學習成本低

下面是Cerberus的lock方法缩举,這些方法和JUC的ReentrantLock的方式保持一致,使用非常靈活且不需要額外的學習時間匹颤。

void lock();

獲取鎖仅孩,如果鎖被占用,將禁用當前線程印蓖,并且在獲得鎖之前辽慕,該線程將一直處于阻塞狀態(tài)。

boolean tryLock();

僅在調用時鎖為空閑狀態(tài)才獲取該鎖赦肃。
如果鎖可用溅蛉,則獲取鎖公浪,并立即返回值true。如果鎖不可用温艇,則此方法將立即返回值false因悲。

boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

如果鎖在給定的等待時間內空閑,并且當前線程未被中斷勺爱,則獲取鎖晃琳。
如果在給定時間內鎖可用,則獲取鎖琐鲁,并立即返回值true卫旱。如果在給定時間內鎖一直不可用,則此方法將立即返回值false围段。

  • void lockInterruptibly() throws InterruptedException;
    獲取鎖顾翼,如果鎖被占用,則一直等待直到線程被中斷或者獲取到鎖奈泪。

  • void unlock();
    釋放當前持有的鎖适贸。

特點三:支持一鍵降級

Cerberus提供了實時切換引擎的接口:

  • String switchEngine()
    轉換分布式鎖引擎,按配置的引擎的順序循環(huán)轉換涝桅。
    返回值:返回當前的engine名字拜姿,如:”zk”。

  • String switchEngine(String engineName)
    轉換分布式鎖引擎冯遂,切換為指定的引擎蕊肥。
    參數:engineName - 引擎的名字,同配置bean的名字蛤肌,”zk”/”tair”壁却。 返回值:返回當前的engine名字,如:”zk”裸准。

當使用方選擇了兩種引擎展东,平時分布式鎖會工作在主引擎上。一旦所依賴的主引擎出現(xiàn)故障狼速,那么使用方可以通過自動或者手動方式調用該切換引擎接口琅锻,平滑的將分布式鎖切換到另一個引擎上以將風險降到最低。自動切換方式可以利用Hystrix實現(xiàn)向胡。手動切換推薦的一個方案則是使用美團點評基于Zookeeper的基礎組件MCC恼蓬,通過監(jiān)聽MCC配置項更改,來達到手動將分布式系統(tǒng)所有主機同步切換引擎的目的僵芹。需要注意的是处硬,切換引擎目前并不會遷移原引擎已有的鎖。

這樣做的目的是出于必要性拇派、系統(tǒng)復雜度和可靠性的綜合考慮荷辕。在實際情況下凿跳,引擎故障到切換引擎,尤其是手動切換引擎的時間疮方,要遠大于分布式鎖的存活時間控嗜。作為較輕量級的Cerberus來說,遷移鎖會帶來不必要的開銷以及較高的系統(tǒng)復雜度骡显。鑒于此疆栏,如果想要保證在引擎故障后的絕對可靠,那么則需要結合其他方案來進行處理惫谤。

除此以外壁顶,Cerberus還提供了內置公用集群,免去搭建和配置集群的煩惱溜歪。Cerberus也有一套完善的應用授權機制若专,以此防止業(yè)務方未經評估使用,對集群造成影響蝴猪。

目前调衰,Cerberus分布式鎖已經持續(xù)迭代了8個版本,先后在美團點評多個項目中穩(wěn)定運行自阱。搜索Java知音公眾號窖式,回復“后端面試”,送你一份Java面試題寶典

冪等性問題

所謂冪等动壤,簡單地說,就是對接口的多次調用所產生的結果和調用一次是一致的淮逻。擴展一下琼懊,這里的接口,可以理解為對外發(fā)布的HTTP接口或者Thrift接口爬早,也可以是接收消息的內部接口哼丈,甚至是一個內部方法或操作。

那么我們?yōu)槭裁葱枰涌诰哂袃绲刃阅厣秆希吭O想一下以下情形:

  1. 在App中下訂單的時候醉旦,點擊確認之后,沒反應桨啃,就又點擊了幾次车胡。在這種情況下,如果無法保證該接口的冪等性照瘾,那么將會出現(xiàn)重復下單問題匈棘。
  2. 在接收消息的時候,消息推送重復析命。如果處理消息的接口無法保證冪等主卫,那么重復消費消息產生的影響可能會非常大逃默。

在分布式環(huán)境中,網絡環(huán)境更加復雜簇搅,因前端操作抖動完域、網絡故障、消息重復瘩将、響應速度慢等原因吟税,對接口的重復調用概率會比集中式環(huán)境下更大,尤其是重復消息在分布式環(huán)境中很難避免鸟蟹。Tyler Treat也在《You Cannot Have Exactly-Once Delivery》一文中提到:

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

分布式環(huán)境中乌妙,有些接口是天然保證冪等性的,如查詢操作建钥。有些對數據的修改是一個常量藤韵,并且無其他記錄和操作,那也可以說是具有冪等性的熊经。其他情況下泽艘,所有涉及對數據的修改、狀態(tài)的變更就都有必要防止重復性操作的發(fā)生镐依。通過間接的實現(xiàn)接口的冪等性來防止重復操作所帶來的影響匹涮,成為了一種有效的解決方案。

GTIS

GTIS就是這樣的一個解決方案槐壳。它是一個輕量的重復操作關卡系統(tǒng)然低,它能夠確保在分布式環(huán)境中操作的唯一性。我們可以用它來間接保證每個操作的冪等性务唐。它具有如下特點:

  • 高效:低延時雳攘,單個方法平均響應時間在2ms內,幾乎不會對業(yè)務造成影響枫笛;

  • 可靠:提供降級策略吨灭,以應對外部存儲引擎故障所造成的影響;提供應用鑒權刑巧,提供集群配置自定義喧兄,降低不同業(yè)務之間的干擾;

  • 簡單:接入簡捷方便啊楚,學習成本低吠冤。只需簡單的配置,在代碼中進行兩個方法的調用即可完成所有的接入工作特幔;

  • 靈活:提供多種接口參數咨演、使用策略,以滿足不同的業(yè)務需求蚯斯。

實現(xiàn)原理

基本原理

GTIS的實現(xiàn)思路是將每一個不同的業(yè)務操作賦予其唯一性薄风。這個唯一性是通過對不同操作所對應的唯一的內容特性生成一個唯一的全局ID來實現(xiàn)的饵较。基本原則為:相同的操作生成相同的全局ID遭赂;不同的操作生成不同的全局ID循诉。

生成的全局ID需要存儲在外部存儲引擎中,數據庫撇他、Redis亦或是Tair等均可實現(xiàn)茄猫。考慮到Tair天生分布式和持久化的優(yōu)勢困肩,目前的GTIS存儲在Tair中划纽。其相應的key和value如下:

  • key:將對于不同的業(yè)務,采用APP_KEY+業(yè)務操作內容特性生成一個唯一標識trans_contents锌畸。然后對唯一標識進行加密生成全局ID作為Key勇劣。

  • value:current_timestamp + trans_contents,current_timestamp用于標識當前的操作線程潭枣。

判斷是否重復比默,主要利用Tair的SETNX方法,如果原來沒有值則set且返回成功盆犁,如果已經有值則返回失敗命咐。

內部流程

GTIS的內部實現(xiàn)流程為:

  1. 業(yè)務方在業(yè)務操作之前,生成一個能夠唯一標識該操作的transContents谐岁,傳入GTIS醋奠;
  2. GTIS根據傳入的transContents,用MD5生成全局ID伊佃;
  3. GTIS將全局ID作為key钝域,current_timestamp+transContents作為value放入Tair進行setNx,將結果返回給業(yè)務方锭魔;
  4. 業(yè)務方根據返回結果確定能否開始進行業(yè)務操作;
  5. 若能路呜,開始進行操作迷捧;若不能,則結束當前操作胀葱;
  6. 業(yè)務方將操作結果和請求結果傳入GTIS漠秋,系統(tǒng)進行一次請求結果的檢驗;
  7. 若該次操作成功抵屿,GTIS根據key取出value值庆锦,跟傳入的返回結果進行比對,如果兩者相等轧葛,則將該全局ID的過期時間改為較長時間搂抒;
  8. GTIS返回最終結果

實現(xiàn)難點

GTIS的實現(xiàn)難點在于如何保證其判斷重復的可靠性艇搀。由于分布式環(huán)境的復雜度和業(yè)務操作的不確定性,在上一章節(jié)分布式鎖的實現(xiàn)中考慮的網絡斷開或主機宕機等問題求晶,同樣需要在GTIS中設法解決焰雕。這里列出幾個典型的場景:

  1. 如果操作執(zhí)行失敗,理想的情況應該是另一個相同的操作可以立即進行芳杏。因此矩屁,需要對業(yè)務方的操作結果進行判斷,如果操作失敗爵赵,那么就需要立即刪除該全局ID吝秕;
  2. 如果操作超時或主機宕機,當前的操作無法告知GTIS操作是否成功空幻。那么我們必須引入超時機制烁峭,一旦長時間獲取不到業(yè)務方的操作反饋,那么也需要該全局ID失效氛悬;
  3. 結合上兩個場景则剃,既然全局ID會失效并且可能會被刪除,那就需要保證刪除的不是另一個相同操作的全局ID如捅。這就需要將特殊的標識記錄下來棍现,并由此來判斷。這里所用的標識為當前時間戳镜遣。

可以看到己肮,解決這些問題的思路,也和上一章節(jié)中的實現(xiàn)有很多類似的地方悲关。除此以外谎僻,還有更多的場景需要考慮和解決,所有分支流程如下:

使用說明

使用時寓辱,業(yè)務方只需要在操作的前后調用GTIS的前置方法和后置方法艘绍,如下圖所示。如果前置方法返回可進行操作秫筏,則說明此時無重復操作诱鞠,可以進行。否則則直接結束操作这敬。

使用方需要考慮的主要是下面兩個參數:

  1. 空間全局性:業(yè)務方輸入的能夠標志操作唯一性的內容特性航夺,可以是唯一性的String類型的ID,也可以是map崔涂、POJO等形式阳掐。如訂單ID等
  2. 時間全局性:確定在多長時間內不允許重復,1小時內還是一個月內亦或是永久。

此外缭保,GTIS還提供了不同的故障處理策略和重試機制汛闸,以此來降低外部存儲引擎異常對系統(tǒng)造成的影響。

目前涮俄,GTIS已經持續(xù)迭代了7個版本蛉拙,距離第一個版本有近1年之久,先后在美團點評多個項目中穩(wěn)定運行彻亲。

結語

在分布式環(huán)境中孕锄,操作互斥性問題和冪等性問題非常普遍。經過分析苞尝,我們找出了解決這兩個問題的基本思路和實現(xiàn)原理畸肆,給出了具體的解決方案。

針對操作互斥性問題宙址,常見的做法便是通過分布式鎖來處理對共享資源的搶占轴脐。分布式鎖的實現(xiàn),很大程度借鑒了多線程和多進程環(huán)境中的互斥鎖的實現(xiàn)原理抡砂。只要滿足一些存儲方面的基本條件大咱,并且能夠解決如網絡斷開等異常情況,那么就可以實現(xiàn)一個分布式鎖注益。

目前已經有基于Zookeeper和Redis等存儲引擎的比較典型的分布式鎖實現(xiàn)碴巾。但是由于單存儲引擎的局限,我們開發(fā)了基于ZooKeeper和Tair的多引擎分布式鎖Cerberus丑搔,它具有使用靈活方便等諸多優(yōu)點厦瓢,還提供了完善的一鍵降級方案。

針對操作冪等性問題啤月,我們可以通過防止重復操作來間接的實現(xiàn)接口的冪等性煮仇。GTIS提供了一套可靠的解決方法:依賴于存儲引擎,通過對不同操作所對應的唯一的內容特性生成一個唯一的全局ID來防止操作重復谎仲。

目前Cerberus分布式鎖浙垫、GTIS都已應用在生產環(huán)境并平穩(wěn)運行。兩者提供的解決方案已經能夠解決大多數分布式環(huán)境中的操作互斥性和冪等性的問題郑诺。值得一提的是绞呈,分布式鎖和GTIS都不是萬能的,它們對外部存儲系統(tǒng)的強依賴使得在環(huán)境不那么穩(wěn)定的情況下间景,對可靠性會造成一定的影響。在并發(fā)量過高的情況下艺智,如果不能很好的控制鎖的粒度倘要,那么使用分布式鎖也是不太合適的。

總的來說,分布式環(huán)境下的業(yè)務場景紛繁復雜封拧,要解決互斥性和冪等性問題還需要結合當前系統(tǒng)架構志鹃、業(yè)務需求和未來演進綜合考慮。Cerberus分布式鎖和GTIS也會持續(xù)不斷地迭代更新泽西,提供更多的引擎選擇曹铃、更高效可靠的實現(xiàn)方式、更簡捷的接入流程捧杉,以期滿足更復雜的使用場景和業(yè)務需求陕见。

寫在最后

歡迎大家關注我的公眾號【風平浪靜如碼】,海量Java相關文章味抖,學習資料都會在里面更新评甜,整理的資料也會放在里面。

覺得寫的還不錯的就點個贊仔涩,加個關注唄忍坷!點關注,不迷路熔脂,持續(xù)更新E逖小!霞揉!

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末旬薯,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子零聚,更是在濱河造成了極大的恐慌袍暴,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,539評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件隶症,死亡現(xiàn)場離奇詭異政模,居然都是意外死亡竟宋,警方通過查閱死者的電腦和手機空骚,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,594評論 3 396
  • 文/潘曉璐 我一進店門黑毅,熙熙樓的掌柜王于貴愁眉苦臉地迎上來抠艾,“玉大人义钉,你說我怎么就攤上這事层宫√撬剩” “怎么了辕万?”我有些...
    開封第一講書人閱讀 165,871評論 0 356
  • 文/不壞的土叔 我叫張陵彪见,是天一觀的道長儡司。 經常有香客問我,道長余指,這世上最難降的妖魔是什么捕犬? 我笑而不...
    開封第一講書人閱讀 58,963評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上碉碉,老公的妹妹穿的比我還像新娘柴钻。我一直安慰自己,他們只是感情好垢粮,可當我...
    茶點故事閱讀 67,984評論 6 393
  • 文/花漫 我一把揭開白布贴届。 她就那樣靜靜地躺著,像睡著了一般蜡吧。 火紅的嫁衣襯著肌膚如雪毫蚓。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,763評論 1 307
  • 那天斩跌,我揣著相機與錄音绍些,去河邊找鬼。 笑死耀鸦,一個胖子當著我的面吹牛柬批,可吹牛的內容都是我干的。 我是一名探鬼主播袖订,決...
    沈念sama閱讀 40,468評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼氮帐,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了洛姑?” 一聲冷哼從身側響起上沐,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎楞艾,沒想到半個月后参咙,有當地人在樹林里發(fā)現(xiàn)了一具尸體,經...
    沈念sama閱讀 45,850評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡硫眯,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,002評論 3 338
  • 正文 我和宋清朗相戀三年蕴侧,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片两入。...
    茶點故事閱讀 40,144評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡净宵,死狀恐怖,靈堂內的尸體忽然破棺而出裹纳,到底是詐尸還是另有隱情择葡,我是刑警寧澤,帶...
    沈念sama閱讀 35,823評論 5 346
  • 正文 年R本政府宣布剃氧,位于F島的核電站敏储,受9級特大地震影響,放射性物質發(fā)生泄漏朋鞍。R本人自食惡果不足惜已添,卻給世界環(huán)境...
    茶點故事閱讀 41,483評論 3 331
  • 文/蒙蒙 一迫横、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧酝碳,春花似錦、人聲如沸恨狈。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,026評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽禾怠。三九已至返奉,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間吗氏,已是汗流浹背芽偏。 一陣腳步聲響...
    開封第一講書人閱讀 33,150評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留弦讽,地道東北人污尉。 一個月前我還...
    沈念sama閱讀 48,415評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像往产,于是被迫代替她去往敵國和親被碗。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,092評論 2 355

推薦閱讀更多精彩內容