參考:
RedLock
什么是 RedLock
Redis 官方站這篇文章提出了一種權威的基於 Redis 實現分布式鎖的方式名叫 Redlock,此種方式比原先的單節點的方法更安全。它可以保證以下特性:
安全特性:互斥訪問,即永遠只有一個 client 能拿到鎖
避免死鎖:最終 client 都可能拿到鎖,不會出現死鎖的情況,即使原本鎖住某資源的 client crash 了或者出現了網絡分區
容錯性:只要大部分 Redis 節點存活就可以正常提供服務
怎么在單節點上實現分布式鎖
SET resource_name my_random_value NX PX 30000
主要依靠上述命令,該命令僅當 Key 不存在時(NX保證)set 值,並且設置過期時間 3000ms (PX保證),值 my_random_value 必須是所有 client 和所有鎖請求發生期間唯一的,釋放鎖的邏輯是:

if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else return 0 end
上述實現可以避免釋放另一個client創建的鎖,如果只有 del 命令的話,那么如果 client1 拿到 lock1 之后因為某些操作阻塞了很長時間,
此時 Redis 端 lock1 已經過期了並且已經被重新分配給了 client2,那么 client1 此時再去釋放這把鎖就會造成 client2 原本獲取到的鎖被 client1 無故釋放了,
但現在為每個 client 分配一個 unique 的 string 值可以避免這個問題。至於如何去生成這個 unique string,方法很多隨意選擇一種就行了。
Redlock 算法
算法很易懂,起 5 個 master 節點,分布在不同的機房盡量保證可用性。為了獲得鎖,client 會進行如下操作:
- 得到當前的時間,微秒單位
- 嘗試順序地在 5 個實例上申請鎖,當然需要使用相同的 key 和 random value,這里一個 client 需要合理設置與 master 節點溝通的 timeout 大小,避免長時間和一個 fail 了的節點浪費時間
- 當 client 在大於等於 3 個 master 上成功申請到鎖的時候,且它會計算申請鎖消耗了多少時間,這部分消耗的時間采用獲得鎖的當下時間減去第一步獲得的時間戳得到,如果鎖的持續時長(lock validity time)比流逝的時間多的話,那么鎖就真正獲取到了。
- 如果鎖申請到了,那么鎖真正的 lock validity time 應該是 origin(lock validity time) - 申請鎖期間流逝的時間
- 如果 client 申請鎖失敗了,那么它就會在少部分申請成功鎖的 master 節點上執行釋放鎖的操作,重置狀態
失敗重試
如果一個 client 申請鎖失敗了,那么它需要稍等一會在重試避免多個 client 同時申請鎖的情況,最好的情況是一個 client 需要幾乎同時向 5 個 master 發起鎖申請。
另外就是如果 client 申請鎖失敗了它需要盡快在它曾經申請到鎖的 master 上執行 unlock 操作,便於其他 client 獲得這把鎖,避免這些鎖過期造成的時間浪費,
當然如果這時候網絡分區使得 client 無法聯系上這些 master,那么這種浪費就是不得不付出的代價了。
放鎖
放鎖操作很簡單,就是依次釋放所有節點上的鎖就行了
性能、崩潰恢復和 fsync
如果我們的節點沒有持久化機制,client 從 5 個 master 中的 3 個處獲得了鎖,然后其中一個重啟了,這是注意 整個環境中又出現了 3 個 master 可供另一個 client 申請同一把鎖! 違反了互斥性。
如果我們開啟了 AOF 持久化那么情況會稍微好轉一些,因為 Redis 的過期機制是語義層面實現的,所以在 server 掛了的時候時間依舊在流逝,重啟之后鎖狀態不會受到污染。
但是考慮斷電之后呢,AOF部分命令沒來得及刷回磁盤直接丟失了,除非我們配置刷回策略為 fsnyc = always,但這會損傷性能。
解決這個問題的方法是,當一個節點重啟之后,我們規定在 max TTL 期間它是不可用的,這樣它就不會干擾原本已經申請到的鎖,
等到它 crash 前的那部分鎖都過期了,環境不存在歷史鎖了,那么再把這個節點加進來正常工作。
Redlock(redis分布式鎖)原理分析
Redlock:全名叫做 Redis Distributed Lock;即使用redis實現的分布式鎖;
使用場景:多個服務間保證同一時刻同一時間段內同一用戶只能有一個請求(防止關鍵業務出現並發攻擊);
官網文檔地址如下:https://redis.io/topics/distlock
這個鎖的算法實現了多redis實例的情況,相對於單redis節點來說,
優點在於 防止了 單節點故障造成整個服務停止運行的情況;並且在多節點中鎖的設計,及多節點同時崩潰等各種意外情況有自己獨特的設計方法;
此博客或者官方文檔的相關概念:
1.TTL:Time To Live;只 redis key 的過期時間或有效生存時間
2.clock drift:時鍾漂移;指兩個電腦間時間流速基本相同的情況下,兩個電腦(或兩個進程間)時間的差值;如果電腦距離過遠會造成時鍾漂移值 過大
最低保證分布式鎖的有效性及安全性的要求如下:
1.互斥;任何時刻只能有一個client獲取鎖
2.釋放死鎖;即使鎖定資源的服務崩潰或者分區,仍然能釋放鎖
3.容錯性;只要多數redis節點(一半以上)在使用,client就可以獲取和釋放鎖
網上講的基於故障轉移實現的redis主從無法真正實現Redlock:
因為redis在進行主從復制時是異步完成的,比如在clientA獲取鎖后,主redis復制數據到從redis過程中崩潰了,
導致沒有復制到從redis中,然后從redis選舉出一個升級為主redis,造成新的主redis沒有clientA 設置的鎖,這是clientB嘗試獲取鎖,並且能夠成功獲取鎖,導致互斥失效;
思考題:這個失敗的原因是因為從redis立刻升級為主redis,如果能夠過TTL時間再升級為主redis(延遲升級)后,
或者立刻升級為主redis但是過TTL的時間后再執行獲取鎖的任務,就能成功產生互斥效果;是不是這樣就能實現基於redis主從的Redlock;
redis單實例中實現分布式鎖的正確方式(原子性非常重要):
1.設置鎖時,使用set命令,因為其包含了setnx,expire的功能,起到了原子操作的效果,給key設置隨機值,並且只有在key不存在時才設置成功返回True,並且設置key的過期時間(最好用毫秒)
SET key_name my_random_value NX PX 30000 # NX 表示if not exist
2.在獲取鎖后,並完成相關業務后,需要刪除自己設置的鎖(必須是只能刪除自己設置的鎖,不能刪除他人設置的鎖);
刪除原因:保證服務器資源的高利用效率,不用等到鎖自動過期才刪除;
刪除方法:最好使用Lua腳本刪除(redis保證執行此腳本時不執行其他操作,保證操作的原子性),代碼如下;邏輯是 先獲取key,如果存在並且值是自己設置的就刪除此key;否則就跳過;

if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else return 0 end
算法流程圖如下:
多節點redis實現的分布式鎖算法(RedLock):有效防止單點故障
假設有5個完全獨立的redis主服務器
1.獲取當前時間戳
2.client嘗試按照順序使用相同的key,value獲取所有redis服務的鎖,在獲取鎖的過程中的獲取時間比鎖過期時間短很多,這是為了不要過長時間等待已經關閉的redis服務。並且試着獲取下一個redis實例。
比如:TTL為5s,設置獲取鎖最多用1s,所以如果一秒內無法獲取鎖,就放棄獲取這個鎖,從而嘗試獲取下個鎖
3.client通過獲取所有能獲取的鎖后的時間減去第一步的時間,這個時間差要小於TTL時間並且至少有3個redis實例成功獲取鎖,才算真正的獲取鎖成功
4如果成功獲取鎖,則鎖的真正有效時間是 TTL減去第三步的時間差 的時間;比如:TTL 是5s,獲取所有鎖用了2s,則真正鎖有效時間為3s(其實應該再減去時鍾漂移);
5.如果客戶端由於某些原因獲取鎖失敗,便會開始解鎖所有redis實例;因為可能已經獲取了小於3個鎖,必須釋放,否則影響其他client獲取鎖
算法示意圖如下:
RedLock算法是否是異步算法?
可以看成是同步算法;因為 即使進程間(多個電腦間)沒有同步時鍾,但是每個進程時間流速大致相同;
並且時鍾漂移相對於TTL叫小,可以忽略,所以可以看成同步算法;(不夠嚴謹,算法上要算上時鍾漂移,因為如果兩個電腦在地球兩端,則時鍾漂移非常大)
RedLock失敗重試
當client不能獲取鎖時,應該在隨機時間后重試獲取鎖;並且最好在同一時刻並發的把set命令發送給所有redis實例;
而且對於已經獲取鎖的client在完成任務后要及時釋放鎖,這是為了節省時間;
RedLock釋放鎖
由於釋放鎖時會判斷這個鎖的value是不是自己設置的,如果是才刪除;所以在釋放鎖時非常簡單,只要向所有實例都發出釋放鎖的命令,不用考慮能否成功釋放鎖;
RedLock注意點(Safety arguments):
1.先假設client獲取所有實例,所有實例包含相同的key和過期時間(TTL) ,但每個實例set命令時間不同導致不能同時過期,
第一個set命令之前是T1,最后一個set命令后為T2,則此client有效獲取鎖的最小時間為TTL-(T2-T1)-時鍾漂移;
2.對於以N/2+ 1(也就是一半以 上)的方式判斷獲取鎖成功,是因為如果小於一半判斷為成功的話,有可能出現多個client都成功獲取鎖的情況, 從而使鎖失效
3.一個client鎖定大多數事例耗費的時間大於或接近鎖的過期時間,就認為鎖無效,並且解鎖這個redis實例(不執行業務) ;只要在TTL時間內成功獲取一半以上的鎖便是有效鎖;否則無效
系統有活性的三個特征
1.能夠自動釋放鎖
2.在獲取鎖失敗(不到一半以上),或任務完成后 能夠自動釋放鎖,不用等到其自動過期
3.在client重試獲取哦鎖前(第一次失敗到第二次重試時間間隔)大於第一次獲取鎖消耗的時間;
4.重試獲取鎖要有一定次數限制
RedLock性能及崩潰恢復的相關解決方法
1.如果redis沒有持久化功能,在clientA獲取鎖成功后,所有redis重啟,clientB能夠再次獲取到鎖,這樣違法了鎖的排他互斥性;
2.如果啟動AOF永久化存儲,事情會好些, 舉例:當我們重啟redis后,由於redis過期機制是按照unix時間戳走的,所以在重啟后,然后會按照規定的時間過期,
不影響業務;但是由於AOF同步到磁盤的方式默認是每秒-次,如果在一秒內斷電,會導致數據丟失,
立即重啟會造成鎖互斥性失效;但如果同步磁盤方式使用Always(每一個寫命令都同步到硬盤)造成性能急劇下降;所以在鎖完全有效性和性能方面要有所取舍;
3.有效解決既保證鎖完全有效性及性能高效及即使斷電情況的方法是redis同步到磁盤方式保持默認的每秒,
在redis無論因為什么原因停掉后要等待TTL時間后再重啟(學名:延遲重啟) ;缺點是 在TTL時間內服務相當於暫停狀態;
SpringBoot+Redis分布式鎖:模擬搶單

package com.test.yl.testjedis; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.params.SetParams; /** * @ClassName: Test * @Description: 測試分布式鎖 */ @SpringBootTest public class Test { @Autowired private JedisPool jedisPool; /** * @Description: 獲取鎖 * @parameter: key * @parameter: val * @return: boolean */ public boolean setnx(String key, String val) { Jedis jedis = null; try { jedis = jedisPool.getResource(); if (jedis == null) { return false; } SetParams setParams=new SetParams(); //NX:是否存在key,存在就不set成功 //PX:key過期時間單位設置為毫秒(EX:單位秒) setParams.nx().px(1000*60); return jedis.set(key, val,setParams). equalsIgnoreCase("ok"); } catch (Exception ex) { } finally { if (jedis != null) { jedis.close(); } } return false; } /** * @Description: 刪除鎖 * @parameter: key * @parameter: val * @return: int */ public int delnx(String key, String val) { Jedis jedis = null; try { jedis = jedisPool.getResource(); if (jedis == null) { return 0; } //這里也使用了jedis方式,直接執行lua腳本:根據val判斷其是否存在,如果存在就del; // //其實個人認為通過jedis的get方式獲取val后,然后再比較value是否是當前持有鎖的用戶, // 如果是那最后再刪除,效果其實相當; // 只不過直接通過eval執行腳本,這樣避免多一次操作了redis而已,縮短了原子操作的間隔。 //if redis.call('get','orderkey')=='1111' then return redis.call('del','orderkey') else return 0 end StringBuilder sbScript = new StringBuilder(); sbScript.append("if redis.call('get','").append(key).append("')").append("=='").append(val).append("'"). append(" then "). append(" return redis.call('del','").append(key).append("')"). append(" else "). append(" return 0"). append(" end"); return Integer.valueOf(jedis.eval(sbScript.toString()).toString()); } catch (Exception ex) { } finally { if (jedis != null) { jedis.close(); } } return 0; } @org.junit.jupiter.api.Test public void test11(){ Boolean flag= setnx("yy","11"); System.out.println("---------------flag:"+flag); } }