大家所推崇的Redis分布式鎖真的就萬無一失嗎?
在單實例JVM中,常見的處理并發(fā)問題的方法有很多,比如synchronized關(guān)鍵字進(jìn)行訪問控制、volatile關(guān)鍵字、ReentrantLock等常用方法。但是在分布式環(huán)境中,上述方法卻不能在跨JVM場景中用于處理并發(fā)問題,當(dāng)業(yè)務(wù)場景需要對分布式環(huán)境中的并發(fā)問題進(jìn)行處理時,需要使用分布式鎖來實現(xiàn)。
分布式鎖,是指在分布式的部署環(huán)境下,通過鎖機制來讓多客戶端互斥的對共享資源進(jìn)行訪問。
目前比較常見的分布式鎖實現(xiàn)方案有以下幾種:
- 基于數(shù)據(jù)庫,如MySQL
- 基于緩存,如Redis
- 基于Zookeeper、etcd等。
這里介紹一下如何使用緩存(Redis)實現(xiàn)分布式鎖。
使用Redis實現(xiàn)分布式鎖最簡單的方案是使用命令SETNX。SETNX(SET if Not eXist)的使用方式為:SETNX key value,只在鍵key不存在的情況下,將鍵key的值設(shè)置為value,若鍵key存在,則SETNX不做任何動作。SETNX在設(shè)置成功時返回,設(shè)置失敗時返回0。當(dāng)要獲取鎖時,直接使用SETNX獲取鎖,當(dāng)要釋放鎖時,使用DEL命令刪除掉對應(yīng)的鍵key即可。
上面這種方案有一個致命問題,就是某個線程在獲取鎖之后由于某些異常因素(比如宕機)而不能正常的執(zhí)行解鎖操作,那么這個鎖就永遠(yuǎn)釋放不掉了。為此,我們可以為這個鎖加上一個超時時間。***時間我們會聯(lián)想到Redis的EXPIRE命令(EXPIRE key seconds)。但是這里我們不能使用EXPIRE來實現(xiàn)分布式鎖,因為它與SETNX一起是兩個操作,在這兩個操作之間可能會發(fā)生異常,從而還是達(dá)不到預(yù)期的結(jié)果,示例如下:
- // STEP 1
- SETNX key value
- // 若在這里(STEP1和STEP2之間)程序突然崩潰,則無法設(shè)置過期時間,將有可能無法釋放鎖
- // STEP 2
- EXPIRE key expireTime
對此,正確的姿勢應(yīng)該是使用“SET key value [EX seconds] [PX milliseconds] [NX|XX]”這個命令。
從 Redis 2.6.12 版本開始, SET 命令的行為可以通過一系列參數(shù)來修改:
- EX seconds : 將鍵的過期時間設(shè)置為 seconds 秒。 執(zhí)行 SET key value EX seconds 的效果等同于執(zhí)行 SETEX key seconds value 。
- PX milliseconds : 將鍵的過期時間設(shè)置為 milliseconds 毫秒。 執(zhí)行 SET key value PX milliseconds 的效果等同于執(zhí)行 PSETEX key milliseconds value 。
- NX : 只在鍵不存在時, 才對鍵進(jìn)行設(shè)置操作。 執(zhí)行 SET key value NX 的效果等同于執(zhí)行 SETNX key value 。
- XX : 只在鍵已經(jīng)存在時, 才對鍵進(jìn)行設(shè)置操作。
舉例,我們需要創(chuàng)建一個分布式鎖,并且設(shè)置過期時間為10s,那么可以執(zhí)行以下命令:
- SET lockKey lockValue EX 10 NX
- 或者
- SET lockKey lockValue PX 10000 NX
注意EX和PX不能同時使用,否則會報錯:ERR syntax error。
解鎖的時候還是使用DEL命令來解鎖。
修改之后的方案看上去很***,但實際上還是會有問題。試想一下,某線程A獲取了鎖并且設(shè)置了過期時間為10s,然后在執(zhí)行業(yè)務(wù)邏輯的時候耗費了15s,此時線程A獲取的鎖早已被Redis的過期機制自動釋放了。在線程A獲取鎖并經(jīng)過10s之后,改鎖可能已經(jīng)被其它線程獲取到了。當(dāng)線程A執(zhí)行完業(yè)務(wù)邏輯準(zhǔn)備解鎖(DEL key)的時候,有可能刪除掉的是其它線程已經(jīng)獲取到的鎖。
所以***的方式是在解鎖時判斷鎖是否是自己的。我們可以在設(shè)置key的時候?qū)alue設(shè)置為一個唯一值uniqueValue(可以是隨機值、UUID、或者機器號+線程號的組合、簽名等)。當(dāng)解鎖時,也就是刪除key的時候先判斷一下key對應(yīng)的value是否等于先前設(shè)置的值,如果相等才能刪除key,偽代碼示例如下:
- if uniqueKey == GET(key) {
- DEL key
- }
這里我們一眼就可以看出問題來:GET和DEL是兩個分開的操作,在GET執(zhí)行之后且在DEL執(zhí)行之前的間隙是可能會發(fā)生異常的。如果我們只要保證解鎖的代碼是原子性的就能解決問題了。這里我們引入了一種新的方式,就是Lua腳本,示例如下:
- if redis.call("get",KEYS[1]) == ARGV[1] then
- return redis.call("del",KEYS[1])
- else
- return 0
- end
其中ARGV[1]表示設(shè)置key時指定的唯一值。
由于Lua腳本的原子性,在Redis執(zhí)行該腳本的過程中,其他客戶端的命令都需要等待該Lua腳本執(zhí)行完才能執(zhí)行。
下面我們使用Jedis來演示一下獲取鎖和解鎖的實現(xiàn),具體如下:
- public boolean lock(String lockKey, String uniqueValue, int seconds){
- SetParams params = new SetParams();
- params.nx().ex(seconds);
- String result = jedis.set(lockKey, uniqueValue, params);
- if ("OK".equals(result)) {
- return true;
- }
- return false;
- }
- public boolean unlock(String lockKey, String uniqueValue){
- String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
- "then return redis.call('del', KEYS[1]) else return 0 end";
- Object result = jedis.eval(script,
- Collections.singletonList(lockKey),
- Collections.singletonList(uniqueValue));
- if (result.equals(1)) {
- return true;
- }
- return false;
- }
如此就萬無一失了嗎?顯然不是!
表面來看,這個方法似乎很管用,但是這里存在一個問題:在我們的系統(tǒng)架構(gòu)里存在一個單點故障,如果Redis的master節(jié)點宕機了怎么辦呢?有人可能會說:加一個slave節(jié)點!在master宕機時用slave就行了!
但是其實這個方案明顯是不可行的,因為Redis的復(fù)制是異步的。舉例來說:
- 線程A在master節(jié)點拿到了鎖。
- master節(jié)點在把A創(chuàng)建的key寫入slave之前宕機了。
- slave變成了master節(jié)點。
- 線程B也得到了和A還持有的相同的鎖。(因為原來的slave里面還沒有A持有鎖的信息)
當(dāng)然,在某些場景下這個方案沒有什么問題,比如業(yè)務(wù)模型允許同時持有鎖的情況,那么使用這種方案也未嘗不可。
舉例說明,某個服務(wù)有2個服務(wù)實例:A和B,初始情況下A獲取了鎖然后對資源進(jìn)行操作(可以假設(shè)這個操作很耗費資源),B沒有獲取到鎖而不執(zhí)行任何操作,此時B可以看做是A的熱備。當(dāng)A出現(xiàn)異常時,B可以“轉(zhuǎn)正”。當(dāng)鎖出現(xiàn)異常時,比如Redis master宕機,那么B可能會同時持有鎖并且對資源進(jìn)行操作,如果操作的結(jié)果是冪等的(或者其它情況),那么也可以使用這種方案。這里引入分布式鎖可以讓服務(wù)在正常情況下避免重復(fù)計算而造成資源的浪費。
為了應(yīng)對這種情況,antriez提出了Redlock算法。Redlock算法的主要思想是:假設(shè)我們有N個Redis master節(jié)點,這些節(jié)點都是完全獨立的,我們可以運用前面的方案來對前面單個的Redis master節(jié)點來獲取鎖和解鎖,如果我們總體上能在合理的范圍內(nèi)或者N/2+1個鎖,那么我們就可以認(rèn)為成功獲得了鎖,反之則沒有獲取鎖(可類比Quorum模型)。雖然Redlock的原理很好理解,但是其內(nèi)部的實現(xiàn)細(xì)節(jié)很是復(fù)雜,要考慮很多因素
Redlock算法也并非是“銀彈”,他除了條件有點苛刻外,其算法本身也被質(zhì)疑。關(guān)于Redis分布式鎖的安全性問題,在分布式系統(tǒng)專家Martin Kleppmann和Redis的作者antirez之間就發(fā)生過一場爭論。