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