Redis、Zookeeper實現分散式鎖——原理與實踐

binecy發表於2021-11-30

Redis與分散式鎖的問題已經是老生常談了,本文嘗試總結一些Redis、Zookeeper實現分散式鎖的常用方案,並提供一些比較好的實踐思路(基於Java)。不足之處,歡迎探討。

Redis分散式鎖

單機Redis下實現分散式鎖

方案1:使用SET命令。
假如當前客戶端需要佔有一個user_lock的鎖,它首次需要生成一個token(一個隨機字串,例如uiid),並使用該token進行加鎖。

加鎖命令:

redis> SET user_lock <token> EX 15 NX
OK

EX:該鍵會在指定時間後指定過期,單位為秒,類似引數還有PX、EXAT、PXAT。
NX:只有該鍵不存在的時候才會設定key的值。

所以如果user_lock鍵不存在,上面Redis命令會成功建立該Redis鍵,並設定該鍵在15秒後過期。
而其他客戶端也使用該命令進行加鎖,在這15秒時間內,其他客戶端加鎖失敗(NX引數保證了該Redis鍵存在時命令執行失敗)。
所以,當前客戶端中鎖定了user_lock,鎖的有效時間為15秒。

為什麼要使用token、有效期呢?有以下原因:
(1)鎖的有效期可以保證不會發生死鎖的情況。通常佔有鎖的客戶端操作完成後需要釋放鎖(刪除Redis鍵),使用鎖有效期後,即使佔有鎖的客戶端故障下線,15秒後鎖也會自動失效,其他客戶端就可以搶佔該鎖,不會出現死鎖的情況。
(2)token的作用是防止客戶端釋放了不是自己佔有的鎖。客戶端釋放鎖時需要檢測該鎖當前是否為自己所佔有,即鍵user_lock的值是否為自己的token,如果是才可以刪除該鍵。
這裡涉及兩個命令,可以lua指令碼保證原子性。如下面命令:

> EVAL "if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end" 1 user_lock <token>
(integer) 1

如果不使用token,所以客戶端都使用同一個值作為鍵user_lock的值,假如客戶端A佔有了鎖user_lock,但由於過期時間到了,user_lock鍵被Redis伺服器刪除,這時客戶端B佔有了鎖。而客戶端A操作後,直接使用DEL命令刪除當前user_lock鍵,這樣客戶端A就刪除了非自己佔有的鎖。

該方案可參考官方文件:https://redis.io/commands/set

從上面內容可以看到,該方案的分散式鎖並不是安全的,佔有鎖的客戶端將在鎖有效時間過後自動失去鎖,這時其他客戶端就可以佔有該鎖,這樣將出現兩個客戶端同時佔有一個鎖的情況,分散式鎖失效了。

所以,該方案鎖的有效時間就非常重要,鎖的有效時間設定過短,可能會出現分散式鎖失效的情況,而有效時間設定過長,那麼佔有鎖的客戶端下線後,其他客戶端仍然要無效等待較長時間才可以佔有該鎖,效能較差。
有沒有更好一點的方案?我們看一下方案2。

方案2:自動延遲鎖有效時間。
我們可以在一開始給鎖設定一個較短的有效時間,並啟動一個後臺執行緒,在該鎖失效前,主動延遲該鎖的有效時間,
例如,在一開始時給鎖設定有效時間為10秒,並啟動一個後臺執行緒,每隔9秒,就將鎖的過期時間修改為當前時間10秒後。
示意程式碼如下:

new Thread(new Runnable() {
    public void run() {
        while(lockIsExist) {
            redis.call("EXPIRE user_lock 10");
            Thread.sleep(1000 * 9);
        }
    }
}).start();

這樣就可以保證當前佔用客戶端的鎖不會因為時間到期而失效,避免了分散式鎖失效的問題,並且如果當前客戶端故障下線,由於沒有後臺執行緒定時延遲鎖有效時間,該鎖也會很快自動失效。
提示;當前客戶端釋放鎖的時候,需要停止該後臺執行緒或者修改lockIsExist為false。

Java客戶端Redisson提供了該方案,使用非常方便。
下面介紹一下如何示意Redisson實現Redis分散式鎖。
(1)新增Redisson引用。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.4</version>
</dependency>

(2)使用示例如下:

Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
RedissonClient redissonClient = Redisson.create(config);

RLock lock = redissonClient.getLock("user_lock");
lock.lock();
try {
// process...
} finally {
    lock.unlock();
}

如果沒有特殊原因,建議直接使用Redisson提供的分散式鎖。

但這種方式就一定安全嗎?
大家考慮這樣一種場景,假如獲得鎖的客戶端因為CPU負載過高或者GC等原因,負責延遲鎖過期時間的執行緒沒法按時獲得CPU去執行任務,則同樣會出現鎖失效的場景。
picture 2
該場景暫時沒有比較好的處理方案,也不展開。

Sentinel、Cluster模式下實現分散式鎖

實際生產環境中比較少使用單節點的Redis,通常會部署Sentinel、Cluster模型部署Redis叢集,Redis在這兩種模式下線實現分散式鎖會有一個很麻煩的問題了。
為了保證高效能,Redis主從同步使用的是非同步模式,就是說Redis主節點返回SET命令成功響應時,Redis從節點可能還沒有同步該命令。
如果這時主節點故障下線了,那麼就會出現以下情況:
(1)Sentinel、Cluster模式會選舉一個從節點成為新主節點,而這個主節點是沒有執行SET命令的。也就是說這時客戶端並沒有佔有鎖。
(2)客戶端收到(之前主節點返回的)SET命令的成功響應,以為自己佔有鎖成功。
這時其他客戶端也請求這個鎖,也能佔有這個鎖,這時就會出現分散式鎖失效的情況。

picture 3

出現這個情況的本質是Redis使用了非同步複製的方式同步主從節點資料,並不嚴格保證主從節點資料的一致性。
對此,Redis作者提出了RedLock演算法,大概方案是部署多個單獨的Redis主節點,並將SET命令同時傳送到多個節點,當收到半數以上Redis主節點返回成功後,則認為加鎖成功。
這種機制感覺與分散式一致性演算法(如Raft演算法)中利用的“Quorum機制”基本一致吧。
關於該方案是否能真正保證分散式鎖安全,Redis作者與另一位大佬Martin爆發了熱烈的討論,本文偏向實戰內容,這裡不一一展示RedLock演算法細節。

即使該演算法可以真正保證分散式鎖安全,如果你要使用該方案,也很麻煩,需要另外部署多個Redis主節點,還需要支援該演算法的可靠的客戶端。考慮這些情況,如果在嚴格要求分散式鎖安全的情況,使用ZooKeeper、Etcd等嚴格保證資料一致性的元件更合適。

Zookeeper分散式鎖

Zookeeper由於保證叢集資料一致,並自帶Watch,客戶端過期失效檢測等機制,非常適合實現分散式鎖。
Zookeeper實現分散式鎖的方式很簡單,客戶端通過建立臨時節點來鎖定分散式鎖,如果建立成功,則加鎖成功,否則,說明該鎖已經被其他客戶端鎖定,這時當前客戶端監聽該臨時節點變化,如果該臨時節點被刪除,則可以再次嘗試鎖定該分散式鎖。
雖然ZooKeeper實現分佈鎖的不同方案細節不同,但整體基本基於該方案進行擴充套件。

這裡推薦使用Curator框架(Netflix提供的ZooKeeper客戶端)實現分散式鎖,非常方便。
下面介紹一下Curator的使用。
(1)引入Curator引用

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>3.3.0</version>
</dependency>

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>3.3.0</version>
</dependency>

注意Curator版本與ZooKeeper版本對應。

(2)使用InterProcessMutex類實現分散式鎖。

CuratorFramework client = CuratorFrameworkFactory.newClient("127.0.0.1:2181", 60000, 15000,
        new ExponentialBackoffRetry(1000, 3));
client.start();
InterProcessMutex lock = new InterProcessMutex(client, "/user_lock");
lock.acquire();
try {
    // process...
} finally {
    lock.release();
}

Curator支援多種分散式鎖,非常全面:

  • InterProcessMutex:可重入排它鎖,例子展示就是這種鎖。
  • InterProcessSemaphoreMutex:不可重入排它鎖。
  • InterProcessReadWriteLock:分散式讀寫鎖。
    使用方式也非常簡單,這裡不一一展開。

那麼Zookeeper實現分散式鎖一定安全嗎?
假如客戶端Client1在ZooKeeper中加鎖成功,即成功建立了臨時ZK節點,但Client1由於GC長時間沒有響應ZooKeeper的心跳檢測請求,ZooKeeper將Client1判斷為失效,從而將臨時ZK節點,這時客戶端Client2請求加鎖就可以成功加鎖。那麼這時就會出現Client1、Client2同時佔有一個分散式鎖,即分散式鎖失效。
該場景與上面說的Redis延遲執行緒沒有按時執行的場景有點型別,該場景展示也沒有較好的解決方案。
雖然理論上ZooKeeper存在分散式鎖失效的可能,但發生的概率應該比較,也可以通過增加ZooKeeper判斷客戶端的時間來減少這種場景,所以ZooKeeper分散式鎖是可以滿足絕大數要求分散式鎖的場景的。

總結一下:
(1)
如果不嚴格要求分散式鎖安全,可以考慮在Sentinel、Cluster模式下使用redis實現分散式鎖。例如多個客戶端同時獲取鎖並不會導致嚴重的業務問題,或者只是要求效能優化避免多個客戶端同時操作等場景,都可以使用Redisson提供的分散式鎖。
(2)如果嚴格要求分散式鎖安全,則可以使用ZooKeeper、Etcd等元件實現分散式鎖。
當然,建議使用Redisson、Curator等成熟框架實現分散式鎖,避免重複編碼,也減少錯誤風險。

如需系統學習Redis,可參考作者新書《Redis核心原理與實踐》。本書通過深入分析Redis 6.0原始碼,總結了Redis核心功能的設計與實現。通過閱讀本書,讀者可以深入理解Redis內部機制及最新特性,並學習到Redis相關的資料結構與演算法、Unix程式設計、儲存系統設計,分散式系統架構等一系列知識。
經過該書編輯同意,我會繼續在個人技術公眾號(binecy)釋出書中部分章節內容,作為書的預覽內容,歡迎大家查閱,謝謝。

語雀平臺預覽:《Redis核心原理與實踐》

相關文章