百度Java架構師分享分散式鎖的技術選型及思考

Java高階開發發表於2018-04-13

本文來自作者 一行 在 GitChat 分享的{分散式鎖的技術選型及思考}

鎖和分散式鎖

在計算機中,鎖的作用是解決在併發狀態下的共享資源互斥問題,保證在同一時間只有一個程式/執行緒可以掌握資源的控制權。

例如以下幾種情況:

檔案鎖的實現是為了解決不同使用者同時讀寫同一檔案的併發問題而出現的,防止導致檔案的內容被破壞。

使用陣列實現的佇列,在 push 操作的地方一般需要加鎖來解決槽位的爭奪問題,防止出現多次 push 衝突從而導致資料丟失問題。

對於12306來說,火車票就是他的資源,最終放票的時候需要鎖來保證票、人、座位唯一對應。

……

上面的例子中其實就包含了我們通常講的傳統單機鎖和我要講的分散式鎖。

單機環境下,資源競爭者都是來自機器內部((程式/執行緒),那麼實現鎖的方案只需要藉助單機資源就可以了,比如藉助磁碟、記憶體、暫存器來實現。

但是對於分散式環境下,資源競爭者生存環境更復雜了,原有依賴單機的方案不再發揮作用,這時候就需要一個大家都認可的協調者出來,幫助解決競爭問題,那這個協調者稱之為分散式鎖。

上面這個例子就像兩個職員產生的矛盾,只要公司的領匯出面就可以解決。而當兩個公司產生競爭矛盾的時候,就需要司法機關出面,是同一個道理。

簡單的說,分散式鎖就是解決分散式環境下資源競爭問題的手段。

分散式鎖的應用場景

所有分散式環境下會出現資源競爭的地方都需要分散式鎖的協調,除了上面介紹的 12306 放票,還有類似共享文件平臺編輯問題、王者榮耀選擇英雄、全域性自增主鍵等應用需要用到。簡單介紹一下在類似公司內部 Wiki 等多人協作編輯平臺的使用場景。

Wiki 中的多人線上編輯

場景1:清明節前,團隊要求我們在 Wiki 登記自己的休假情況,假設我們在 id=1 這個文件上記錄我們的休假時間和聯絡電話。A、C 兩個同學同時開始編輯,並且 A 和 C 在同一時間提交了結果,他們在提交前文件是空的。服務需要如何處理這兩個請求呢?以誰的為準呢?會不會產生覆蓋現象導致 A 的記錄丟失了?

場景2:另一個 case,我是 Z 同學,在我前面別人都已經填完了,我有一個陋習,喜歡在儲存的時候連續按3-5下 Ctrl+s,而每一個 Ctrl+s 都會觸發一個請求,但是每個請求處理大概1s鍾,但是實際請求都在 20ms 內發出去了。

問題同上面,如何保證不重複的追加記錄呢?

假設你的儲存服務和儲存架構是這樣的:

這裡寫圖片描述

一般的處理程式碼是這樣的:

//根據docid獲取檔案內容,從分散式檔案系統取,時間不可控 nowFileContent = getFileByDocId(docId) //do something,類似diff,追加操作 newFileContent = doSomeThing() //儲存到檔案系統 setNewFileContent(docId,newFileContent)

對於場景1講到的 A、C 兩個請求同時到達程式碼段,但是由於網路原因,A 先拿到文件內容,C 在 A 寫入前讀到檔案內容,所以最終的結果是兩者會丟失一個寫入。

這裡寫圖片描述

所以需要對讀寫操作做一次加鎖,保證事務的完整、一致。

下圖是《現代作業系統》中的插圖,這裡的效果也希望如此。
這裡寫圖片描述

Wiki 這類場景屬於長耗時事務的資源處理問題,鎖的出現保證不會因為事務中的讀寫間跨度耗時大導致寫覆蓋的情況,使得請求排隊,順序處理。

解決方案選擇

我遇到的問題也是類 Wiki 這類長事務的問題,遇到問題第一想法是去看網上的解決方案。

網上 MySQL、ZK、Redis 各種實現方式很多,我需要選擇哪種?怎麼選擇?我需要權衡哪些方面?

以前看分散式書的時候,一個被提到很多次的詞是:trade-off,我理解是取捨或者是權衡吧。

作為一個 Web 開發者,我需要考慮的主要包含下面幾個部分:

實現我的功能是否 OK,耗時是否滿足線上需求?

實現難度、學習成本;

運維成本。

那麼按照這幾個標準來看一下現在的可選方案:

這裡寫圖片描述

MySQL 單主架構,寫都會到 master,有瓶頸。ZK 的方式需要自己搭建、運維,而且需要堆機器,利用率不高。最終採用了 Redis 來實現,流量/儲存都可以擴容,運維也不需要自己。

實現

選好了方案,下面就是實現了。如果我們最終實現了這個鎖,對它的要求是什麼呢?

lock 實現必須要是原子操作,同時保證任何時候只有一個競爭者是獨佔的;

unlock 必須是原子的,同時保證只有自己可以解鎖自己;

不能出現死鎖,當程式掛掉之後不影響其他的加鎖行為;

支援 Twemproxy 模式下的架構和單機;

耗時可以接受。

基於上述要求我的實現如下(只提供了大致,刪除了敏感資訊):

<?phpclass LockUtility{ const DEFAULT_UNLOCK_TIME = 4 ; const COMMON_REDISKEY_PREFIX = 'xxxxx' ; /** * @brief * * @param $ukey 需要加鎖的key * @param $unlockTime 鎖持有時長 * * @return */ public function __construct($ukey,$unlockTime=self::DEFAULT_UNLOCK_TIME){ $this->_objRedis = RedisFactory::getRedis(); $this->_redisKey = self::COMMON_REDISKEY_PREFIX.$ukey; $this->_unLockTime = $unlockTime ; //為單次加鎖生成唯一guid $this->_guid = genGuid(); } /** * @brief 對給定的key進行加鎖處理 * * @return * * true 表示加鎖成功 * * 丟擲異常則表示加鎖未成功,根據業務選擇自己的care的級別 * 異常錯誤碼 : * 1.網路錯誤: ErrorCodes::REDIS_ERROR 視業務嚴謹度,這個錯誤是否忽略 * 2.鎖被佔用: ErrorCodes::LOCK_IS_USED 明確確定鎖被別人佔有 */ public function lock(){ /* * 設定鎖的過程需要是原子的,所以採用了set來操作 * SET key value [EX seconds] [PX milliseconds] [NX|XX] * Redis 2.6.12 版本開始支援通過set 指定引數完成setexnx功能 * * php 語法 : $redis->set('key', 'value', Array('xx', 'px'=>1000)); * */ $setRet = $this->_objRedis->set($this->_redisKey,$this->_guid,array('nx', 'ex' => $this->_unLockTime)); //返回false表示請求鎖失敗 if(false === $setRet){ //鎖被佔用,拋異常 throw new Exception("get Lock Failed!Locking",Constants_ErrorCodes::LOCK_IS_USED); } //redis返回null,是網路、機器授權、語法錯誤等等 if(is_null($setRet)){ //網路錯誤、異常 throw new Exception("Request Redis Failed",Constants_ErrorCodes::REDIS_ERROR); } return $setRet ; } /** * @brief 解除對某個key的鎖定,原則上不需要關心返回值,可以多次呼叫 * * @return * 1 redis會話成功,並且成功刪除了key * 0 redis會話成功,但是待刪除的key已經不存在 * */ public function unlock(){ //Reids 2.6 版本增加了對 Lua 環境的支援,解決了長久以來不能高效地處理 CAS (check-and-set)命令的缺點 $luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" ; $delRet = $this->_objRedis->eval($luaScript,array($this->_redisKey,$this->_guid),1); if(is_null($delRet)){ //redis返回null,是網路、機器授權、語法錯誤等等 throw new Exception("Request Redis Failed",Constants_ErrorCodes::REDIS_ERROR); } return $delRet ; }}

程式碼寫出來之後是否解決了上面的問題呢?我們來看一下單機和叢集 Redis 方案下的使用。

單機 Redis 架構

這裡寫圖片描述
對於上圖的單點架構,讀寫不分離。

那麼上面的程式碼對於上面要求是否滿足?

lock 採用了set + nx + ex 引數 + redis 單執行緒可以保證 lock 是個原子操作,加鎖成功即成功,失敗即失敗,滿足要求1和要求3死鎖處理,超時 key 失效;

unlock 採用 Lua 保證了 compare and del 這個操作是原子的,同時解決了自己刪除自己的需求;

耗時上呢?都是一次請求,可以接受,同機房在 ms 級。

Twemproxy 模式下的多地域多分片主從架構

這裡寫圖片描述

Twemproxy 是對 Redis/Memcache 的代理,主要負責根據 key 路由到分片的功能,存在它不支援的操作,例如 keys *。不支援的原因是它需要遍歷所有分片才能完成操作,對於簡單的 set/get 還是路由到相應的分片,工作原理一致。

對於 Lua 指令碼呢? Lua 指令碼是怎麼路由的?支援嗎?

我們使用 eval 來執行的時候,我發現我們叢集的文件裡這麼寫:

必須至少有一個 key 在 script 後面。命令將發往第一個 key 所在的分片。

也就是說使用 eval 來完成工作,命令是發向第一個 key 的,而我們的第一個 key 就是我們要處理的 key,所以這套程式碼在叢集模式也是支援的。

但是對於叢集來說,現在都是採用的最終一致性、單地域主多地域從、寫走主地域的模式。

那麼就是說寫請求是跨地域的?這個我使用了多一步操作讀來優化,因為讀不跨地域、寫跨地域,但是99%以上的請求主從延時都沒這麼大,當然99%這個比例是我猜測的。

具體程式碼如下:

function lock(){ //首先採用exist來看指定key是不是存在了 if($objRedis->exist($key)){ //key存在一定是被佔了,拋異常 } //if not exist,並不能代表這個鎖真的沒被佔用,可能是主從延時,這時候複用上面的程式碼更安全,減少一次跨機房寫}

使用注意事項如下:

使用時候需要控制好自己的 lockTime,需要長於你的事務執行時間;

上層在獲取鎖失敗的時候,需要自己去選擇是阻塞還是拋棄這次請求,讓使用者端重試。

目前待解決問題有:

如果你的程式因為 CUP 吃緊而被掛起,而且掛起的時間超過了你設定的鎖的失效時間,是不是仍然會出現問題?

如果叢集模式一個分片掛了,會發生什麼?

你有什麼辦法解決嗎?歡迎留言討論。

總結

總結一下我這次的分享,主要有以下幾點總結:

分散式鎖是指分散式業務環境下需要的鎖,對支援鎖的服務沒有要求要分散式;

鎖實際上是一個資源協調者的角色,管理併發態下的資源控制權;

方案選擇就像投資,需要考慮投入產出比;

Redis 單機和叢集方案有自己的優化點,根據場景做優化;

在寫完文章後發現我的題目有點問題,更準確的叫法應該是《 Redis 實現分散式鎖的思考》,如果騙了你,請告訴我。

參考

吳大山的部落格 :提醒了我解鈴還需繫鈴人(Lua指令碼)

Twemproxy:Twemproxy 的程式碼,我沒看完,但是搭建了服務測試。

架構技術是程式設計師繞不開的話題,關於分散式,微服務,原始碼,框架結構,設計模式等這些技術我都分享在群697579751,可免費下載。希望可以幫助在這個行業發展的朋友和童鞋們,在論壇部落格等地方少花些時間找資料,把有限的時間,真正花在學習上,我把這些視訊分享出來。相信對於已經工作和遇到技術瓶頸的碼友,在這個群裡一定有你需要的內容。

這裡寫圖片描述

相關文章