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