Zookeeper 和 Redis 實現分佈式鎖,附我的可靠性分析
作者:今天你敲代碼了嗎
鏈接:https://www.jianshu.com/p/b6953745e341
在分佈式系統中,爲保證同一時間只有一個客戶端可以對共享資源進行操作,需要對共享資源加鎖來實現,常見有三種方式:
-
基於數據庫實現分佈式鎖
-
基於 Redis 實現分佈式鎖
-
基於 Zookeeper 實現分佈式鎖
高併發下數據庫鎖性能太差,本文不做探究。僅針對 Redis 和 Zookeeper 實現的分佈式鎖進行分析。
實現一個分佈式鎖應該具備的特性:
-
高可用、高性能的獲取鎖與釋放鎖
-
在分佈式系統環境下,一個方法或者變量同一時間只能被一個線程操作
-
具備鎖失效機制,網絡中斷或宕機無法釋放鎖時,鎖必須被刪除,防止死鎖
-
具備阻塞鎖特性,即沒有獲取到鎖,則繼續等待獲取鎖
-
具備非阻塞鎖特性,即沒有獲取到鎖,則直接返回獲取鎖失敗
-
具備可重入特性,一個線程中可以多次獲取同一把鎖,比如一個線程在執行一個帶鎖的方法,該方法中又調用了另一個需要相同鎖的方法,則該線程可以直接執行調用的方法,而無需重新獲得鎖
先上結論,Redis 在鎖時間限制和緩存一致性存在一定問題,Zookeeper 在可靠性上強於 Redis,只是效率相對較低,開發人員需要根據實際需求進行技術選型。
單機情況下:
1. Redis 單機實現分佈式鎖
1.1 Redis 加鎖
//SET resource_name my_random_value NX PX 30000
String result = jedis.set(key, value, "NX", "PX", 30000);
if ("OK".equals(result)) {
return true; //代表獲取到鎖
}
return false;
加鎖就一行代碼:jedis.set(String key, String value, String nxxx, String expx, int time),這個 set() 方法一共有五個形參:
-
第一個爲 key,使用 key 來當鎖,因爲 key 是唯一的。
-
第二個爲 value,是由客戶端生成的一個隨機字符串,相當於是客戶端持有鎖的標誌。用於標識加鎖和解鎖必須是同一個客戶端。
-
第三個爲 nxxx,傳的是 NX,意思是 SET IF NOT EXIST,即當 key 不存在時,進行 set 操作;若 key 已經存在,則不做任何操作。
-
第四個爲 expx,傳的是 PX,意思是我們要給這個 key 加一個過期的設置,具體時間由第五個參數決定。
-
第五個爲 time,與第四個參數相呼應,代表 key 的過期時間,如上 30000 表示這個鎖有一個 30 秒的自動過期時間。
1.2 Redis 解鎖
解鎖時,爲了防止客戶端 1 獲得的鎖,被客戶端 2 給釋放,需要採用的 Lua 腳本來釋放鎖:
final Long RELEASE_SUCCESS = 1L;
//採用Lua腳本來釋放鎖
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(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
在執行這段 Lua 腳本的時候,KEYS[1] 的值爲 key,ARGV[1] 的值爲 value。原理就是先獲取鎖對應的 value 值,保證和客戶端傳進去的 value 值相等,這樣就能避免自己的鎖被其他人釋放。另外,採取 Lua 腳本操作保證了原子性。如果不是原子性操作,則有了下述情況出現:
1.3 Redis 加鎖過期時間設置問題
理想情況是客戶端 Redis 加鎖後,完成一系列業務操作,順利在鎖過期時間前釋放掉鎖,這個分佈式鎖的設置是有效的。但是如果客戶端在操作共享資源的過程中,因爲長期阻塞的原因,導致鎖過期,那麼接下來訪問共享資源就變得不再安全。
2. Zookeeper 單機實現分佈式鎖
2.1 Curator 實現 Zookeeper 加解鎖
使用 Apache 開源的 curator 可實現 Zookeeper 分佈式鎖。
可以通過調用 InterProcessLock 接口提供的幾個方法來實現加鎖、解鎖。
/**
* 獲取鎖、阻塞等待、可重入
*/
public void acquire() throws Exception;
/**
* 獲取鎖、阻塞等待、可重入、超時則獲取失敗
*/
public boolean acquire(long time, TimeUnit unit) throws Exception;
/**
* 釋放鎖
*/
public void release() throws Exception;
/**
* Returns true if the mutex is acquired by a thread in this JVM
*/
boolean isAcquiredInThisProcess();
2.2 Zookeeper 加鎖實現原理
Zookeeper 的分佈式鎖原理是利用了臨時節點 (EPHEMERAL) 的特性。其實現原理:
-
創建一個鎖目錄 lock
-
線程 A 獲取鎖會在 lock 目錄下,創建臨時順序節點
-
獲取鎖目錄下所有的子節點,然後獲取比自己小的兄弟節點,如果不存在,則說明當前線程順序號最小,獲得鎖
-
線程 B 創建臨時節點並獲取所有兄弟節點,判斷自己不是最小節點,設置監聽 (watcher) 比自己次小的節點(只關注比自己次小的節點是爲了防止發生“羊羣效應”)
-
線程 A 處理完,刪除自己的節點,線程 B 監聽到變更事件,判斷自己是最小的節點,獲得鎖
由於節點的臨時屬性,如果創建 znode 的那個客戶端崩潰了,那麼相應的 znode 會被自動刪除。這樣就避免了設置過期時間的問題。
2.3 GC 停頓導致臨時節點釋放問題
但是使用臨時節點又會存在另一個問題:Zookeeper 如果長時間檢測不到客戶端的心跳的時候 (Session 時間),就會認爲 Session 過期了,那麼這個 Session 所創建的所有的 ephemeral 類型的 znode 節點都會被自動刪除。
如上圖所示,客戶端 1 發生 GC 停頓的時候,Zookeeper 檢測不到心跳,也是有可能出現多個客戶端同時操作共享資源的情形。當然,你可以說,我們可以通過 JVM 調優,避免 GC 停頓出現。但是注意了,我們所做的一切,只能儘可能避免多個客戶端操作共享資源,無法完全消除。
集羣情況下:
3. Redis 集羣下分佈式鎖存在問題
3.1 集羣 Master 宕機導致鎖丟失
爲了 Redis 的高可用,一般都會給 Redis 的節點掛一個 slave, 然後採用哨兵模式進行主備切換。但由於 Redis 的主從複製(replication)是異步的,這可能會出現在數據同步過程中,master 宕機,slave 來不及同步數據就被選爲 master,從而數據丟失。具體流程如下所示:
-
(1) 客戶端 1 從 Master 獲取了鎖。
-
(2)Master 宕機了,存儲鎖的 key 還沒有來得及同步到 Slave 上。
-
(3)Slave 升級爲 Master。
-
(4) 客戶端 1 的鎖丟失,客戶端 2 從新的 Master 獲取到了對應同一個資源的鎖。
3.2 Redlock 算法
爲了應對這個情形, Redis 作者 antirez 基於分佈式環境下提出了一種更高級的分佈式鎖的實現方式:Redlock。
antirez 提出的 redlock 算法大概是這樣的:
在 Redis 的分佈式環境中,我們假設有 N 個 Redis master。這些節點完全互相獨立,不存在主從複製或者其他集羣協調機制。我們確保將在 N 個實例上使用與在 Redis 單實例下相同方法獲取和釋放鎖。現在我們假設有 5 個 Redis master 節點 (官方文檔裏將 N 設置成 5,其實大等於 3 就行),同時我們需要在 5 臺服務器上面運行這些 Redis 實例,這樣保證他們不會同時都宕掉。
爲了取到鎖,客戶端應該執行以下操作:
-
(1) 獲取當前 Unix 時間,以毫秒爲單位。
-
(2) 依次嘗試從 5 個實例,使用相同的 key 和具有唯一性的 value(例如 UUID)獲取鎖。當向 Redis 請求獲取鎖時,客戶端應該設置一個網絡連接和響應超時時間,這個超時時間應該小於鎖的失效時間。例如你的鎖自動失效時間爲 10 秒,則超時時間應該在 5-50 毫秒之間。這樣可以避免服務器端 Redis 已經掛掉的情況下,客戶端還在死死地等待響應結果。如果服務器端沒有在規定時間內響應,客戶端應該儘快嘗試去另外一個 Redis 實例請求獲取鎖。
-
(3) 客戶端使用當前時間減去開始獲取鎖時間(步驟 1 記錄的時間)就得到獲取鎖使用的時間。當且僅當從大多數(N/2+1,這裏是 3 個節點)的 Redis 節點都取到鎖,並且使用的時間小於鎖失效時間時,鎖纔算獲取成功。
-
(4) 如果取到了鎖,key 的真正有效時間等於有效時間減去獲取鎖所使用的時間(步驟 3 計算的結果)。
-
(5) 如果因爲某些原因,獲取鎖失敗(沒有在至少 N/2+1 個 Redis 實例取到鎖或者取鎖時間已經超過了有效時間),客戶端應該在所有的 Redis 實例上進行解鎖(即便某些 Redis 實例根本就沒有加鎖成功,防止某些節點獲取到鎖但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖)。
redisson 已經有對 redlock 算法封裝,如下是調用代碼示例:
Config config = new Config();
config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
.setMasterName("masterName")
.setPassword("password").setDatabase(0);
RedissonClient redissonClient = Redisson.create(config);
// 還可以getFairLock(), getReadWriteLock()
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
isLock = redLock.tryLock();
// 500ms拿不到鎖, 就認爲獲取鎖失敗。10000ms即10s是鎖失效時間。
isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
if (isLock) {
//TODO if get lock success, do something;
}
} catch (Exception e) {
} finally {
// 無論如何, 最後都要解鎖
redLock.unlock();
}
3.3 Redlock 未完全解決問題
Redlock 算法細想一下還存在下面的問題:節點崩潰重啓,會出現多個客戶端持有鎖 假設一共有 5 個 Redis 節點:A, B, C, D, E。設想發生瞭如下的事件序列:
-
(1) 客戶端 1 成功鎖住了 A, B, C,獲取鎖成功(但 D 和 E 沒有鎖住)。
-
(2) 節點 C 崩潰重啓了,但客戶端 1 在 C 上加的鎖沒有持久化下來,丟失了。
-
(3) 節點 C 重啓後,客戶端 2 鎖住了 C, D, E,獲取鎖成功。
這樣,客戶端 1 和客戶端 2 同時獲得了鎖(針對同一資源)。
爲了應對節點重啓引發的鎖失效問題,redis 的作者 antirez 提出了延遲重啓的概念,即一個節點崩潰後,先不立即重啓它,而是等待一段時間再重啓,等待的時間大於鎖的有效時間。採用這種方式,這個節點在重啓前所參與的鎖都會過期,它在重啓後就不會對現有的鎖造成影響。這其實也是通過人爲補償措施,降低不一致發生的概率。時間跳躍問題
-
(1) 假設一共有 5 個 Redis 節點:A, B, C, D, E。設想發生瞭如下的事件序列:
-
(2) 客戶端 1 從 Redis 節點 A, B, C 成功獲取了鎖(多數節點)。由於網絡問題,與 D 和 E 通信失敗。
-
(3) 節點 C 上的時鐘發生了向前跳躍,導致它上面維護的鎖快速過期。
-
(4) 客戶端 2 從 Redis 節點 C, D, E 成功獲取了同一個資源的鎖(多數節點)。
-
(5) 客戶端 1 和客戶端 2 現在都認爲自己持有了鎖。
爲了應對始終跳躍引發的鎖失效問題,redis 的作者 antirez 提出了應該禁止人爲修改系統時間,使用一個不會進行 “跳躍” 式調整系統時鐘的 ntpd 程序。這也是通過人爲補償措施,降低不一致發生的概率。超時導致鎖失效問題 RedLock 算法並沒有解決,操作共享資源超時,導致鎖失效的問題。回憶一下 RedLock 算法的過程,如下圖所示
如圖所示,我們將其分爲上下兩個部分。對於上半部分框圖裏的步驟來說,無論因爲什麼原因發生了延遲,RedLock 算法都能處理,客戶端不會拿到一個它認爲有效,實際卻失效的鎖。然而,對於下半部分框圖裏的步驟來說,如果發生了延遲導致鎖失效,都有可能使得客戶端 2 拿到鎖。因此,RedLock 算法並沒有解決該問題。
4. Zookeeper 集羣下分佈式鎖可靠性分析
4.1 Zookeeper 的寫數據的原理
Zookeeper 在集羣部署中,Zookeeper 節點數量一般是奇數,且一定大等於 3。下面是 Zookeeper 的寫數據的原理:
那麼寫數據流程步驟如下:
-
(1) 在 Client 向 Follwer 發出一個寫的請求
-
(2)Follwer 把請求發送給 Leader
-
(3)Leader 接收到以後開始發起投票並通知 Follwer 進行投票
-
(4)Follwer 把投票結果發送給 Leader,只要半數以上返回了 ACK 信息,就認爲通過
-
(5)Leader 將結果彙總後如果需要寫入,則開始寫入同時把寫入操作通知給 Leader,然後 commit;
-
(6)Follwer 把請求結果返回給 Client
還有一點,Zookeeper 採取的是全局串行化操作。
4.2 集羣模式下 Zookeeper 可靠性分析
下面列出 Redis 集羣下分佈式鎖可能存在的問題,判斷其在 Zookeeper 集羣下是否會存在:
集羣同步
-
client 給 Follwer 寫數據,可是 Follwer 卻宕機了,會出現數據不一致問題麼?不可能,這種時候,client 建立節點失敗,根本獲取不到鎖。
-
client 給 Follwer 寫數據,Follwer 將請求轉發給 Leader,Leader 宕機了,會出現不一致的問題麼?不可能,這種時候,Zookeeper 會選取新的 leader,繼續上面的提到的寫流程。
總之,採用 Zookeeper 作爲分佈式鎖,你要麼就獲取不到鎖,一旦獲取到了,必定節點的數據是一致的,不會出現 redis 那種異步同步導致數據丟失的問題。時間跳躍問題 Zookeeper 不依賴全局時間,不存在該問題。超時導致鎖失效問題 Zookeeper 不依賴有效時間,不存在該問題。
5. 鎖的其他特性比較
redis 的讀寫性能比 Zookeeper 強太多,如果在高併發場景中,使用 Zookeeper 作爲分佈式鎖,那麼會出現獲取鎖失敗的情況,存在性能瓶頸。
Zookeeper 可以實現讀寫鎖,Redis 不行。
Zookeeper 的 watch 機制, 客戶端試圖創建 znode 的時候,發現它已經存在了,這時候創建失敗, 那麼進入一種等待狀態,當 znode 節點被刪除的時候,Zookeeper 通過 watch 機制通知它,這樣它就可以繼續完成創建操作(獲取鎖)。這可以讓分佈式鎖在客戶端用起來就像一個本地的鎖一樣:加鎖失敗就阻塞住,直到獲取到鎖爲止。這套機制,redis 無法實現。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/hvvQfMtWCigYt5_lRHbimQ