自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

Redis 分布式鎖的正確實(shí)現(xiàn)原理演化歷程與 Redisson 實(shí)戰(zhàn)總結(jié)

存儲(chǔ) 存儲(chǔ)軟件 分布式 Redis
Redis 分布式鎖使用 SET 指令就可以實(shí)現(xiàn)了么?在分布式領(lǐng)域 CAP 理論一直存在。分布式鎖的門道可沒那么簡(jiǎn)單,我們?cè)诰W(wǎng)上看到的分布式鎖方案可能是有問題的。

[[437124]]

Redis 分布式鎖使用 SET 指令就可以實(shí)現(xiàn)了么?在分布式領(lǐng)域 CAP 理論一直存在。

分布式鎖的門道可沒那么簡(jiǎn)單,我們?cè)诰W(wǎng)上看到的分布式鎖方案可能是有問題的。

「碼哥」一步步帶你深入分布式鎖是如何一步步完善,在高并發(fā)生產(chǎn)環(huán)境中如何正確使用分布式鎖。

在進(jìn)入正文之前,我們先帶著問題去思考:

  • 什么時(shí)候需要分布式鎖?
  • 加、解鎖的代碼位置有講究么?
  • 如何避免出現(xiàn)鎖再也無(wú)法刪除?
  • 超時(shí)時(shí)間設(shè)置多少合適呢?
  • 如何避免鎖被其他線程釋放
  • 如何實(shí)現(xiàn)重入鎖?
  • 主從架構(gòu)會(huì)帶來(lái)什么安全問題?
  • 什么是 Redlock
  • Redisson 分布式鎖最佳實(shí)戰(zhàn)
  • 看門狗實(shí)現(xiàn)原理
  • ……

什么時(shí)候用分布式鎖?

碼哥,說(shuō)個(gè)通俗的例子講解下什么時(shí)候需要分布式鎖呢?

診所只有一個(gè)醫(yī)生,很多患者前來(lái)就診。

醫(yī)生在同一時(shí)刻只能給一個(gè)患者提供就診服務(wù)。

如果不是這樣的話,就會(huì)出現(xiàn)醫(yī)生在就診腎虧的「肖菜雞」準(zhǔn)備開藥時(shí)候患者切換成了腳臭的「謝霸哥」,這時(shí)候藥就被謝霸哥取走了。

治腎虧的藥被有腳臭的拿去了。

當(dāng)并發(fā)去讀寫一個(gè)【共享資源】的時(shí)候,我們?yōu)榱吮WC數(shù)據(jù)的正確,需要控制同一時(shí)刻只有一個(gè)線程訪問。

分布式鎖就是用來(lái)控制同一時(shí)刻,只有一個(gè) JVM 進(jìn)程中的一個(gè)線程可以訪問被保護(hù)的資源。

分布式鎖入門

65 哥:分布式鎖應(yīng)該滿足哪些特性?

互斥:在任何給定時(shí)刻,只有一個(gè)客戶端可以持有鎖;

無(wú)死鎖:任何時(shí)刻都有可能獲得鎖,即使獲取鎖的客戶端崩潰;

容錯(cuò):只要大多數(shù) Redis的節(jié)點(diǎn)都已經(jīng)啟動(dòng),客戶端就可以獲取和釋放鎖。

碼哥,我可以使用 SETNX key value 命令是實(shí)現(xiàn)「互斥」特性。

這個(gè)命令來(lái)自于SET if Not eXists的縮寫,意思是:如果 key 不存在,則設(shè)置 value 給這個(gè)key,否則啥都不做。Redis 官方地址說(shuō)的:

命令的返回值:

  • 1:設(shè)置成功;
  • 0:key 沒有設(shè)置成功。

如下場(chǎng)景:

敲代碼一天累了,想去放松按摩下肩頸。

168 號(hào)技師最搶手,大家喜歡點(diǎn),所以并發(fā)量大,需要分布式鎖控制。

同一時(shí)刻只允許一個(gè)「客戶」預(yù)約 168 技師。

肖菜雞申請(qǐng) 168 技師成功:

  1. > SETNX lock:168 1 
  2. (integer) 1 # 獲取 168 技師成功 

謝霸哥后面到,申請(qǐng)失?。?/p>

  1. > SETNX lock 2 
  2.  
  3. (integer) 0 # 客戶謝霸哥 2 獲取失敗 

此刻,申請(qǐng)成功的客戶就可以享受 168 技師的肩頸放松服務(wù)「共享資源」。

享受結(jié)束后,要及時(shí)釋放鎖,給后來(lái)者享受 168 技師的服務(wù)機(jī)會(huì)。

肖菜雞,碼哥考考你如何釋放鎖呢?

很簡(jiǎn)單,使用 DEL 刪除這個(gè) key 就行。

  1. > DEL lock:168 
  2. (integer) 1 

碼哥,你見過「龍」么?我見過,因?yàn)槲冶灰粭l龍服務(wù)過。

肖菜雞,事情可沒這么簡(jiǎn)單。

這個(gè)方案存在一個(gè)存在造成鎖無(wú)法釋放的問題,造成該問題的場(chǎng)景如下:

客戶端所在節(jié)點(diǎn)崩潰,無(wú)法正確釋放鎖;

業(yè)務(wù)邏輯異常,無(wú)法執(zhí)行 DEL指令。

這樣,這個(gè)鎖就會(huì)一直占用,鎖在我手里,我掛了,這樣其他客戶端再也拿不到這個(gè)鎖了。

超時(shí)設(shè)置

碼哥,我可以在獲取鎖成功的時(shí)候設(shè)置一個(gè)「超時(shí)時(shí)間」

比如設(shè)定按摩服務(wù)一次 60 分鐘,那么在給這個(gè) key 加鎖的時(shí)候設(shè)置 60 分鐘過期即可:

  1. > SETNX lock:168 1 // 獲取鎖 
  2.  
  3. (integer) 1 
  4.  
  5. > EXPIRE lock:168 60 // 60s 自動(dòng)刪除 
  6.  
  7. (integer) 1 

這樣,到點(diǎn)后鎖自動(dòng)釋放,其他客戶就可以繼續(xù)享受 168 技師按摩服務(wù)了。

誰(shuí)要這么寫,就糟透了。

「加鎖」、「設(shè)置超時(shí)」是兩個(gè)命令,他們不是原子操作。

如果出現(xiàn)只執(zhí)行了第一條,第二條沒機(jī)會(huì)執(zhí)行就會(huì)出現(xiàn)「超時(shí)時(shí)間」設(shè)置失敗,依然出現(xiàn)鎖無(wú)法釋放。

碼哥,那咋辦,我想被一條龍服務(wù),要解決這個(gè)問題

Redis 2.6.X 之后,官方拓展了 SET 命令的參數(shù),滿足了當(dāng) key 不存在則設(shè)置 value,同時(shí)設(shè)置超時(shí)時(shí)間的語(yǔ)義,并且滿足原子性。

  1. SET resource_name random_value NX PX 30000 
  • NX:表示只有 resource_name 不存在的時(shí)候才能 SET 成功,從而保證只有一個(gè)客戶端可以獲得鎖;
  • PX 30000:表示這個(gè)鎖有一個(gè) 30 秒自動(dòng)過期時(shí)間。

這樣寫還不夠,我們還要防止不能釋放不是自己加的鎖。我們可以在 value 上做文章。

繼續(xù)往下看……

釋放了不是自己加的鎖

這樣我能穩(wěn)妥的享受一條龍服務(wù)了么?

No,還有一種場(chǎng)景會(huì)導(dǎo)致釋放別人的鎖:

客戶 1 獲取鎖成功并設(shè)置設(shè)置 30 秒超時(shí);

客戶 1 因?yàn)橐恍┰驅(qū)е聢?zhí)行很慢(網(wǎng)絡(luò)問題、發(fā)生 FullGC……),過了 30 秒依然沒執(zhí)行完,但是鎖過期「自動(dòng)釋放了」;

客戶 2 申請(qǐng)加鎖成功;

客戶 1 執(zhí)行完成,執(zhí)行 DEL 釋放鎖指令,這個(gè)時(shí)候就把客戶 2 的鎖給釋放了。

有個(gè)關(guān)鍵問題需要解決:自己的鎖只能自己來(lái)釋放。

我要如何刪除是自己加的鎖呢?

在執(zhí)行 DEL 指令的時(shí)候,我們要想辦法檢查下這個(gè)鎖是不是自己加的鎖再執(zhí)行刪除指令。

解鈴還須系鈴人

碼哥,我在加鎖的時(shí)候設(shè)置一個(gè)「唯一標(biāo)識(shí)」作為 value 代表加鎖的客戶端。SET resource_name random_value NX PX 30000

在釋放鎖的時(shí)候,客戶端將自己的「唯一標(biāo)識(shí)」與鎖上的「標(biāo)識(shí)」比較是否相等,匹配上則刪除,否則沒有權(quán)利釋放鎖。

偽代碼如下:

  1. // 比對(duì) value 與 唯一標(biāo)識(shí) 
  2. if (redis.get("lock:168").equals(random_value)){ 
  3.    redis.del("lock:168"); //比對(duì)成功則刪除 
  4.  } 

有沒有想過,這是 GET + DEL 指令組合而成的,這里又會(huì)涉及到原子性問題。

我們可以通過 Lua 腳本來(lái)實(shí)現(xiàn),這樣判斷和刪除的過程就是原子操作了。

  1. // 獲取鎖的 value 與 ARGV[1] 是否匹配,匹配則執(zhí)行 del 
  2. if redis.call("get",KEYS[1]) == ARGV[1] then 
  3.     return redis.call("del",KEYS[1]) 
  4. else 
  5.     return 0 
  6. end 

這樣通過唯一值設(shè)置成 value 標(biāo)識(shí)加鎖的客戶端很重要,僅使用 DEL 是不安全的,因?yàn)橐粋€(gè)客戶端可能會(huì)刪除另一個(gè)客戶端的鎖。

使用上面的腳本,每個(gè)鎖都用一個(gè)隨機(jī)字符串“簽名”,只有當(dāng)刪除鎖的客戶端的“簽名”與鎖的 value 匹配的時(shí)候,才會(huì)刪除它。

官方文檔也是這么說(shuō)的:https://redis.io/topics/distlock

這個(gè)方案已經(jīng)相對(duì)完美,我們用的最多的可能就是這個(gè)方案了。

正確設(shè)置鎖超時(shí)

鎖的超時(shí)時(shí)間怎么計(jì)算合適呢?

這個(gè)時(shí)間不能瞎寫,一般要根據(jù)在測(cè)試環(huán)境多次測(cè)試,然后壓測(cè)多輪之后,比如計(jì)算出平均執(zhí)行時(shí)間 200 ms。

那么鎖的超時(shí)時(shí)間就放大為平均執(zhí)行時(shí)間的 3~5 倍。

為啥要放放大呢?

因?yàn)槿绻i的操作邏輯中有網(wǎng)絡(luò) IO 操作、JVM FullGC 等,線上的網(wǎng)絡(luò)不會(huì)總一帆風(fēng)順,我們要給網(wǎng)絡(luò)抖動(dòng)留有緩沖時(shí)間。

那我設(shè)置更大一點(diǎn),比如設(shè)置 1 小時(shí)不是更安全?

不要鉆牛角,多大算大?

設(shè)置時(shí)間過長(zhǎng),一旦發(fā)生宕機(jī)重啟,就意味著 1 小時(shí)內(nèi),分布式鎖的服務(wù)全部節(jié)點(diǎn)不可用。

你要讓運(yùn)維手動(dòng)刪除這個(gè)鎖么?

只要運(yùn)維真的不會(huì)打你。

有沒有完美的方案呢?不管時(shí)間怎么設(shè)置都不大合適。

我們可以讓獲得鎖的線程開啟一個(gè)守護(hù)線程,用來(lái)給快要過期的鎖「續(xù)航」。

加鎖的時(shí)候設(shè)置一個(gè)過期時(shí)間,同時(shí)客戶端開啟一個(gè)「守護(hù)線程」,定時(shí)去檢測(cè)這個(gè)鎖的失效時(shí)間。

如果快要過期,但是業(yè)務(wù)邏輯還沒執(zhí)行完成,自動(dòng)對(duì)這個(gè)鎖進(jìn)行續(xù)期,重新設(shè)置過期時(shí)間。

這個(gè)道理行得通,可我寫不出。

別慌,已經(jīng)有一個(gè)庫(kù)把這些工作都封裝好了他叫 Redisson。

在使用分布式鎖時(shí),它就采用了「自動(dòng)續(xù)期」的方案來(lái)避免鎖過期,這個(gè)守護(hù)線程我們一般也把它叫做「看門狗」線程。

一路優(yōu)化下來(lái),方案似乎比較「嚴(yán)謹(jǐn)」了,抽象出對(duì)應(yīng)的模型如下。

  1. 通過 SET lock_resource_name random_value NX PX expire_time,同時(shí)啟動(dòng)守護(hù)線程為快要過期但還沒執(zhí)行完的客戶端的鎖續(xù)命;
  2. 客戶端執(zhí)行業(yè)務(wù)邏輯操作共享資源;
  3. 通過 Lua 腳本釋放鎖,先 get 判斷鎖是否是自己加的,再執(zhí)行 DEL。

這個(gè)方案實(shí)際上已經(jīng)比較完美,能寫到這一步已經(jīng)打敗 90% 的程序猿了。

但是對(duì)于追求極致的程序員來(lái)說(shuō)還遠(yuǎn)遠(yuǎn)不夠:

  1. 可重入鎖如何實(shí)現(xiàn)?
  2. 主從架構(gòu)崩潰恢復(fù)導(dǎo)致鎖丟失如何解決?
  3. 客戶端加鎖的位置有門道么?

加解鎖代碼位置有講究

根據(jù)前面的分析,我們已經(jīng)有了一個(gè)「相對(duì)嚴(yán)謹(jǐn)」的分布式鎖了。

于是「謝霸哥」就寫了如下代碼將分布式鎖運(yùn)用到項(xiàng)目中,以下是偽代碼邏輯:

  1. public void doSomething() { 
  2.   redisLock.lock(); // 上鎖 
  3.     try { 
  4.         // 處理業(yè)務(wù) 
  5.         ..... 
  6.         redisLock.unlock(); // 釋放鎖 
  7.     } catch (Exception e) { 
  8.         e.printStackTrace(); 
  9.     } 

有沒有想過:一旦執(zhí)行業(yè)務(wù)邏輯過程中拋出異常,程序就無(wú)法執(zhí)行釋放鎖的流程。

所以釋放鎖的代碼一定要放在 finally{} 塊中。

加鎖的位置也有問題,放在 try 外面的話,如果執(zhí)行 redisLock.lock() 加鎖異常,但是實(shí)際指令已經(jīng)發(fā)送到服務(wù)端并執(zhí)行,只是客戶端讀取響應(yīng)超時(shí),就會(huì)導(dǎo)致沒有機(jī)會(huì)執(zhí)行解鎖的代碼。

所以 redisLock.lock() 應(yīng)該寫在 try 代碼塊,這樣保證一定會(huì)執(zhí)行解鎖邏輯。

綜上所述,正確代碼位置如下 :

  1. public void doSomething() { 
  2.     try { 
  3.         // 上鎖 
  4.         redisLock.lock(); 
  5.         // 處理業(yè)務(wù) 
  6.         ... 
  7.     } catch (Exception e) { 
  8.         e.printStackTrace(); 
  9.     } finally { 
  10.       // 釋放鎖 
  11.       redisLock.unlock(); 
  12.     } 

實(shí)現(xiàn)可重入鎖

65 哥:可重入鎖要如何實(shí)現(xiàn)呢?

當(dāng)一個(gè)線程執(zhí)行一段代碼成功獲取鎖之后,繼續(xù)執(zhí)行時(shí),又遇到加鎖的代碼,可重入性就就保證線程能繼續(xù)執(zhí)行,而不可重入就是需要等待鎖釋放之后,再次獲取鎖成功,才能繼續(xù)往下執(zhí)行。

用一段代碼解釋可重入:

  1. public synchronized void a() { 
  2.     b(); 
  3. public synchronized void b() { 
  4.     // pass 

假設(shè) X 線程在 a 方法獲取鎖之后,繼續(xù)執(zhí)行 b 方法,如果此時(shí)不可重入,線程就必須等待鎖釋放,再次爭(zhēng)搶鎖。

鎖明明是被 X 線程擁有,卻還需要等待自己釋放鎖,然后再去搶鎖,這看起來(lái)就很奇怪,我釋放我自己~

Redis Hash 可重入鎖

Redisson 類庫(kù)就是通過 Redis Hash 來(lái)實(shí)現(xiàn)可重入鎖

當(dāng)線程擁有鎖之后,往后再遇到加鎖方法,直接將加鎖次數(shù)加 1,然后再執(zhí)行方法邏輯。

退出加鎖方法之后,加鎖次數(shù)再減 1,當(dāng)加鎖次數(shù)為 0 時(shí),鎖才被真正的釋放。

可以看到可重入鎖最大特性就是計(jì)數(shù),計(jì)算加鎖的次數(shù)。

所以當(dāng)可重入鎖需要在分布式環(huán)境實(shí)現(xiàn)時(shí),我們也就需要統(tǒng)計(jì)加鎖次數(shù)。

加鎖邏輯

我們可以使用 Redis hash 結(jié)構(gòu)實(shí)現(xiàn),key 表示被鎖的共享資源, hash 結(jié)構(gòu)的 fieldKey 的 value 則保存加鎖的次數(shù)。

通過 Lua 腳本實(shí)現(xiàn)原子性,假設(shè) KEYS1 = 「lock」, ARGV「1000,uuid」:

  1. ---- 1 代表 true 
  2. ---- 0 代表 false 
  3. if (redis.call('exists', KEYS[1]) == 0) then 
  4.     redis.call('hincrby', KEYS[1], ARGV[2], 1); 
  5.     redis.call('pexpire', KEYS[1], ARGV[1]); 
  6.     return 1; 
  7. end ; 
  8. if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 
  9.     redis.call('hincrby', KEYS[1], ARGV[2], 1); 
  10.     redis.call('pexpire', KEYS[1], ARGV[1]); 
  11.     return 1; 
  12. end ; 
  13. return 0; 

加鎖代碼首先使用 Redis exists 命令判斷當(dāng)前 lock 這個(gè)鎖是否存在。

如果鎖不存在的話,直接使用 hincrby創(chuàng)建一個(gè)鍵為 lock hash 表,并且為 Hash 表中鍵為 uuid 初始化為 0,然后再次加 1,最后再設(shè)置過期時(shí)間。

如果當(dāng)前鎖存在,則使用 hexists判斷當(dāng)前 lock 對(duì)應(yīng)的 hash 表中是否存在 uuid 這個(gè)鍵,如果存在,再次使用 hincrby 加 1,最后再次設(shè)置過期時(shí)間。

最后如果上述兩個(gè)邏輯都不符合,直接返回。

解鎖邏輯

-- 判斷 hash set 可重入 key 的值是否等于 0

-- 如果為 0 代表 該可重入 key 不存在

  1. -- 判斷 hash set 可重入 key 的值是否等于 0 
  2. -- 如果為 0 代表 該可重入 key 不存在 
  3. if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then 
  4.     return nil; 
  5. end ; 
  6. -- 計(jì)算當(dāng)前可重入次數(shù) 
  7. local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1); 
  8. -- 小于等于 0 代表可以解鎖 
  9. if (counter > 0) then 
  10.     return 0; 
  11. else 
  12.     redis.call('del', KEYS[1]); 
  13.     return 1; 
  14. end ; 
  15. return nil; 

首先使用 hexists 判斷 Redis Hash 表是否存給定的域。

如果 lock 對(duì)應(yīng) Hash 表不存在,或者 Hash 表不存在 uuid 這個(gè) key,直接返回 nil。

若存在的情況下,代表當(dāng)前鎖被其持有,首先使用 hincrby使可重入次數(shù)減 1 ,然后判斷計(jì)算之后可重入次數(shù),若小于等于 0,則使用 del 刪除這把鎖。

解鎖代碼執(zhí)行方式與加鎖類似,只不過解鎖的執(zhí)行結(jié)果返回類型使用 Long。這里之所以沒有跟加鎖一樣使用 Boolean ,這是因?yàn)榻怄i lua 腳本中,三個(gè)返回值含義如下:

  • 1 代表解鎖成功,鎖被釋放
  • 0 代表可重入次數(shù)被減 1
  • null 代表其他線程嘗試解鎖,解鎖失敗.

主從架構(gòu)帶來(lái)的問題

碼哥,到這里分布式鎖「很完美了」吧,沒想到分布式鎖這么多門道。

路還很遠(yuǎn),之前分析的場(chǎng)景都是,鎖在「單個(gè)」Redis 實(shí)例中可能產(chǎn)生的問題,并沒有涉及到 Redis 主從模式導(dǎo)致的問題。

我們通常使用「Cluster 集群」或者「哨兵集群」的模式部署保證高可用。

這兩個(gè)模式都是基于「主從架構(gòu)數(shù)據(jù)同步復(fù)制」實(shí)現(xiàn)的數(shù)據(jù)同步,而 Redis 的主從復(fù)制默認(rèn)是異步的。

以下內(nèi)容來(lái)自于官方文檔 https://redis.io/topics/distlock

我們?cè)囅胂氯缦聢?chǎng)景會(huì)發(fā)生什么問題:

客戶端 A 在 master 節(jié)點(diǎn)獲取鎖成功。

還沒有把獲取鎖的信息同步到 slave 的時(shí)候,master 宕機(jī)。

slave 被選舉為新 master,這時(shí)候沒有客戶端 A 獲取鎖的數(shù)據(jù)。

客戶端 B 就能成功的獲得客戶端 A 持有的鎖,違背了分布式鎖定義的互斥。

雖然這個(gè)概率極低,但是我們必須得承認(rèn)這個(gè)風(fēng)險(xiǎn)的存在。

Redis 的作者提出了一種解決方案,叫 Redlock(紅鎖)

Redis 的作者為了統(tǒng)一分布式鎖的標(biāo)準(zhǔn),搞了一個(gè) Redlock,算是 Redis 官方對(duì)于實(shí)現(xiàn)分布式鎖的指導(dǎo)規(guī)范,https://redis.io/topics/distlock,但是這個(gè) Redlock 也被國(guó)外的一些分布式專家給噴了。

因?yàn)樗膊煌昝溃?ldquo;漏洞”。

什么是 Redlock

紅鎖是不是這個(gè)?

[[437127]]

泡面吃多了你,Redlock 紅鎖是為了解決主從架構(gòu)中當(dāng)出現(xiàn)主從切換導(dǎo)致多個(gè)客戶端持有同一個(gè)鎖而提出的一種算法。

大家可以看官方文檔(https://redis.io/topics/distlock),以下來(lái)自官方文檔的翻譯。

想用使用 Redlock,官方建議在不同機(jī)器上部署 5 個(gè) Redis 主節(jié)點(diǎn),節(jié)點(diǎn)都是完全獨(dú)立,也不使用主從復(fù)制,使用多個(gè)節(jié)點(diǎn)是為容錯(cuò)。

一個(gè)客戶端要獲取鎖有 5 個(gè)步驟:

客戶端獲取當(dāng)前時(shí)間 T1(毫秒級(jí)別);

使用相同的 key和 value順序嘗試從 N個(gè) Redis實(shí)例上獲取鎖。

每個(gè)請(qǐng)求都設(shè)置一個(gè)超時(shí)時(shí)間(毫秒級(jí)別),該超時(shí)時(shí)間要遠(yuǎn)小于鎖的有效時(shí)間,這樣便于快速嘗試與下一個(gè)實(shí)例發(fā)送請(qǐng)求。

比如鎖的自動(dòng)釋放時(shí)間 10s,則請(qǐng)求的超時(shí)時(shí)間可以設(shè)置 5~50 毫秒內(nèi),這樣可以防止客戶端長(zhǎng)時(shí)間阻塞。

客戶端獲取當(dāng)前時(shí)間 T2 并減去步驟 1 的 T1 來(lái)計(jì)算出獲取鎖所用的時(shí)間(T3 = T2 -T1)。當(dāng)且僅當(dāng)客戶端在大多數(shù)實(shí)例(N/2 + 1)獲取成功,且獲取鎖所用的總時(shí)間 T3 小于鎖的有效時(shí)間,才認(rèn)為加鎖成功,否則加鎖失敗。

如果第 3 步加鎖成功,則執(zhí)行業(yè)務(wù)邏輯操作共享資源,key 的真正有效時(shí)間等于有效時(shí)間減去獲取鎖所使用的時(shí)間(步驟 3 計(jì)算的結(jié)果)。

如果因?yàn)槟承┰?,獲取鎖失敗(沒有在至少 N/2+1 個(gè) Redis 實(shí)例取到鎖或者取鎖時(shí)間已經(jīng)超過了有效時(shí)間),客戶端應(yīng)該在所有的 Redis 實(shí)例上進(jìn)行解鎖(即便某些 Redis 實(shí)例根本就沒有加鎖成功)。

另外部署實(shí)例的數(shù)量要求是奇數(shù),為了能很好的滿足過半原則,如果是 6 臺(tái)則需要 4 臺(tái)獲取鎖成功才能認(rèn)為成功,所以奇數(shù)更合理

事情可沒這么簡(jiǎn)單,Redis 作者把這個(gè)方案提出后,受到了業(yè)界著名的分布式系統(tǒng)專家的質(zhì)疑。

兩人好比神仙打架,兩人一來(lái)一回論據(jù)充足的對(duì)一個(gè)問題提出很多論斷……

Martin Kleppmann 提出質(zhì)疑的博客:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

Redlock 設(shè)計(jì)者的回復(fù):http://antirez.com/news/101

Redlock 是與非

Martin Kleppmann 認(rèn)為鎖定的目的是為了保護(hù)對(duì)共享資源的讀寫,而分布式鎖應(yīng)該「高效」和「正確」。

高效性:分布式鎖應(yīng)該要滿足高效的性能,Redlock 算法向 5 個(gè)節(jié)點(diǎn)執(zhí)行獲取鎖的邏輯性能不高,成本增加,復(fù)雜度也高;

正確性:分布式鎖應(yīng)該防止并發(fā)進(jìn)程在同一時(shí)刻只能有一個(gè)線程能對(duì)共享數(shù)據(jù)讀寫。

出于這兩點(diǎn),我們沒必要承擔(dān) Redlock 的成本和復(fù)雜,運(yùn)行 5 個(gè) Redis 實(shí)例并判斷加鎖是否滿足大多數(shù)才算成功。

主從架構(gòu)崩潰恢復(fù)極小可能發(fā)生,這沒什么大不了的。使用單機(jī)版就夠了,Redlock 太重了,沒必要。

Martin 認(rèn)為 Redlock 根本達(dá)不到安全性的要求,也依舊存在鎖失效的問題!

Martin 的結(jié)論

Redlock 不倫不類:對(duì)于偏好效率來(lái)講,Redlock 比較重,沒必要這么做,而對(duì)于偏好正確性來(lái)說(shuō),Redlock 是不夠安全的。

時(shí)鐘假設(shè)不合理:該算法對(duì)系統(tǒng)時(shí)鐘做出了危險(xiǎn)的假設(shè)(假設(shè)多個(gè)節(jié)點(diǎn)機(jī)器時(shí)鐘都是一致的),如果不滿足這些假設(shè),鎖就會(huì)失效。

無(wú)法保證正確性:Redlock 不能提供類似 fencing token 的方案,所以解決不了正確性的問題。為了正確性,請(qǐng)使用有「共識(shí)系統(tǒng)」的軟件,例如 Zookeeper。

Redis 作者 Antirez 的反駁

在 Redis 作者的反駁文章中,有 3 個(gè)重點(diǎn):

時(shí)鐘問題:Redlock 并不需要完全一致的時(shí)鐘,只需要大體一致就可以了,允許有「誤差」,只要誤差不要超過鎖的租期即可,這種對(duì)于時(shí)鐘的精度要求并不是很高,而且這也符合現(xiàn)實(shí)環(huán)境。

網(wǎng)絡(luò)延遲、進(jìn)程暫停問題:

客戶端在拿到鎖之前,無(wú)論經(jīng)歷什么耗時(shí)長(zhǎng)問題,Redlock 都能夠在第 3 步檢測(cè)出來(lái)

客戶端在拿到鎖之后,發(fā)生 NPC,那 Redlock、Zookeeper 都無(wú)能為力

質(zhì)疑 fencing token 機(jī)制。

關(guān)于 Redlock 的爭(zhēng)論我們下期再見,現(xiàn)在進(jìn)入 Redisson 實(shí)現(xiàn)分布式鎖實(shí)戰(zhàn)部分。

Redisson 分布式鎖

基于 SpringBoot starter 方式,添加 starter。

  1. <dependency> 
  2.   <groupId>org.redisson</groupId> 
  3.   <artifactId>redisson-spring-boot-starter</artifactId> 
  4.   <version>3.16.4</version> 
  5. </dependency> 

不過這里需要注意 springboot 與 redisson 的版本,因?yàn)楣俜酵扑] redisson 版本與 springboot 版本配合使用。

將 Redisson 與 Spring Boot 庫(kù)集成,還取決于 Spring Data Redis 模塊。

「碼哥」使用 SpringBoot 2.5.x 版本, 所以需要添加 redisson-spring-data-25。

  1. <dependency> 
  2.   <groupId>org.redisson</groupId> 
  3.   <!-- for Spring Data Redis v.2.5.x --> 
  4.   <artifactId>redisson-spring-data-25</artifactId> 
  5.   <version>3.16.4</version> 
  6. </dependency> 

添加配置文件

  1. spring: 
  2.   redis: 
  3.     database
  4.     host: 
  5.     port: 
  6.     password
  7.     ssl: 
  8.     timeout: 
  9.     # 根據(jù)實(shí)際情況配置 cluster 或者哨兵 
  10.     cluster: 
  11.       nodes: 
  12.     sentinel: 
  13.       master: 
  14.       nodes: 

就這樣在 Spring 容器中我們擁有以下幾個(gè) Bean 可以使用:

  • RedissonClient
  • RedissonRxClient
  • RedissonReactiveClient
  • RedisTemplate
  • ReactiveRedisTemplate

失敗無(wú)限重試

  1. RLock lock = redisson.getLock("碼哥字節(jié)"); 
  2. try { 
  3.  
  4.   // 1.最常用的第一種寫法 
  5.   lock.lock(); 
  6.  
  7.   // 執(zhí)行業(yè)務(wù)邏輯 
  8.   ..... 
  9.  
  10. } finally { 
  11.   lock.unlock(); 

拿鎖失敗時(shí)會(huì)不停的重試,具有 Watch Dog 自動(dòng)延期機(jī)制,默認(rèn)續(xù) 30s 每隔 30/3=10 秒續(xù)到 30s。

失敗超時(shí)重試,自動(dòng)續(xù)命

  1. // 嘗試拿鎖10s后停止重試,獲取失敗返回false,具有Watch Dog 自動(dòng)延期機(jī)制, 默認(rèn)續(xù)30s 
  2. boolean flag = lock.tryLock(10, TimeUnit.SECONDS); 

超時(shí)自動(dòng)釋放鎖

  1. // 沒有Watch Dog ,10s后自動(dòng)釋放,不需要調(diào)用 unlock 釋放鎖。 
  2.  
  3. lock.lock(10, TimeUnit.SECONDS); 

超時(shí)重試,自動(dòng)解鎖

  1. // 嘗試加鎖,最多等待100秒,上鎖以后10秒自動(dòng)解鎖,沒有 Watch dog 
  2. boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); 
  3. if (res) { 
  4.    try { 
  5.      ... 
  6.    } finally { 
  7.        lock.unlock(); 
  8.    } 

Watch Dog 自動(dòng)延時(shí)

如果獲取分布式鎖的節(jié)點(diǎn)宕機(jī),且這個(gè)鎖還處于鎖定狀態(tài),就會(huì)出現(xiàn)死鎖。

為了避免這個(gè)情況,我們都會(huì)給鎖設(shè)置一個(gè)超時(shí)自動(dòng)釋放時(shí)間。

然而,還是會(huì)存在一個(gè)問題。

假設(shè)線程獲取鎖成功,并設(shè)置了 30 s 超時(shí),但是在 30s 內(nèi)任務(wù)還沒執(zhí)行完,鎖超時(shí)釋放了,就會(huì)導(dǎo)致其他線程獲取不該獲取的鎖。

所以,Redisson 提供了 watch dog 自動(dòng)延時(shí)機(jī)制,提供了一個(gè)監(jiān)控鎖的看門狗,它的作用是在 Redisson 實(shí)例被關(guān)閉前,不斷的延長(zhǎng)鎖的有效期。

也就是說(shuō),如果一個(gè)拿到鎖的線程一直沒有完成邏輯,那么看門狗會(huì)幫助線程不斷的延長(zhǎng)鎖超時(shí)時(shí)間,鎖不會(huì)因?yàn)槌瑫r(shí)而被釋放。

默認(rèn)情況下,看門狗的續(xù)期時(shí)間是 30s,也可以通過修改 Config.lockWatchdogTimeout 來(lái)另行指定。

另外 Redisson 還提供了可以指定 leaseTime 參數(shù)的加鎖方法來(lái)指定加鎖的時(shí)間。

超過這個(gè)時(shí)間后鎖便自動(dòng)解開了,不會(huì)延長(zhǎng)鎖的有效期。

原理如下圖:

有兩個(gè)點(diǎn)需要注意:

  • watchDog 只有在未顯示指定加鎖超時(shí)時(shí)間(leaseTime)時(shí)才會(huì)生效。
  • lockWatchdogTimeout 設(shè)定的時(shí)間不要太小 ,比如設(shè)置的是 100 毫秒,由于網(wǎng)絡(luò)直接導(dǎo)致加鎖完后,watchdog 去延期時(shí),這個(gè) key 在 redis 中已經(jīng)被刪除了。

源碼導(dǎo)讀

在調(diào)用 lock 方法時(shí),會(huì)最終調(diào)用到 tryAcquireAsync。

調(diào)用鏈為:lock()->tryAcquire->tryAcquireAsync,詳細(xì)解釋如下:

  1. private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) { 
  2.         RFuture<Long> ttlRemainingFuture; 
  3.         //如果指定了加鎖時(shí)間,會(huì)直接去加鎖 
  4.         if (leaseTime != -1) { 
  5.             ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG); 
  6.         } else { 
  7.             //沒有指定加鎖時(shí)間 會(huì)先進(jìn)行加鎖,并且默認(rèn)時(shí)間就是 LockWatchdogTimeout的時(shí)間 
  8.             //這個(gè)是異步操作 返回RFuture 類似netty中的future 
  9.             ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime, 
  10.                     TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG); 
  11.         } 
  12.  
  13.         //這里也是類似netty Future 的addListener,在future內(nèi)容執(zhí)行完成后執(zhí)行 
  14.         ttlRemainingFuture.onComplete((ttlRemaining, e) -> { 
  15.             if (e != null) { 
  16.                 return
  17.             } 
  18.  
  19.             // lock acquired 
  20.             if (ttlRemaining == null) { 
  21.                 // leaseTime不為-1時(shí),不會(huì)自動(dòng)延期 
  22.                 if (leaseTime != -1) { 
  23.                     internalLockLeaseTime = unit.toMillis(leaseTime); 
  24.                 } else { 
  25.                     //這里是定時(shí)執(zhí)行 當(dāng)前鎖自動(dòng)延期的動(dòng)作,leaseTime為-1時(shí),才會(huì)自動(dòng)延期 
  26.                     scheduleExpirationRenewal(threadId); 
  27.                 } 
  28.             } 
  29.         }); 
  30.         return ttlRemainingFuture; 
  31.     } 

scheduleExpirationRenewal 中會(huì)調(diào)用 renewExpiration 啟用了一個(gè) timeout 定時(shí),去執(zhí)行延期動(dòng)作。

  1. private void renewExpiration() { 
  2.         ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName()); 
  3.         if (ee == null) { 
  4.             return
  5.         } 
  6.  
  7.         Timeout task = commandExecutor.getConnectionManager() 
  8.           .newTimeout(new TimerTask() { 
  9.             @Override 
  10.             public void run(Timeout timeout) throws Exception { 
  11.                 // 省略部分代碼 
  12.                 .... 
  13.  
  14.                 RFuture<Boolean> future = renewExpirationAsync(threadId); 
  15.                 future.onComplete((res, e) -> { 
  16.                     .... 
  17.  
  18.                     if (res) { 
  19.                         //如果 沒有報(bào)錯(cuò),就再次定時(shí)延期 
  20.                         // reschedule itself 
  21.                         renewExpiration(); 
  22.                     } else { 
  23.                         cancelExpirationRenewal(null); 
  24.                     } 
  25.                 }); 
  26.             } 
  27.             // 這里我們可以看到定時(shí)任務(wù) 是 lockWatchdogTimeout 的1/3時(shí)間去執(zhí)行 renewExpirationAsync 
  28.         }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); 
  29.  
  30.         ee.setTimeout(task); 
  31.     } 

scheduleExpirationRenewal 會(huì)調(diào)用到 renewExpirationAsync,執(zhí)行下面這段 lua 腳本。

他主要判斷就是 這個(gè)鎖是否在 redis 中存在,如果存在就進(jìn)行 pexpire 延期。

  1. protected RFuture<Boolean> renewExpirationAsync(long threadId) { 
  2.         return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, 
  3.                 "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + 
  4.                         "redis.call('pexpire', KEYS[1], ARGV[1]); " + 
  5.                         "return 1; " + 
  6.                         "end; " + 
  7.                         "return 0;"
  8.                 Collections.singletonList(getRawName()), 
  9.                 internalLockLeaseTime, getLockName(threadId)); 
  10.     } 
  • watch dog 在當(dāng)前節(jié)點(diǎn)還存活且任務(wù)未完成則每 10 s 給鎖續(xù)期 30s。
  • 程序釋放鎖操作時(shí)因?yàn)楫惓]有被執(zhí)行,那么鎖無(wú)法被釋放,所以釋放鎖操作一定要放到 finally {} 中;
  • 要使 watchLog 機(jī)制生效 ,lock 時(shí) 不要設(shè)置 過期時(shí)間。
  • watchlog 的延時(shí)時(shí)間 可以由 lockWatchdogTimeout 指定默認(rèn)延時(shí)時(shí)間,但是不要設(shè)置太小。
  • watchdog 會(huì)每 lockWatchdogTimeout/3 時(shí)間,去延時(shí)。
  • 通過 lua 腳本實(shí)現(xiàn)延遲。

總結(jié)

完工,我建議你合上屏幕,自己在腦子里重新過一遍,每一步都在做什么,為什么要做,解決什么問題。

我們一起從頭到尾梳理了一遍 Redis 分布式鎖中的各種門道,其實(shí)很多點(diǎn)是不管用什么做分布式鎖都會(huì)存在的問題,重要的是思考的過程。

對(duì)于系統(tǒng)的設(shè)計(jì),每個(gè)人的出發(fā)點(diǎn)都不一樣,沒有完美的架構(gòu),沒有普適的架構(gòu),但是在完美和普適能平衡的很好的架構(gòu),就是好的架構(gòu)。

本文轉(zhuǎn)載自微信公眾號(hào)「碼哥字節(jié)」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系碼哥字節(jié)公眾號(hào)。

 

責(zé)任編輯:武曉燕 來(lái)源: 碼哥字節(jié)
相關(guān)推薦

2025-03-25 10:29:52

2024-01-24 13:15:00

Redis分布式鎖SpringBoot

2024-11-28 15:11:28

2024-01-02 13:15:00

分布式鎖RedissonRedis

2022-08-04 08:45:50

Redisson分布式鎖工具

2021-09-17 07:51:24

RedissonRedis分布式

2021-02-28 07:49:28

Zookeeper分布式

2023-01-13 07:39:07

2023-08-21 19:10:34

Redis分布式

2022-01-06 10:58:07

Redis數(shù)據(jù)分布式鎖

2022-06-30 08:04:16

Redis分布式鎖Redisson

2019-06-19 15:40:06

分布式鎖RedisJava

2024-11-06 12:29:02

2021-07-06 08:37:29

Redisson分布式

2023-09-04 08:12:16

分布式鎖Springboot

2019-02-26 09:51:52

分布式鎖RedisZookeeper

2021-03-10 09:54:06

Redis分布式

2024-10-07 10:07:31

2023-03-01 08:07:51

2024-04-01 05:10:00

Redis數(shù)據(jù)庫(kù)分布式鎖
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)