天天看點

轉載:基于Redis實作分布式鎖

背景

在很多網際網路産品應用中,有些場景需要加鎖處理,比如:秒殺,全局遞增ID,樓層生成等等。大部分的解決方案是基于DB實作的,Redis為單程序單線程模式,采用隊列模式将并發通路變成串行通路,且多用戶端對Redis的連接配接并不存在競争關系。其次Redis提供一些指令SETNX,GETSET,可以友善實作分布式鎖機制。

Redis指令介紹

使用Redis實作分布式鎖,有兩個重要函數需要介紹

SETNX指令(SET if Not eXists)

文法:

SETNX key value

功能:

當且僅當 key 不存在,将 key 的值設為 value ,并傳回1;若給定的 key 已經存在,則 SETNX 不做任何動作,并傳回0。

GETSET指令

GETSET key value

将給定 key 的值設為 value ,并傳回 key 的舊值 (old value),當 key 存在但不是字元串類型時,傳回一個錯誤,當key不存在時,傳回nil。

GET指令

GET key

傳回 key 所關聯的字元串值,如果 key 不存在那麼傳回特殊值 nil 。

DEL指令

DEL key [KEY …]

删除給定的一個或多個 key ,不存在的 key 會被忽略。

兵貴精,不在多。分布式鎖,我們就依靠這四個指令。但在具體實作,還有很多細節,需要仔細斟酌,因為在分布式并發多程序中,任何一點出現差錯,都會導緻死鎖,hold住所有程序。

加鎖實作

SETNX 可以直接加鎖操作,比如說對某個關鍵詞foo加鎖,用戶端可以嘗試

SETNX foo.lock <current unix time>

如果傳回1,表示用戶端已經擷取鎖,可以往下操作,操作完成後,通過

DEL foo.lock

指令來釋放鎖。

如果傳回0,說明foo已經被其他用戶端上鎖,如果鎖是非堵塞的,可以選擇傳回調用。如果是堵塞調用調用,就需要進入以下個重試循環,直至成功獲得鎖或者重試逾時。理想是美好的,現實是殘酷的。僅僅使用SETNX加鎖帶有競争條件的,在某些特定的情況會造成死鎖錯誤。

處理死鎖

在上面的處理方式中,如果擷取鎖的用戶端端執行時間過長,程序被kill掉,或者因為其他異常崩潰,導緻無法釋放鎖,就會造成死鎖。是以,需要對加鎖要做時效性檢測。是以,我們在加鎖時,把目前時間戳作為value存入此鎖中,通過目前時間戳和Redis中的時間戳進行對比,如果超過一定內插補點,認為鎖已經時效,防止鎖無限期的鎖下去,但是,在大并發情況,如果同時檢測鎖失效,并簡單粗暴的删除死鎖,再通過SETNX上鎖,可能會導緻競争條件的産生,即多個用戶端同時擷取鎖。

C1擷取鎖,并崩潰。C2和C3調用SETNX上鎖傳回0後,獲得foo.lock的時間戳,通過比對時間戳,發現鎖逾時。

C2 向foo.lock發送DEL指令。

C2 向foo.lock發送SETNX擷取鎖。

C3 向foo.lock發送DEL指令,此時C3發送DEL時,其實DEL掉的是C2的鎖。

C3 向foo.lock發送SETNX擷取鎖。

此時C2和C3都擷取了鎖,産生競争條件,如果在更高并發的情況,可能會有更多用戶端擷取鎖。是以,DEL鎖的操作,不能直接使用在鎖逾時的情況下,幸好我們有GETSET方法,假設我們現在有另外一個用戶端C4,看看如何使用GETSET方式,避免這種情況産生。

C1擷取鎖,并崩潰。C2和C3調用SETNX上鎖傳回0後,調用GET指令獲得foo.lock的時間戳T1,通過比對時間戳,發現鎖逾時。

C4 向foo.lock發送GESET指令,

GETSET foo.lock <current unix time>

并得到foo.lock中老的時間戳T2

如果T1=T2,說明C4獲得時間戳。

如果T1!=T2,說明C4之前有另外一個用戶端C5通過調用GETSET方式擷取了時間戳,C4未獲得鎖。隻能sleep下,進入下次循環中。

現在唯一的問題是,C4設定foo.lock的新時間戳,是否會對鎖産生影響。其實我們可以看到C4和C5執行的時間內插補點極小,并且寫入foo.lock中的都是有效時間錯,是以對鎖并沒有影響。

為了讓這個鎖更加強壯,擷取鎖的用戶端,應該在調用關鍵業務時,再次調用GET方法擷取T1,和寫入的T0時間戳進行對比,以免鎖因其他情況被執行DEL意外解開而不知。以上步驟和情況,很容易從其他參考資料中看到。用戶端處理和失敗的情況非常複雜,不僅僅是崩潰這麼簡單,還可能是用戶端因為某些操作被阻塞了相當長時間,緊接着 DEL 指令被嘗試執行(但這時鎖卻在另外的用戶端手上)。也可能因為處理不當,導緻死鎖。還有可能因為sleep設定不合理,導緻Redis在大并發下被壓垮。最為常見的問題還有

GET傳回nil時應該走那種邏輯?

第一種走逾時邏輯

C1用戶端擷取鎖,并且處理完後,DEL掉鎖,在DEL鎖之前。C2通過SETNX向foo.lock設定時間戳T0 發現有用戶端擷取鎖,進入GET操作。

C2 向foo.lock發送GET指令,擷取傳回值T1(nil)。

C2 通過T0>T1+expire對比,進入GETSET流程。

C2 調用GETSET向foo.lock發送T0時間戳,傳回foo.lock的原值T2

C2 如果T2=T1相等,獲得鎖,如果T2!=T1,未獲得鎖。

第二種情況走循環走setnx邏輯

C2 循環,進入下一次SETNX邏輯

兩種邏輯貌似都是OK,但是從邏輯處理上來說,第一種情況存在問題。當GET傳回nil表示,鎖是被删除的,而不是逾時,應該走SETNX邏輯加鎖。走第一種情況的問題是,正常的加鎖邏輯應該走SETNX,而現在當鎖被解除後,走的是GETST,如果判斷條件不當,就會引起死鎖,很悲催,我在做的時候就碰到了,具體怎麼碰到的看下面的問題

GETSET傳回nil時應該怎麼處理?

C1和C2用戶端調用GET接口,C1傳回T1,此時C3網絡情況更好,快速進入擷取鎖,并執行DEL删除鎖,C2傳回T2(nil),C1和C2都進入逾時處理邏輯。

C1 向foo.lock發送GETSET指令,擷取傳回值T11(nil)。

C1 比對C1和C11發現兩者不同,處理邏輯認為未擷取鎖。

C2 向foo.lock發送GETSET指令,擷取傳回值T22(C1寫入的時間戳)。

C2 比對C2和C22發現兩者不同,處理邏輯認為未擷取鎖。

此時C1和C2都認為未擷取鎖,其實C1是已經擷取鎖了,但是他的處理邏輯沒有考慮GETSET傳回nil的情況,隻是單純的用GET和GETSET值就行對比,至于為什麼會出現這種情況?一種是多用戶端時,每個用戶端連接配接Redis的後,發出的指令并不是連續的,導緻從單用戶端看到的好像連續的指令,到Redis server後,這兩條指令之間可能已經插入大量的其他用戶端發出的指令,比如DEL,SETNX等。第二種情況,多用戶端之間時間不同步,或者不是嚴格意義的同步。

時間戳的問題

我們看到foo.lock的value值為時間戳,是以要在多用戶端情況下,保證鎖有效,一定要同步各伺服器的時間,如果各伺服器間,時間有差異。時間不一緻的用戶端,在判斷鎖逾時,就會出現偏差,進而産生競争條件。

鎖的逾時與否,嚴格依賴時間戳,時間戳本身也是有精度限制,假如我們的時間精度為秒,從加鎖到執行操作再到解鎖,一般操作肯定都能在一秒内完成。這樣的話,我們上面的CASE,就很容易出現。是以,最好把時間精度提升到毫秒級。這樣的話,可以保證毫秒級别的鎖是安全的。

分布式鎖的問題

1:必要的逾時機制:擷取鎖的用戶端一旦崩潰,一定要有過期機制,否則其他用戶端都降無法擷取鎖,造成死鎖問題。

2:分布式鎖,多用戶端的時間戳不能保證嚴格意義的一緻性,是以在某些特定因素下,有可能存在鎖串的情況。要适度的機制,可以承受小機率的事件産生。

3:隻對關鍵處理節點加鎖,良好的習慣是,把相關的資源準備好,比如連接配接資料庫後,調用加鎖機制擷取鎖,直接進行操作,然後釋放,盡量減少持有鎖的時間。

4:在持有鎖期間要不要CHECK鎖,如果需要嚴格依賴鎖的狀态,最好在關鍵步驟中做鎖的CHECK檢查機制,但是根據我們的測試發現,在大并發時,每一次CHECK鎖操作,都要消耗掉幾個毫秒,而我們的整個持鎖處理邏輯才不到10毫秒,玩客沒有選擇做鎖的檢查。

5:sleep學問,為了減少對Redis的壓力,擷取鎖嘗試時,循環之間一定要做sleep操作。但是sleep時間是多少是門學問。需要根據自己的Redis的QPS,加上持鎖處理時間等進行合理計算。

6:至于為什麼不使用Redis的muti,expire,watch等機制,可以查一參考資料,找下原因。

鎖測試資料

未使用sleep

第一種,鎖重試時未做sleep。單次請求,加鎖,執行,解鎖時間 

轉載:基于Redis實作分布式鎖

可以看到加鎖和解鎖時間都很快,當我們使用

ab -n1000 -c100 'http://sandbox6.wanke.etao.com/test/test_sequence.php?tbpm=t'

AB 并發100累計1000次請求,對這個方法進行壓測時。 

轉載:基于Redis實作分布式鎖

我們會發現,擷取鎖的時間變成,同時持有鎖後,執行時間也變成,而delete鎖的時間,将近10ms時間,為什麼會這樣?

1:持有鎖後,我們的執行邏輯中包含了再次調用Redis操作,在大并發情況下,Redis執行明顯變慢。

2:鎖的删除時間變長,從之前的0.2ms,變成9.8ms,性能下降近50倍。

在這種情況下,我們壓測的QPS為49,最終發現QPS和壓測總量有關,當我們并發100總共100次請求時,QPS得到110多。當我們使用sleep時

使用Sleep時

單次執行請求時

轉載:基于Redis實作分布式鎖

我們看到,和不使用sleep機制時,性能相當。當時用相同的壓測條件進行壓縮時 

轉載:基于Redis實作分布式鎖

擷取鎖的時間明顯變長,而鎖的釋放時間明顯變短,僅是不采用sleep機制的一半。當然執行時間變成就是因為,我們在執行過程中,重新建立資料庫連接配接,導緻時間變長的。同時我們可以對比下Redis的指令執行壓力情況 

轉載:基于Redis實作分布式鎖

上圖中細高部分是為未采用sleep機制的時的壓測圖,矮胖部分為采用sleep機制的壓測圖,通上圖看到壓力減少50%左右,當然,sleep這種方式還有個缺點QPS下降明顯,在我們的壓測條件下,僅為35,并且有部分請求出現逾時情況。不過綜合各種情況後,我們還是決定采用sleep機制,主要是為了防止在大并發情況下把Redis壓垮,很不行,我們之前碰到過,是以肯定會采用sleep機制。

參考資料

<a href="http://www.worlduc.com/FileSystem/18/2518/590664/9f63555e6079482f831c8ab1dcb8c19c.pdf" target="_blank">http://www.worlduc.com/FileSystem/18/2518/590664/9f63555e6079482f831c8ab1dcb8c19c.pdf</a>

<a href="http://redis.io/commands/setnx" target="_blank">http://redis.io/commands/setnx</a>

<a href="http://www.blogjava.net/caojianhua/archive/2013/01/28/394847.html" target="_blank">http://www.blogjava.net/caojianhua/archive/2013/01/28/394847.html</a>

繼續閱讀