redis分布式鎖
單機版本
為什麼要使用鎖?
第一個是正确性,這個衆人皆知。就像Java裡的synchronize,就是用來保證多線程并發場景下,程式的正确性。JVM裡需要保證并發通路的正确性,在分布式系統裡面,也同樣需要,隻不過并發通路的機關,不再是線程,而是程序。
舉個例子,一個檔案系統,為了提高性能,部署了三台檔案伺服器。
當伺服器A在修改檔案A的時候,其他伺服器就不能對檔案A進行修改,否則A的修改就會被覆寫掉。
鎖還有第二個用處——效率。比如應用A有一個耗時的統計任務,每天淩晨兩點,定時執行,這時我們給應用A部署了三台機器,如果不加鎖,那麼每天淩晨兩點一到,這三台機器就都會去執行這個很耗時的統計任務,而實際上,我們最後隻需要一份統計結果。這時候,就可以在定時任務開始前,先去擷取鎖,擷取到鎖的,執行統計任務,擷取不到的,該幹嘛幹嘛去。
分布式鎖和本地鎖的差別?
就像上面說的,單機,并發的機關是線程,分布式,并發的機關是多程序。
并發機關的等級上去了,鎖的等級自然也得上去。
以前鎖是程序自己的,程序下的線程都看這個鎖的眼色行事,誰拿到鎖,誰才可以放行。
程序外面還有别的程序,你要跟别人合作,就不能光看着自己了,得有一個大家都看得到的,光明正大的地方,來放這把鎖。有不少适合放這把鎖的地方,redis、zookeeper、etcd等等
擷取鎖
要怎麼在redis裡擷取一把鎖?
很簡單,執行set指令就好了,還是上面檔案系統的例子,比如你想修改檔案id是9527的檔案,那就往redis裡,添加一個key為file:9257,value為任意字元串的值即可:
set成功了,就說明擷取到鎖。
這樣可以嗎?很明顯不行,set方法預設是會覆寫的,也就是說,就算file:9527已經有值了,set還是可以成功,這樣鎖就起不到互斥的作用。
那在set之前,先用get判斷一下,如果是null,再去set?
也不行,原因很簡單,get和set都在用戶端執行,不具有原子性。
要實作原子性,唯一的辦法,就是隻給redis發送一條指令,來完成擷取鎖的動作。
于是就有了下面這條指令:
NX=If Not Existed
如果不存在,才執行set
完美了嗎?非也,這個值沒有設定過期時間,如果後面獲得鎖的用戶端,因為挂掉了,或者其他原因,沒有釋放鎖,那其他程序也都擷取不到鎖了,結果就是死鎖。
是以有了終極版的擷取鎖指令:
使用EX參數,可以設定過期時間,機關是秒,另一個參數PX,也可以設定過期時間,機關是毫秒。
上述的指令會拆解成兩條指令,一條set設定值,一條expire設定過期時間,是以實際上,服務端還是分兩次執行這條指令,是以還是不滿足原子性。
然而,redis是單線程處理指令的,是以,在redis執行這段函數的過程中,不可能有精力去執行其他函數,是以,就算是分成兩個動作去執行,也不影響。
釋放鎖
最後再來看看釋放鎖。
有人說,釋放鎖,簡單,直接del:
有問題嗎?當然有,這會把别人的鎖給釋放掉。
舉個例子:
- A拿到了鎖,過期時間5s
- 5s過去了,A還沒釋放鎖,也許是發生了GC,也許是某個耗時操作。
- 鎖過期了,B搶到了鎖
- A緩過神來了,以為鎖還是自己的,執行del file:9527
- C搶到了鎖,也進來了
- B看看屋裡的C,有看看剛出門的A,對着A吼了一句:尼瑪,你幹嘛把我的鎖釋放了
是以,為了防止把别人的鎖釋放了,必須檢查一下,目前的value是不是自己設定進去的value,如果不是,就說明鎖不是自己的了,不能釋放。
顯然,這個過程,如果放在用戶端做,就又不滿足原子性了,隻能整在一起,一次性讓redis server執行完。
這下redis可沒有一條指令,可以做這麼多事情的,好在redis提供了lua腳本的調用方式,隻需使用eval指令調用以下腳本即可:
那麼redis在執行lua腳本時,是原子的嗎?答案當然是肯定的:
單機版實作的局限性
大多數生産環境,都不可能隻部署一個Redis,至少是主從架構:
更多的是主從+分片的架構
當然主從架構也可以進化為一主多架構乃至主從鍊架構(Master-Salve Chain):
而其實在主從架構下,之前那套分布式鎖的機制,就已經失效了,原因正如之前說的:
Redloc算法
針對Redis叢集架構,redis的作者antirez提出了Redlock算法,來實作叢集架構下的分布式鎖。
Redlock算法并不複雜,假設我們Redis分片下,有三個Master的節點,這三個Master,又各自有一個Slave:
好,現在用戶端想擷取一把分布式鎖:
記下開始擷取鎖的時間 startTime
按照A->B->C的順序,依次向這三台Master發送擷取鎖的指令。用戶端在等待每台Master回響應時,都有逾時時間timeout。舉個例子,用戶端向A發送擷取鎖的指令,在等了timeout時間之後,都沒收到響應,就會認為擷取鎖失敗,繼續嘗試擷取下一把鎖
如果擷取到超過半數的鎖,也就是 3/2+1 = 2把鎖,這時候還沒完,要記下目前時間endTime
計算拿到這些鎖花費的時間 costTime = endTime - startTime,如果costTime小于鎖的過期時間expireTime,則認為擷取鎖成功
如果擷取不到超過一半的鎖,或者拿到超過一半的鎖時,計算出costTime>=expireTime,這兩種情況下,都視為擷取鎖失敗
如果擷取鎖失敗,需要向全部Master節點,都發生釋放鎖的指令,也就是那段Lua腳本
追問Redlock
1、為什麼要給每個擷取鎖的請求設定timeout
為了防止在某個出了問題的Master節點上,浪費太多時間。一旦逾時了,馬上嘗試下一個。
2、擷取了過半數的鎖之後,還要不要繼續擷取
這個沒有限制。
你可以選擇适可而止,這樣可以提高擷取鎖的速度,總共三台,A和B都拿到了,就不必去拿C了。
3、如果costTime隻比expireTime小一點點,會不會有問題?
當然有問題,這樣你前腳剛拿到鎖,走進門,後腳分布式鎖就過期了,别人也拿到鎖,進門了,互斥性被打破。
解決辦法是,每個請求的timeout要比expireTime小很多,比如你的expireTime是10s,那麼timeout可以設定為50ms,這樣costTime最多也就50*3=150ms,剩下的9850ms,這九秒多鐘,你都可以用來執行代碼,保證不會有其他程序可以進入。
當然,如果你的代碼執行了9850ms還沒執行完,那别的程序還是可以搶到鎖。這也是一個暫時無解的問題。
4、釋放鎖時,為什麼不能隻向成功擷取到鎖的Master發送釋放指令,而要向所有的Master節點發送
很簡單,假設你向Master A發送了擷取鎖的指令,set指令執行成功了,但是在回響應時發送了故障,響應沒發回來,過了逾時時間後,你會認為擷取鎖失敗,而實際上,鎖已經在redis那邊生效了。
是以在釋放鎖的時候,必須向全部節點都發生指令,不管你到底有沒有在那節點上面擷取到鎖。
5、如果有節點crash,鎖不也還是會丢失嗎?
的确,單機時候的問題,在叢集依然存在。
Redlock算法,在有節點重新開機或者crash的情況下,也會有可能無法達到互斥的目的。
假設有三個節點ABC:
- 程序1在B和C上拿到了鎖
- 這時候B crash了
- 如果B沒有Slave節點,那麼B會重新開機,如果資料還沒備份,那麼重新開機後B上的鎖就丢了
- 又或者B有Slave節點,但是crash時,Master B的資料還沒同步到Slave,Slave被提拔為Master
- 不管有沒有Slave,其他程序都有可能在B crash掉之後,在B上拿到鎖,再加上在A拿到的鎖,就可以拿到超過半數的鎖,這樣就有兩個程序同時拿到了鎖,互斥性被打破
對于上面這個問題,Redis的作者,同時也是Redlock的作者antirez,提出了delay的解決方案,就是讓B别那麼快重新開機,稍微等一下,等的時間,就是分布式鎖的最大過期時間,等到其他節點上的鎖都過期了,你再重新開機,對外提供服務。
對于有Slave的情況,也可以用類似的方案,Slave先别那麼快接替Master,稍微等一下下。
6、會不會有鎖饑餓的問題?
還是三台Master節點,現在有三個程序同時要加同一把鎖,會不會出現每次都是一個程序搶到一把鎖的情況?
這是有可能的。
解決辦法1:
擷取鎖失敗後,随機休息一段時間
解決辦法2:
如果用戶端在發現,就算後面全部的鎖,都被我搶到,加起來也不能超過半數,這時候就不再繼續往下搶。
舉個例子,程序1搶到了節點A的鎖,程序2搶到節點B的,這時候程序3想過來搶鎖,按照ABC的順序,逐個搶,A和B都搶不過别人,于是掐指一算,就算C讓我搶到了,我也搶不到超過半數了,沒必要繼續搶了,我還是先嘗試搶一下A吧。
這樣就不會出現三把鎖,分别被三個不同的程序搶的情況了。
Redisson(一個Java的redis用戶端)在實作redlock時就采用了這個解決方案。
Redlock實作:Redisson
上面講的隻是Redlock的算法,具體怎麼用代碼來實作,可以看redlock各種語言的用戶端源碼,比如Java的實作,就可以看看Redisson。