在不少互聯網產品應用中,有些場景須要加鎖處理,好比:秒殺,全局遞增ID,樓層生成等等。大部分的解決方案是基於DB實現的,Redis爲單進程單線程模式,採用隊列模式將併發訪問變成串行訪問,且多客戶端對Redis的鏈接並不存在競爭關係。其次Redis提供一些命令SETNX,GETSET,能夠方便實現分佈式鎖機制。php
使用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在大併發下被壓垮。最爲常見的問題還有
第一種走超時邏輯
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邏輯
C1客戶端獲取鎖,而且處理完後,DEL掉鎖,在DEL鎖以前。C2經過SETNX向foo.lock設置時間戳T0 發現有客戶端獲取鎖,進入GET操做。
C2 向foo.lock發送GET命令,獲取返回值T1(nil)。
C2 循環,進入下一次SETNX邏輯
兩種邏輯貌似都是OK,可是從邏輯處理上來講,第一種狀況存在問題。當GET返回nil表示,鎖是被刪除的,而不是超時,應該走SETNX邏輯加鎖。走第一種狀況的問題是,正常的加鎖邏輯應該走SETNX,而如今當鎖被解除後,走的是GETST,若是判斷條件不當,就會引發死鎖,很悲催,我在作的時候就碰到了,具體怎麼碰到的看下面的問題
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。單次請求,加鎖,執行,解鎖時間
能夠看到加鎖和解鎖時間都很快,當咱們使用
ab -n1000 -c100 'http://sandbox6.wanke.etao.com/test/test_sequence.php?tbpm=t'
AB 併發100累計1000次請求,對這個方法進行壓測時。
咱們會發現,獲取鎖的時間變成,同時持有鎖後,執行時間也變成,而delete鎖的時間,將近10ms時間,爲何會這樣?
1:持有鎖後,咱們的執行邏輯中包含了再次調用Redis操做,在大併發狀況下,Redis執行明顯變慢。
2:鎖的刪除時間變長,從以前的0.2ms,變成9.8ms,性能降低近50倍。
在這種狀況下,咱們壓測的QPS爲49,最終發現QPS和壓測總量有關,當咱們併發100總共100次請求時,QPS獲得110多。當咱們使用sleep時
單次執行請求時
咱們看到,和不使用sleep機制時,性能至關。當時用相同的壓測條件進行壓縮時
獲取鎖的時間明顯變長,而鎖的釋放時間明顯變短,僅是不採用sleep機制的一半。固然執行時間變成就是由於,咱們在執行過程當中,從新建立數據庫鏈接,致使時間變長的。同時咱們能夠對比下Redis的命令執行壓力狀況
上圖中細高部分是爲未採用sleep機制的時的壓測圖,矮胖部分爲採用sleep機制的壓測圖,通上圖看到壓力減小50%左右,固然,sleep這種方式還有個缺點QPS降低明顯,在咱們的壓測條件下,僅爲35,而且有部分請求出現超時狀況。不過綜合各類狀況後,咱們仍是決定採用sleep機制,主要是爲了防止在大併發狀況下把Redis壓垮,很不行,咱們以前碰到過,因此確定會採用sleep機制。