在分布式系統中,為保證同一時間只有一個客戶端可以對共享資源進行操作,需要對共享資源加鎖來實現,常見有三種方式:
- 基於數據庫實現分布式鎖
- 基於 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無法實現
參考:
https://www.cnblogs.com/rjzheng/p/9310976.html
https://redis.io/topics/distlock
https://mp.weixin.qq.com/s/7ze2v9HQH07rvYoNpUTmzw
https://blog.csdn.net/fengxueersui/article/details/80139039
https://www.cnblogs.com/cjsblog/p/8367002.html
https://www.cnblogs.com/cjsblog/p/9831423.html