分散式學習筆記四:分散式鎖的實現方式
目前幾乎很多大型網站及應用都是分散式部署的,分散式場景中的資料一致性問題一直是一個比較重要的話題。分散式的CAP理論告訴我們“任何一個分散式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分割槽容錯性(Partition tolerance),最多隻能同時滿足兩項。”所以,很多系統在設計之初就要對這三者做出取捨。在網際網路領域的絕大多數的場景中,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證“最終一致性”,只要這個最終時間是在使用者可以接受的範圍內即可。
在很多場景中,我們為了保證資料的最終一致性,需要很多的技術方案來支援,比如分散式事務、分散式鎖等。有的時候,我們需要保證一個方法在同一時間內只能被同一個執行緒執行。在單機環境中,Java中其實提供了很多併發處理相關的API,但是這些API在分散式場景中就無能為力了。也就是說單純的Java Api並不能提供分散式鎖的能力。所以針對分散式鎖的實現目前有多種方案。
針對分散式鎖的實現,目前比較常用的有以下幾種方案:
基於資料庫實現分散式鎖,基於快取(redis,memcached)實現分散式鎖,基於Zookeeper實現分散式鎖
在分析這幾種實現方案之前我們先來想一下,我們需要的分散式鎖應該是怎麼樣的?(這裡以方法鎖為例,資源鎖同理)
可以保證在分散式部署的應用叢集中,同一個方法在同一時間只能被一臺機器上的一個執行緒執行。
這把鎖要是一把可重入鎖(避免死鎖)
這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)
有高可用的獲取鎖和釋放鎖功能
獲取鎖和釋放鎖的效能要好
基於資料庫實現分散式鎖
基於資料庫表
要實現分散式鎖,最簡單的方式可能就是直接建立一張鎖表,然後通過操作該表中的資料來實現了。
當我們要鎖住某個方法或資源時,我們就在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。
建立這樣一張資料庫表:
1 2 3 4 5 6 7 8 |
varchar (64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名' ,
|
當我們想要鎖住某個方法時,執行以下SQL:
1 |
|
因為我們對method_name
做了唯一性約束,這裡如果有多個請求同時提交到資料庫的話,資料庫會保證只有一個操作可以成功,那麼我們就可以認為操作成功的那個執行緒獲得了該方法的鎖,可以執行方法體內容。
當方法執行完畢之後,想要釋放鎖的話,需要執行以下Sql:
1 |
|
上面這種簡單的實現有以下幾個問題:
1、這把鎖強依賴資料庫的可用性,資料庫是一個單點,一旦資料庫掛掉,會導致業務系統不可用。
2、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在資料庫中,其他執行緒無法再獲得到鎖。
3、這把鎖只能是非阻塞的,因為資料的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的執行緒並不會進入排隊佇列,要想再次獲得鎖就要再次觸發獲得鎖操作。
4、這把鎖是非重入的,同一個執行緒在沒有釋放鎖之前無法再次獲得該鎖。因為資料中資料已經存在了。
當然,我們也可以有其他方式解決上面的問題。
- 資料庫是單點?搞兩個資料庫,資料之前雙向同步。一旦掛掉快速切換到備庫上。
- 沒有失效時間?只要做一個定時任務,每隔一定時間把資料庫中的超時資料清理一遍。
- 非阻塞的?搞一個while迴圈,直到insert成功再返回成功。
- 非重入的?在資料庫表中加個欄位,記錄當前獲得鎖的機器的主機資訊和執行緒資訊,那麼下次再獲取鎖的時候先查詢資料庫,如果當前機器的主機資訊和執行緒資訊在資料庫可以查到的話,直接把鎖分配給他就可以了。
基於資料庫排他鎖
除了可以通過增刪操作資料表中的記錄以外,其實還可以藉助資料中自帶的鎖來實現分散式的鎖。
我們還用剛剛建立的那張資料庫表。可以通過資料庫的排他鎖來實現分散式鎖。 基於MySql的InnoDB引擎,可以使用以下方法來實現加鎖操作:
public boolean lock(){
connection.setAutoCommit(false)
while(true){
try{
result = select * from methodLock where method_name=xxx for update;
if(result==null){
return true;
}
}catch(Exception e){
}
sleep(1000);
}
return false;
}
在查詢語句後面增加for update
,資料庫會在查詢過程中給資料庫表增加排他鎖(這裡再多提一句,InnoDB引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這裡我們希望使用行級鎖,就要給method_name新增索引,值得注意的是,這個索引一定要建立成唯一索引,否則會出現多個過載方法之間無法同時被訪問的問題。過載方法的話建議把引數型別也加上。)。當某條記錄被加上排他鎖之後,其他執行緒無法再在該行記錄上增加排他鎖。
我們可以認為獲得排它鎖的執行緒即可獲得分散式鎖,當獲取到鎖之後,可以執行方法的業務邏輯,執行完方法之後,再通過以下方法解鎖:
public void unlock(){
connection.commit();
}
通過connection.commit()
操作來釋放鎖。
這種方法可以有效的解決上面提到的無法釋放鎖和阻塞鎖的問題。
- 阻塞鎖?
for update
語句會在執行成功後立即返回,在執行失敗時一直處於阻塞狀態,直到成功。 - 鎖定之後服務宕機,無法釋放?使用這種方式,服務宕機之後資料庫會自己把鎖釋放掉。
但是還是無法直接解決資料庫單點和可重入問題。
這裡還可能存在另外一個問題,雖然我們對method_name
使用了唯一索引,並且顯示使用for update
來使用行級鎖。但是,MySql會對查詢進行優化,即便在條件中使用了索引欄位,但是否使用索引來檢索資料是由 MySQL 通過判斷不同執行計劃的代價來決定的,如果 MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。如果發生這種情況就悲劇了。。。
還有一個問題,就是我們要使用排他鎖來進行分散式鎖的lock,那麼一個排他鎖長時間不提交,就會佔用資料庫連線。一旦類似的連線變得多了,就可能把資料庫連線池撐爆
總結
總結一下使用資料庫來實現分散式鎖的方式,這兩種方式都是依賴資料庫的一張表,一種是通過表中的記錄的存在情況確定當前是否有鎖存在,另外一種是通過資料庫的排他鎖來實現分散式鎖。
資料庫實現分散式鎖的優點
直接藉助資料庫,容易理解。
資料庫實現分散式鎖的缺點
會有各種各樣的問題,在解決問題的過程中會使整個方案變得越來越複雜。
操作資料庫需要一定的開銷,效能問題需要考慮。
使用資料庫的行級鎖並不一定靠譜,尤其是當我們的鎖表並不大的時候。
基於快取實現分散式鎖
相比較於基於資料庫實現分散式鎖的方案來說,基於快取來實現在效能方面會表現的更好一點。而且很多快取是可以叢集部署的,可以解決單點問題。
目前有很多成熟的快取產品,包括Redis,memcached。關於Redis和memcached在網路上有很多相關的文章,並且也有一些成熟的框架及演算法可以直接使用。
memcached分散式鎖
實現原理:memcached帶有add函式,利用add函式的特性即可實現分散式鎖。add和set的區別在於:如果多執行緒併發set,則每個set都會成功,但最後儲存的值以最後的set的執行緒為準。而add的話則相反,add會新增第一個到達的值,並返回true,後續的新增則都會返回false。利用該點即可很輕鬆地實現分散式鎖
優點:併發高效
缺點:1、memcached採用列入LRU置換策略,所以如果記憶體不夠,可能導致快取中的鎖資訊丟失;
2、memcached無法持久化,一旦重啟,將導致資訊丟失。
使用場景:高併發場景。需要1)加上超時時間避免死鎖;2)提供足夠支撐鎖服務的記憶體空間;3)穩定的叢集化管理。
redis分散式鎖
redis分散式鎖即可以結合zk分散式鎖鎖高度安全和memcached併發場景下效率很好的優點,其實現方式和memcached類似,採用setnx即可實現。需要注意的是,這裡的redis也需要設定超時時間。以避免死鎖。可以利用jedis客戶端實現。
使用Redis實現分散式鎖,有兩個重要函式需要介紹:
SETNX命令(SET if Not eXists)
語法:
1. SETNX key value
功能:
當且僅當 key 不存在,將 key 的值設為 value ,並返回1;若給定的 key 已經存在,則 SETNX 不做任何動作,並返回0。
2. GETSET命令
語法:
GETSET key value
功能:
將給定 key 的值設為 value ,並返回 key 的舊值 (old value),當 key 存在但不是字串型別時,返回一個錯誤,當key不存在時,返回nil。
3. GET命令
語法:
GET key
功能:
返回 key 所關聯的字串值,如果 key 不存在那麼返回特殊值 nil 。
4. DEL命令
語法:
DEL key [KEY …]
功能:
刪除給定的一個或多個 key ,不存在的 key 會被忽略。
分散式鎖,我們就依靠這四個命令。但在具體實現,還有很多細節,需要仔細斟酌,因為在分散式併發多程序中,任何一點出現差錯,都會導致死鎖,hold住所有程序。
加鎖實現
SETNX 可以直接加鎖操作,比如說對某個關鍵詞foo加鎖,客戶端可以嘗試
SETNX foo.lock <current unix time>如果返回1,表示客戶端已經獲取鎖,可以往下操作,操作完成後,通過DEL foo.lock命令來釋放鎖。
如果返回0,說明foo已經被其他客戶端上鎖,如果鎖是非堵塞的,可以選擇返回呼叫。如果是堵塞呼叫呼叫,就需要進入以下個重試迴圈,直至成功獲得鎖或者重試超時。理想是美好的,現實是殘酷的。僅僅使用SETNX加鎖帶有競爭條件的,在某些特定的情況會造成死鎖錯誤。
以上實現方式同樣存在幾個問題:
1、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在tair中,其他執行緒無法再獲得到鎖。
2、這把鎖只能是非阻塞的,無論成功還是失敗都直接返回。
3、這把鎖是非重入的,一個執行緒獲得鎖之後,在釋放鎖之前,無法再次獲得該鎖,因為使用到的key在tair中已經存在。無法再執行put操作。
當然,同樣有方式可以解決。
沒有失效時間?Redis的expire方法支援傳入失效時間,到達時間之後資料會自動刪除。
- 非阻塞?while重複執行。
- 非可重入?在一個執行緒獲取到鎖之後,把當前主機資訊和執行緒資訊儲存起來,下次再獲取之前先檢查自己是不是當前鎖的擁有者。
但是,失效時間我設定多長時間為好?如何設定的失效時間太短,方法沒等執行完,鎖就自動釋放了,那麼就會產生併發問題。如果設定的時間太長,其他獲取鎖的執行緒就可能要平白的多等一段時間。這個問題使用資料庫實現分散式鎖同樣存在
總結
可以使用快取來代替資料庫來實現分散式鎖,這個可以提供更好的效能,同時,很多快取服務都是叢集部署的,可以避免單點問題。並且很多快取服務都提供了可以用來實現分散式鎖的方法,比如Tair的put方法,redis的setnx方法等。並且,這些快取服務也都提供了對資料的過期自動刪除的支援,可以直接設定超時時間來控制鎖的釋放。
使用快取實現分散式鎖的優點
效能好,實現起來較為方便。
使用快取實現分散式鎖的缺點
通過超時時間來控制鎖的失效時間並不是十分的靠譜。
基於Zookeeper實現分散式鎖
基於zookeeper臨時有序節點可以實現的分散式鎖。
大致思想即為:每個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個唯一的瞬時有序節點。 判斷是否獲取鎖的方式很簡單,只需要判斷有序節點中序號最小的一個。 當釋放鎖的時候,只需將這個瞬時節點刪除即可。同時,其可以避免服務宕機導致的鎖無法釋放,而產生的死鎖問題。
來看下Zookeeper能不能解決前面提到的問題。
-
鎖無法釋放?使用Zookeeper可以有效的解決鎖無法釋放的問題,因為在建立鎖的時候,客戶端會在ZK中建立一個臨時節點,一旦客戶端獲取到鎖之後突然掛掉(Session連線斷開),那麼這個臨時節點就會自動刪除掉。其他客戶端就可以再次獲得鎖。
-
非阻塞鎖?使用Zookeeper可以實現阻塞的鎖,客戶端可以通過在ZK中建立順序節點,並且在節點上繫結監聽器,一旦節點有變化,Zookeeper會通知客戶端,客戶端可以檢查自己建立的節點是不是當前所有節點中序號最小的,如果是,那麼自己就獲取到鎖,便可以執行業務邏輯了。
-
不可重入?使用Zookeeper也可以有效的解決不可重入的問題,客戶端在建立節點的時候,把當前客戶端的主機資訊和執行緒資訊直接寫入到節點中,下次想要獲取鎖的時候和當前最小的節點中的資料比對一下就可以了。如果和自己的資訊一樣,那麼自己直接獲取到鎖,如果不一樣就再建立一個臨時的順序節點,參與排隊。
-
單點問題?使用Zookeeper可以有效的解決單點問題,ZK是叢集部署的,只要叢集中有半數以上的機器存活,就可以對外提供服務。
可以直接使用zookeeper第三方庫Curator客戶端,這個客戶端中封裝了一個可重入的鎖服務。
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
try {
return interProcessMutex.acquire(timeout, unit);
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
public boolean unlock() {
try {
interProcessMutex.release();
} catch (Throwable e) {
log.error(e.getMessage(), e);
} finally {
executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
}
return true;
}
Curator提供的InterProcessMutex是分散式鎖的實現。acquire方法使用者獲取鎖,release方法用於釋放鎖。
使用ZK實現的分散式鎖好像完全符合了本文開頭我們對一個分散式鎖的所有期望。但是,其實並不是,Zookeeper實現的分散式鎖其實存在一個缺點,那就是效能上可能並沒有快取服務那麼高。因為每次在建立鎖和釋放鎖的過程中,都要動態建立、銷燬瞬時節點來實現鎖功能。ZK中建立和刪除節點只能通過Leader伺服器來執行,然後將資料同步到所有的Follower機器上。
其實,使用Zookeeper也有可能帶來併發問題,只是並不常見而已。考慮這樣的情況,由於網路抖動,客戶端可ZK叢集的session連線斷了,那麼zk以為客戶端掛了,就會刪除臨時節點,這時候其他客戶端就可以獲取到分散式鎖了。就可能產生併發問題。這個問題不常見是因為zk有重試機制,一旦zk叢集檢測不到客戶端的心跳,就會重試,Curator客戶端支援多種重試策略。多次重試之後還不行的話才會刪除臨時節點。(所以,選擇一個合適的重試策略也比較重要,要在鎖的粒度和併發之間找一個平衡。)
總結
使用Zookeeper實現分散式鎖的優點
有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實現起來較為簡單。
使用Zookeeper實現分散式鎖的缺點
效能上不如使用快取實現分散式鎖。 需要對ZK的原理有所瞭解。
分散式鎖的問題
1:必要的超時機制:獲取鎖的客戶端一旦崩潰,一定要有過期機制,否則其他客戶端都降無法獲取鎖,造成死鎖問題。
2:分散式鎖,多客戶端的時間戳不能保證嚴格意義的一致性,所以在某些特定因素下,有可能存在鎖串的情況。要適度的機制,可以承受小概率的事件產生。
3:只對關鍵處理節點加鎖,良好的習慣是,把相關的資源準備好,比如連線資料庫後,呼叫加鎖機制獲取鎖,直接進行操作,然後釋放,儘量減少持有鎖的時間。
4:在持有鎖期間要不要CHECK鎖,如果需要嚴格依賴鎖的狀態,最好在關鍵步驟中做鎖的CHECK檢查機制,但是根據我們的測試發現,在大併發時,每一次CHECK鎖操作,都要消耗掉幾個毫秒,而我們的整個持鎖處理邏輯才不到10毫秒,玩客沒有選擇做鎖的檢查。
5:sleep學問,為了減少對Redis的壓力,獲取鎖嘗試時,迴圈之間一定要做sleep操作。但是sleep時間是多少是門學問。需要根據自己的Redis的QPS,加上持鎖處理時間等進行合理計算。
6:至於為什麼不使用Redis的muti,expire,watch等機制,可以查一參考資料,找下原因。
三種方案的比較
上面幾種方式,哪種方式都無法做到完美。就像CAP一樣,在複雜性、可靠性、效能等方面無法同時滿足,所以,根據不同的應用場景選擇最適合自己的才是王道。
從理解的難易程度角度(從低到高)
資料庫 > 快取 > Zookeeper
從實現的複雜性角度(從低到高)
Zookeeper >= 快取 > 資料庫
從效能角度(從高到低)
快取 > Zookeeper >= 資料庫
從可靠性角度(從高到低)
Zookeeper > 快取 > 資料庫