大家所推崇的Redis分佈式鎖真的就萬無一失嗎?

在單實例JVM中,常見的處理併發問題的方法有很多,比如synchronized關鍵字進行訪問控制、volatile關鍵字、ReentrantLock等常用方法。但是在分佈式環境中,上述方法卻不能在跨JVM場景中用於處理併發問題,當業務場景需要對分佈式環境中的併發問題進行處理時,需要使用分佈式鎖來實現。

分佈式鎖,是指在分佈式的部署環境下,通過鎖機制來讓多客戶端互斥的對共享資源進行訪問。

目前比較常見的分佈式鎖實現方案有以下幾種:

  1. 基於數據庫,如MySQL
  2. 基於緩存,如Redis
  3. 基於Zookeeper、etcd等。

在上一篇《基於數據庫實現分佈式鎖》中介紹瞭如何基於數據庫實現分佈式鎖,這裏介紹一下如何使用緩存(Redis)實現分佈式鎖。

使用Redis實現分佈式鎖最簡單的方案是使用命令SETNX。SETNX(SET if Not eXist)的使用方式爲:SETNX key value,只在鍵key不存在的情況下,將鍵key的值設置爲value,若鍵key存在,則SETNX不做任何動作。SETNX在設置成功時返回,設置失敗時返回0。當要獲取鎖時,直接使用SETNX獲取鎖,當要釋放鎖時,使用DEL命令刪除掉對應的鍵key即可。

上面這種方案有一個致命問題,就是某個線程在獲取鎖之後由於某些異常因素(比如宕機)而不能正常的執行解鎖操作,那麼這個鎖就永遠釋放不掉了。爲此,我們可以爲這個鎖加上一個超時時間。第一時間我們會聯想到Redis的EXPIRE命令(EXPIRE key seconds)。但是這裏我們不能使用EXPIRE來實現分佈式鎖,因爲它與SETNX一起是兩個操作,在這兩個操作之間可能會發生異常,從而還是達不到預期的結果,示例如下:

// STEP 1
SETNX key value
// 若在這裏(STEP1和STEP2之間)程序突然崩潰,則無法設置過期時間,將有可能無法釋放鎖
// STEP 2
EXPIRE key expireTime
  • 1
  • 2
  • 3
  • 4
  • 5

對此,正確的姿勢應該是使用“SET key value [EX seconds] [PX milliseconds] [NX|XX]”這個命令。

從 Redis 2.6.12 版本開始, SET 命令的行爲可以通過一系列參數來修改:

  • EX seconds : 將鍵的過期時間設置爲 seconds 秒。 執行 SET key value EX seconds 的效果等同於執行 SETEX key seconds value 。
  • PX milliseconds : 將鍵的過期時間設置爲 milliseconds 毫秒。 執行 SET key value PX milliseconds 的效果等同於執行 PSETEX key milliseconds value 。
  • NX : 只在鍵不存在時, 纔對鍵進行設置操作。 執行 SET key value NX 的效果等同於執行 SETNX key value 。
  • XX : 只在鍵已經存在時, 纔對鍵進行設置操作。

舉例,我們需要創建一個分佈式鎖,並且設置過期時間爲10s,那麼可以執行以下命令:

SET lockKey lockValue EX 10 NX
或者
SET lockKey lockValue PX 10000 NX
  • 1
  • 2
  • 3

注意EX和PX不能同時使用,否則會報錯:ERR syntax error。

解鎖的時候還是使用DEL命令來解鎖。

修改之後的方案看上去很完美,但實際上還是會有問題。試想一下,某線程A獲取了鎖並且設置了過期時間爲10s,然後在執行業務邏輯的時候耗費了15s,此時線程A獲取的鎖早已被Redis的過期機制自動釋放了。在線程A獲取鎖並經過10s之後,改鎖可能已經被其它線程獲取到了。當線程A執行完業務邏輯準備解鎖(DEL key)的時候,有可能刪除掉的是其它線程已經獲取到的鎖。

所以最好的方式是在解鎖時判斷鎖是否是自己的。我們可以在設置key的時候將value設置爲一個唯一值uniqueValue(可以是隨機值、UUID、或者機器號+線程號的組合、簽名等)。當解鎖時,也就是刪除key的時候先判斷一下key對應的value是否等於先前設置的值,如果相等才能刪除key,僞代碼示例如下:

if uniqueKey == GET(key) {
	DEL key
}
  • 1
  • 2
  • 3

這裏我們一眼就可以看出問題來:GET和DEL是兩個分開的操作,在GET執行之後且在DEL執行之前的間隙是可能會發生異常的。如果我們只要保證解鎖的代碼是原子性的就能解決問題了。這裏我們引入了一種新的方式,就是Lua腳本,示例如下:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
  • 1
  • 2
  • 3
  • 4
  • 5

其中ARGV[1]表示設置key時指定的唯一值。

由於Lua腳本的原子性,在Redis執行該腳本的過程中,其他客戶端的命令都需要等待該Lua腳本執行完才能執行。

下面我們使用Jedis來演示一下獲取鎖和解鎖的實現,具體如下:

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;
}

如此就萬無一失了嗎?顯然不是!

表面來看,這個方法似乎很管用,但是這裏存在一個問題:在我們的系統架構裏存在一個單點故障,如果Redis的master節點宕機了怎麼辦呢?有人可能會說:加一個slave節點!在master宕機時用slave就行了!

但是其實這個方案明顯是不可行的,因爲Redis的複製是異步的。舉例來說:

  1. 線程A在master節點拿到了鎖。
  2. master節點在把A創建的key寫入slave之前宕機了。
  3. slave變成了master節點。
  4. 線程B也得到了和A還持有的相同的鎖。(因爲原來的slave裏面還沒有A持有鎖的信息)

當然,在某些場景下這個方案沒有什麼問題,比如業務模型允許同時持有鎖的情況,那麼使用這種方案也未嘗不可。

舉例說明,某個服務有2個服務實例:A和B,初始情況下A獲取了鎖然後對資源進行操作(可以假設這個操作很耗費資源),B沒有獲取到鎖而不執行任何操作,此時B可以看做是A的熱備。當A出現異常時,B可以“轉正”。當鎖出現異常時,比如Redis master宕機,那麼B可能會同時持有鎖並且對資源進行操作,如果操作的結果是冪等的(或者其它情況),那麼也可以使用這種方案。這裏引入分佈式鎖可以讓服務在正常情況下避免重複計算而造成資源的浪費。

爲了應對這種情況,antriez提出了Redlock算法。Redlock算法的主要思想是:假設我們有N個Redis master節點,這些節點都是完全獨立的,我們可以運用前面的方案來對前面單個的Redis master節點來獲取鎖和解鎖,如果我們總體上能在合理的範圍內或者N/2+1個鎖,那麼我們就可以認爲成功獲得了鎖,反之則沒有獲取鎖(可類比Quorum模型)。雖然Redlock的原理很好理解,但是其內部的實現細節很是複雜,要考慮很多因素,具體內容可以參考:https://redis.io/topics/distlock。 有關Redlock的具體使用方式可以參考我之前轉載的兩篇文章《Redis分佈式鎖最牛逼的實現》和《Redission實現Redis分佈式鎖的N種姿勢》。

Redlock算法也並非是“銀彈”,他除了條件有點苛刻外,其算法本身也被質疑。關於Redis分佈式鎖的安全性問題,在分佈式系統專家Martin Kleppmann和Redis的作者antirez之間就發生過一場爭論。這場爭論的內容大致如下:

Martin Kleppmann發表了一篇blog,名字叫”How to do distributed locking “,地址爲:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html。 Martin在這篇文章中談及了分佈式系統的很多基礎性的問題(特別是分佈式計算的異步模型),對分佈式系統的從業者來說非常值得一讀。

Martin的那篇文章是在2016-02-08這一天發表的,但據Martin說,他在公開發表文章的一星期之前就把草稿發給了antirez進行review,而且他們之間通過email進行了討論。不知道Martin有沒有意料到,antirez對於此事的反應很快,就在Martin的文章發表出來的第二天,antirez就在他的博客上貼出了他對於此事的反駁文章,名字叫”Is Redlock safe?”,地址爲http://antirez.com/news/101。

這是高手之間的過招。antirez這篇文章也條例非常清晰,並且中間涉及到大量的細節。antirez認爲,Martin的文章對於Redlock的批評可以概括爲兩個方面(與Martin文章的前後兩部分對應):

  • 帶有自動過期功能的分佈式鎖,必須提供某種fencing機制來保證對共享資源的真正的互斥保護。Redlock提供不了這樣一種機制。
  • Redlock構建在一個不夠安全的系統模型之上。它對於系統的記時假設(timing assumption)有比較強的要求,而這些要求在現實的系統中是無法保證的。

antirez對這兩方面分別進行了反駁。

首先,關於fencing機制。antirez對於Martin的這種論證方式提出了質疑:既然在鎖失效的情況下已經存在一種fencing機制能繼續保持資源的互斥訪問了,那爲什麼還要使用一個分佈式鎖並且還要求它提供那麼強的安全性保證呢?即使退一步講,Redlock雖然提供不了Martin所講的遞增的fencing token,但利用Redlock產生的隨機字符串(my_random_value)可以達到同樣的效果。這個隨機字符串雖然不是遞增的,但卻是唯一的,可以稱之爲unique token。

然後,antirez的反駁就集中在第二個方面上:關於算法在記時(timing)方面的模型假設。在我們前面分析Martin的文章時也提到過,Martin認爲Redlock會失效的情況主要有三種:1. 時鐘發生跳躍;2. 長時間的GC pause;3. 長時間的網絡延遲。

antirez肯定意識到了這三種情況對Redlock最致命的其實是第一點:時鐘發生跳躍。這種情況一旦發生,Redlock是沒法正常工作的。而對於後兩種情況來說,Redlock在當初設計的時候已經考慮到了,對它們引起的後果有一定的免疫力。所以,antirez接下來集中精力來說明通過恰當的運維,完全可以避免時鐘發生大的跳動,而Redlock對於時鐘的要求在現實系統中是完全可以滿足的。

神仙打架,我們站旁邊看看就好。拋開這個層面而言,在理解Redlock算法時要理解“各個節點完全獨立”這個概念。Redis本身有幾種部署模式:單機模式、主從模式、哨兵模式、集羣模式。比如採用集羣模式部署,如果需要5個節點,那麼就需要部署5個Redis Cluster集羣。很顯然,這種要求每個master節點都獨立的Redlock算法條件有點苛刻,使用它所需要耗費的資源比較多,而且對每個節點都請求一次鎖所帶來的額外開銷也不可忽視。除非有實實在在的業務應用需求,或者有資源可以複用。

使用Redis分佈式鎖並不能做到萬無一失。一般而言,Redis分佈式鎖的優勢在於性能,而如果要考慮到可靠性,那麼Zookeeper、etcd這類的組件會比Redis要高。當然,在合適的環境下使用基於數據庫實現的分佈式鎖會更合適,參考《基於數據庫實現分佈式鎖》。

不過就以可靠性而言,沒有任何組件是完全可靠的,程序員的價值不僅僅在於表象地如何靈活運用這些組件,而在於如何基於這些不可靠的組件構建一個可靠的系統。

還是那句老話,選擇何種方案,合適最重要。

參考資料:

  1. https://redis.io/topics/distlock
  2. https://www.jianshu.com/p/7e47a4503b87
  3. http://ifeve.com/redis-lock/
  4. https://www.cnblogs.com/linjiqin/p/8003838.html
  5. http://zhangtielei.com/posts/blog-redlock-reasoning.html
  6. http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html
原博客鏈接:https://blog.csdn.net/u013256816/article/details/93305532
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章