分布式系統(tǒng)設(shè)計中的并發(fā)訪問解決方案
0、引言
隨著互聯(lián)網(wǎng)信息技術(shù)的飛速發(fā)展,數(shù)據(jù)量不斷增大,業(yè)務邏輯也日趨復雜,對系統(tǒng)的高并發(fā)訪問、海量數(shù)據(jù)處理的場景也越來越多。如何用較低成本實現(xiàn)系統(tǒng)的高可用、易伸縮、可擴展等目標就顯得越發(fā)重要。
為了解決這一系列問題,系統(tǒng)架構(gòu)也在不斷演進。傳統(tǒng)的集中式系統(tǒng)已經(jīng)逐漸無法滿足要求,分布式系統(tǒng)被使用在更多的場景中。分布式系統(tǒng)由獨立的服務器通過網(wǎng)絡(luò)松散耦合組成。在這個系統(tǒng)中每個服務器都是一臺獨立的主機,服務器之間通過內(nèi)部網(wǎng)絡(luò)連接。分布式系統(tǒng)有以下幾個特點:
- 可擴展性:可通過橫向水平擴展提高系統(tǒng)的性能和吞吐量。
- 高可靠性:高容錯,即使系統(tǒng)中一臺或幾臺故障,系統(tǒng)仍可提供服務。
- 高并發(fā)性:各機器并行獨立處理和計算。
- 廉價高效:多臺小型機而非單臺高性能機。
然而,在分布式系統(tǒng)中,其環(huán)境的復雜度、網(wǎng)絡(luò)的不確定性會造成諸如時鐘不一致、"拜占庭將軍問題"等。存在于集中式系統(tǒng)中的機器宕機、消息丟失等問題也會在分布式環(huán)境中變得更加復雜。
基于分布式系統(tǒng)的這些特征,有兩種問題逐漸成為了分布式環(huán)境中需要重點關(guān)注和解決的典型問題:
- 互斥性問題。
- 冪等性問題。
今天我們就針對這兩個問題來進行分析探討。
1、并發(fā)訪問互斥問題
先看一個常見的例子:
例1:某服務記錄關(guān)鍵數(shù)據(jù)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先寫入等情況。
以上的這個例子是典型的存在操作互斥性的問題,互斥性問題用通俗的話來講,就是對共享資源的搶占問題。如果不同的請求對同一個或者同一組資源讀取并修改時,無法保證按序執(zhí)行,無法保證一個操作的原子性,那么就很有可能會出現(xiàn)預期外的情況。因此操作的互斥性問題,也可以理解為一個需要保證時序性、原子性的問題。
在傳統(tǒng)的基于數(shù)據(jù)庫的架構(gòu)中,對于數(shù)據(jù)的搶占問題往往是通過數(shù)據(jù)庫事務(ACID)來保證的。在分布式環(huán)境中,出于對性能以及一致性敏感度的要求,使得分布式鎖成為了一種比較常見而高效的解決方案。事實上,操作互斥性問題也并非分布式環(huán)境所獨有,在傳統(tǒng)的多線程、多進程情況下已經(jīng)有了很好的解決方案。因此在研究分布式鎖之前,我們先來分析下這兩種情況的解決方案,以期能夠?qū)Ψ植际芥i的解決方案提供一些實現(xiàn)思路。
1.1 多線程環(huán)境解決方案及原理
基本上所有的并發(fā)模式在解決線程沖突問題的時候,都是采用序列化訪問共享資源的方案。
在多線程環(huán)境中,線程之間因為公用一些存儲空間,沖突問題時有發(fā)生。解決沖突問題最普遍的方式就是用互斥鎖把該資源或?qū)υ撡Y源的操作保護起來。
Java JDK中提供了兩種互斥鎖Lock和synchronized。不同的線程之間對同一資源進行搶占,該資源通常表現(xiàn)為某個類的普通成員變量。因此,利用ReentrantLock或者synchronized將共享的變量及其操作鎖住,即可基本解決資源搶占的問題。下面來簡單聊一聊兩者的實現(xiàn)原理。
1.2 多線程環(huán)境實現(xiàn)原理
1.2.1 ReentrantLock
ReentrantLock主要利用CAS+CLH隊列來實現(xiàn)。它支持公平鎖和非公平鎖,兩者的實現(xiàn)類似。
- CAS:Compare and Swap,比較并交換。CAS有3個操作數(shù):內(nèi)存值V、預期值A(chǔ)、要修改的新值B。當且僅當預期值A(chǔ)和內(nèi)存值V相同時,將內(nèi)存值V修改為B,否則什么都不做。該操作是一個原子操作,被廣泛的應用在Java的底層實現(xiàn)中。在Java中,CAS主要是由sun.misc.Unsafe這個類通過JNI調(diào)用CPU底層指令實現(xiàn)。
- CLH隊列:帶頭結(jié)點的雙向非循環(huán)鏈表。
ReentrantLock的基本實現(xiàn)可以概括為:先通過CAS嘗試獲取鎖。如果此時已經(jīng)有線程占據(jù)了鎖,那就加入CLH隊列并且被掛起。當鎖被釋放之后,排在CLH隊列隊首的線程會被喚醒,然后CAS再次嘗試獲取鎖。在這個時候,如果:
- 非公平鎖:如果同時還有另一個線程進來嘗試獲取,那么有可能會讓這個線程搶先獲取;
- 公平鎖:如果同時還有另一個線程進來嘗試獲取,當它發(fā)現(xiàn)自己不是在隊首的話,就會排到隊尾,由隊首的線程獲取到鎖。
下面分析下兩個片段:
inal 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;
}
在嘗試獲取鎖的時候,會先調(diào)用上面的方法。如果狀態(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再嘗試獲取一次。否則,則會根據(jù)前序節(jié)點的狀態(tài)判斷是否需要阻塞。如果需要阻塞,則調(diào)用LockSupport的park方法阻塞該線程。
1.2.2 synchronized
在Java語言中存在兩種內(nèi)建的synchronized語法:synchronized語句、synchronized方法。
- synchronized語句:當源代碼被編譯成字節(jié)碼的時候,會在同步塊的入口位置和退出位置分別插入monitorenter和monitorexit字節(jié)碼指令;
- synchronized方法:在Class文件的方法表中將該方法的access_flags字段中的synchronized標志位置1。這個在specification中沒有明確說明。
通過上面的一些了解,我們可以概括出解決互斥性問題,即資源搶占的基本方式為:
對共享資源的操作前后(進入退出臨界區(qū))加解鎖,保證不同線程或進程可以互斥有序的操作資源。
加解鎖方式,有顯式的加解鎖,如ReentrantLock或信號量;也有隱式的加解鎖,如synchronized。那么在分布式環(huán)境中,為了保證不同JVM不同主機間不會出現(xiàn)資源搶占,那么同樣只要對臨界區(qū)加解鎖就可以了。
然而在多線程和多進程中,鎖已經(jīng)有比較完善的實現(xiàn),直接使用即可。但是在分布式環(huán)境下,就需要我們自己來實現(xiàn)分布式鎖。
1.3 分布式環(huán)境下的解決方案-分布式鎖
1.3.1 分布式鎖條件
再回顧下多線程和多進程環(huán)境下的鎖,可以發(fā)現(xiàn)鎖的實現(xiàn)有很多共通之處,它們都需要滿足一些最基本的條件:
1. 需要有存儲鎖的空間,并且鎖的空間是可以訪問到的。
2. 鎖需要被唯一標識。
3. 鎖要有至少兩種狀態(tài)。
仔細分析這三個條件:
存儲空間
鎖是一個抽象的概念,鎖的實現(xiàn),需要依存于一個可以存儲鎖的空間。在多線程中是內(nèi)存,在多進程中是內(nèi)存或者磁盤。更重要的是,這個空間是可以被訪問到的。多線程中,不同的線程都可以訪問到堆中的成員變量;在多進程中,不同的進程可以訪問到共享內(nèi)存中的數(shù)據(jù)或者存儲在磁盤中的文件。但是在分布式環(huán)境中,不同的主機很難訪問對方的內(nèi)存或磁盤。這就需要一個都能訪問到的外部空間來作為存儲空間。
最普遍的外部存儲空間就是數(shù)據(jù)庫了,事實上也確實有基于數(shù)據(jù)庫做分布式鎖(行鎖、version樂觀鎖),如quartz集群架構(gòu)中就有所使用。除此以外,還有各式緩存如Redis、Tair、Memcached、MongoDB,當然還有專門的分布式協(xié)調(diào)服務Zookeeper,甚至是另一臺主機。只要可以存儲數(shù)據(jù)、鎖在其中可以被多主機訪問到,那就可以作為分布式鎖的存儲空間。
唯一標識
不同的共享資源,必然需要用不同的鎖進行保護,因此相應的鎖必須有唯一的標識。在多線程環(huán)境中,鎖可以是一個對象,那么對這個對象的引用便是這個唯一標識。多進程環(huán)境中,信號量在共享內(nèi)存中也是由引用來作為唯一的標識。但是如果不在內(nèi)存中,失去了對鎖的引用,如何唯一標識它呢?上文提到的有名信號量,便是用硬盤中的文件名作為唯一標識。因此,在分布式環(huán)境中,只要給這個鎖設(shè)定一個名稱,并且保證這個名稱是全局唯一的,那么就可以作為唯一標識。
至少兩種狀態(tài)
為了給臨界區(qū)加鎖和解鎖,需要存儲兩種不同的狀態(tài)。如ReentrantLock中的status,0表示沒有線程競爭,大于0表示有線程競爭;信號量大于0表示可以進入臨界區(qū),小于等于0則表示需要被阻塞。因此只要在分布式環(huán)境中,鎖的狀態(tài)有兩種或以上:如有鎖、沒鎖;存在、不存在等,均可以實現(xiàn)。
有了這三個條件,基本就可以實現(xiàn)一個簡單的分布式鎖了。
下面以數(shù)據(jù)庫為例,實現(xiàn)一個簡單的分布式鎖:
數(shù)據(jù)庫表,字段為鎖的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)是否為被鎖,需要經(jīng)歷兩步操作。如果不能保證這兩步的原子性,就可能導致不止一個請求獲取到了鎖,這顯然是不行的。因此,我們需要保證鎖狀態(tài)判斷的原子性。
問題2:網(wǎng)絡(luò)斷開或主機宕機,鎖狀態(tài)無法清除
假設(shè)在主機已經(jīng)獲取到鎖的情況下,突然出現(xiàn)了網(wǎng)絡(luò)斷開或者主機宕機,如果不做任何處理該鎖將仍然處于被鎖定的狀態(tài)。那么之后所有的請求都無法再成功搶占到這個鎖。因此,我們需要在持有鎖的主機宕機或者網(wǎng)絡(luò)斷開的時候,及時的釋放掉這把鎖。
問題3:無法保證釋放的是自己上鎖的那把鎖
在解決了問題2的情況下再設(shè)想一下,假設(shè)持有鎖的主機A在臨界區(qū)遇到網(wǎng)絡(luò)抖動導致網(wǎng)絡(luò)斷開,分布式鎖及時的釋放掉了這把鎖。之后,另一個主機B占有了這把鎖,但是此時主機A網(wǎng)絡(luò)恢復,退出臨界區(qū)時解鎖。由于都是同一把鎖,所以A就會將B的鎖解開。此時如果有第三個主機嘗試搶占這把鎖,也將會成功獲得。因此,我們需要在解鎖時,確定自己解的這個鎖正是自己鎖上的。
進階條件
如果分布式鎖的實現(xiàn),還能再解決上面的三個問題,那么就可以算是一個相對完整的分布式鎖了。然而,在實際的系統(tǒng)環(huán)境中,還會對分布式鎖有更高級的要求。
1. 可重入:線程中的可重入,指的是外層函數(shù)獲得鎖之后,內(nèi)層也可以獲得鎖,ReentrantLock和synchronized都是可重入鎖;衍生到分布式環(huán)境中,一般仍然指的是線程的可重入,在絕大多數(shù)分布式環(huán)境中,都要求分布式鎖是可重入的。
2. 驚群效應(Herd Effect):在分布式鎖中,驚群效應指的是,在有多個請求等待獲取鎖的時候,一旦占有鎖的線程釋放之后,如果所有等待的方都同時被喚醒,嘗試搶占鎖。但是這樣的情況會造成比較大的開銷,那么在實現(xiàn)分布式鎖的時候,應該盡量避免驚群效應的產(chǎn)生。
3. 公平鎖和非公平鎖:不同的需求,可能需要不同的分布式鎖。非公平鎖普遍比公平鎖開銷小。但是業(yè)務需求如果必須要鎖的競爭者按順序獲得鎖,那么就需要實現(xiàn)公平鎖。
4. 阻塞鎖和自旋鎖:針對不同的使用場景,阻塞鎖和自旋鎖的效率也會有所不同。阻塞鎖會有上下文切換,如果并發(fā)量比較高且臨界區(qū)的操作耗時比較短,那么造成的性能開銷就比較大了。但是如果臨界區(qū)操作耗時比較長,一直保持自旋,也會對CPU造成更大的負荷。
保留以上所有問題和條件,我們接下來看一些比較典型的實現(xiàn)方案。
1.3.2 分布式鎖的典型實現(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é)點。
根據(jù)ZK中節(jié)點是否存在,可以作為分布式鎖的鎖狀態(tài),以此來實現(xiàn)一個分布式鎖,下面是分布式鎖的基本邏輯:
- 客戶端調(diào)用create()方法創(chuàng)建名為"/dlm-locks/lockname/lock-"的臨時順序節(jié)點。
- 客戶端調(diào)用getChildren("lockname")方法來獲取所有已經(jīng)創(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é)點失敗等異常情況。
Redis的實現(xiàn)
Redis的分布式緩存特性使其成為了分布式鎖的一種基礎(chǔ)實現(xiàn)。通過Redis中是否存在某個鎖ID,則可以判斷是否上鎖。為了保證判斷鎖是否存在的原子性,保證只有一個線程獲取同一把鎖,Redis有SETNX(即SET if Not
eXists)和GETSET(先寫新值,返回舊值,原子性操作,可以用于分辨是不是首次操作)操作。
為了防止主機宕機或網(wǎng)絡(luò)斷開之后的死鎖,Redis沒有ZK那種天然的實現(xiàn)方式,只能依賴設(shè)置超時時間來規(guī)避。
以下是一種比較普遍但不太完善的Redis分布式鎖的實現(xiàn)步驟(與下圖一一對應):
- 線程A發(fā)送SETNX lock.orderid嘗試獲得鎖,如果鎖不存在,則set并獲得鎖。
- 如果鎖存在,則再判斷鎖的值(時間戳)是否大于當前時間,如果沒有超時,則等待一下再重試。
- 如果已經(jīng)超時了,在用GETSET lock.{orderid}來嘗試獲取鎖,如果這時候拿到的時間戳仍舊超時,則說明已經(jīng)獲得鎖了。
- 如果在此之前,另一個線程C快一步執(zhí)行了上面的操作,那么A拿到的時間戳是個未超時的值,這時A沒有如期獲得鎖,需要再次等待或重試。
該實現(xiàn)還有一個需要考慮的問題是全局時鐘問題,由于生產(chǎn)環(huán)境主機時鐘不能保證完全同步,對時間戳的判斷也可能會產(chǎn)生誤差。
2、冪等性問題
冪等(idempotent)是一個數(shù)學與計算機科學概念,常見于抽象代數(shù)中。
所謂冪等,簡單地說,就是對接口的多次調(diào)用所產(chǎn)生的結(jié)果和調(diào)用一次是一致的,多次調(diào)用方法或者接口不會改變業(yè)務狀態(tài),可以保證重復調(diào)用的結(jié)果和單次調(diào)用的結(jié)果一致。擴展一下,這里的接口,可以理解為對外發(fā)布的HTTP接口或者dubbo接口,也可以是接收消息的內(nèi)部接口,甚至是一個內(nèi)部方法或操作。使用冪等性的場景有如下:
1. 前端重復提交,如在App中下訂單的時候,點擊確認之后,沒反應,就又點擊了幾次。在這種情況下,如果無法保證該接口的冪等性,那么將會出現(xiàn)重復下單問題。
2.接口超時重試,對于給第三方調(diào)用的接口,有可能會因為網(wǎng)絡(luò)原因而調(diào)用失敗,這時,一般在設(shè)計的時候會對接口調(diào)用加上失敗重試的機制。如果第一次調(diào)用已經(jīng)執(zhí)行了一半時,發(fā)生了網(wǎng)絡(luò)異常。這時再次調(diào)用時就會因為臟數(shù)據(jù)的存在而出現(xiàn)調(diào)用異常。
3.消息重復消費,在使用消息中間件來處理消息隊列,且手動 ack 確認消息被正常消費時。如果消費者突然斷開連接,那么已經(jīng)執(zhí)行了一半的消息會重新放回隊列。當消息被其他消費者重新消費時,如果沒有冪等性,就會導致消息重復消費時結(jié)果異常,如數(shù)據(jù)庫重復數(shù)據(jù),數(shù)據(jù)庫數(shù)據(jù)沖突,資源重復等。
在分布式環(huán)境中,網(wǎng)絡(luò)環(huán)境更加復雜,因前端操作抖動、網(wǎng)絡(luò)故障、消息重復、響應速度慢等原因,對接口的重復調(diào)用概率會比集中式環(huán)境下更大,尤其是重復消息在分布式環(huán)境中很難避免。以下是冪等控制的集中解決方案:
2.1 token 機制實現(xiàn)
通過token 機制實現(xiàn)接口的冪等性,這是一種比較通用性的實現(xiàn)方法。
示意圖如下:
具體流程步驟:
1. 客戶端會先發(fā)送一個請求去獲取 token,服務端會生成一個全局唯一的 ID 作為 token 保存在 redis 中,同時把這個 ID 返回給客戶端。
2. 客戶端第二次調(diào)用業(yè)務請求的時候必須攜帶這個 token。
3. 服務端會校驗這個 token,如果校驗成功,則執(zhí)行業(yè)務,并刪除 redis 中的 token。
4. 如果校驗失敗,說明 redis 中已經(jīng)沒有對應的 token,則表示重復操作,直接返回指定的結(jié)果給客戶端
2.2 基于 mysql 實現(xiàn)
這種實現(xiàn)方式是利用 mysql 唯一索引的特性。示意圖如下:
具體流程步驟:
1. 建立一張去重表,其中某個字段需要建立唯一索引
2. 客戶端去請求服務端,服務端會將這次請求的一些信息插入這張去重表中
3. 因為表中某個字段帶有唯一索引,如果插入成功,證明表中沒有這次請求的信息,則執(zhí)行后續(xù)的業(yè)務邏輯
4. 如果插入失敗,則代表已經(jīng)執(zhí)行過當前請求,直接返回。
2.3 基于 redis 實現(xiàn)
這種實現(xiàn)方式是基于 SETNX 命令實現(xiàn)的。
SETNX key value:將 key 的值設(shè)為 value ,當且僅當 key 不存在。若給定的 key 已經(jīng)存在,則 SETNX 不做任何動作。該命令在設(shè)置成功時返回 1,設(shè)置失敗時返回 0。示意圖如下:
具體流程步驟:
1. 客戶端先請求服務端,會拿到一個能代表這次請求業(yè)務的唯一字段
2. 將該字段以 SETNX 的方式存入 redis 中,并根據(jù)業(yè)務設(shè)置相應的超時時間
3. 如果設(shè)置成功,證明這是第一次請求,則執(zhí)行后續(xù)的業(yè)務邏輯
4. 如果設(shè)置失敗,則代表已經(jīng)執(zhí)行過當前請求,直接返回
3、結(jié)語
在分布式環(huán)境中,操作互斥性問題和冪等性問題非常普遍。經(jīng)過分析,我們找出了解決這兩個問題的基本思路和實現(xiàn)原理,并給出了具體的解決方案。
針對操作互斥性問題,常見的做法便是通過分布式鎖來處理對共享資源的搶占。分布式鎖的實現(xiàn),很大程度借鑒了多線程和多進程環(huán)境中的互斥鎖的實現(xiàn)原理。只要滿足一些存儲方面的基本條件,并且能夠解決如網(wǎng)絡(luò)斷開等異常情況,那么就可以實現(xiàn)一個分布式鎖。
針對操作冪等性問題,我們可以通過防止重復操作來間接的實現(xiàn)接口的冪等性,這幾種實現(xiàn)冪等的方式其實都是大同小異的??傊?,當你去設(shè)計一個接口的時候,冪等都是首要考慮的問題,特別是當你負責設(shè)計轉(zhuǎn)賬、支付這種涉及到 money 的接口,都需要重點設(shè)計接口操作的冪等性問題。