天天看點

8、Redis分布式鎖

想要實作分布式鎖,Redis必須要有互斥能力,比如setnx指令,即如果key不存在,才會設定它的值。

用戶端1:

8、Redis分布式鎖

用戶端2:

8、Redis分布式鎖

此時,加鎖成功的用戶端就可以去操作共享資源。操作完成後,還要及時釋放鎖,給後來者讓出操作共享資源的機會,這裡我們可以使用del指令删除這個key即可。

問題:當用戶端1命到鎖後,如果程式處理業務邏輯異常,沒有及時釋放鎖或是程序挂了,沒機會釋放鎖,那麼就會造成死鎖,用戶端1一直占用這個鎖,其它用戶端就永遠拿不到鎖了。

如何避免上述的死鎖呢?

可以給這個key設定一個過期時間,假設操作共享資源的時間不會超過10s,那麼在加鎖時給這個key設定10s過期即可:這樣無論用戶端是否異常,這個鎖10s後都會被自動釋放。

setnx lock 1  //加鎖
expire lock 10  //10s後自動過期
           

這樣處理的問題又來了。。。

加鎖和設定過期是2條指令,如果隻執行了一條呢?不能保證原子性

那麼我們就用下面這一條指令來執行吧,以保證執行的原子性

set lock 1 ex 10 nx
           

這樣雖然解決了死鎖,但是。。。。你以為就這樣了嗎?不。。。問題來了:

想想用戶端1加鎖成功,開始操作共享資源,但是由于種種原因10s後還沒有處理完,鎖就被自動釋放了,然後用戶端2來加鎖成功,開始操作共享資源,這時用戶端1操作共享資源完成,釋放鎖(釋放的是用戶端2的鎖)

有什麼好的解決方案嗎?

鎖過期:我們可以延長過期時間,比如把10s改成15s,

鎖被别人釋放:我們可以在用戶端在加鎖時,設定一個隻有自己知道的唯一辨別進去

set lock $uuid ex 20 nx
           

在釋放鎖的時候,先判斷這把鎖是否還歸自己持有,僞代碼:

if redis.get("lock") == $uuid: 
redis.del("lock")
           

這裡釋放鎖使用的get、del兩條指令,那麼新的問題又來了,在這裡我們又會遇到之前說的原子性問題

解決方案:可以寫成lua腳本,讓Redis執行。

因為 Redis 處理每一個請求是「單線程」執行的,在執行一個 Lua 腳本時,其它請求必須等待,直到這個 Lua 腳本處理完成,這樣一來,GET + DEL 之間就不會插入其它指令了。

安全釋放鎖的lua腳本:

if redis.call("GET",KEYS[1]) == ARGV[1] 
then 
	return redis.call("DEL",KEYS[1]) 
else 
	return 0 
end
           

好了,現在我們小結一下優化後的基于Redis實作分布式鎖的流程:

加鎖:

SET lock_key $unique_id EX $expire_time NX
           

操作共享資源

釋放鎖:Lua 腳本,先 GET 判斷鎖是否歸屬自己,再 DEL 釋放鎖

現在要考慮的是鎖過期時間怎樣評估,這個不好評估要怎麼辦???

方案來了:加鎖時,先設定一個過期時間,然後我們開啟一個守護線程,定時去檢測這個鎖的失效時間,如果鎖快要過期了,操作共享資源還未完成,那麼就自動對鎖進行續期,重新設定過期時間。

嗯嗯,Redis都已經幫我們封裝好了,這個守護線程一般我們把它叫做看門狗線程

Github上可以學習如何使用:https://github.com/redisson/redisson/

好了,我們再來看看前面遇到的幾個主要問題的解決方案:

1、死鎖:設定過期時間

2、過期時間評估不好,鎖提前過期:守護線程,自動續期

3、鎖被别人釋放:鎖寫入唯一辨別,釋放鎖先檢查辨別,再釋放

這還隻是在單機的情況下,那麼我們一般使用Redis都會采用主從叢集+哨兵模式部署,在主從發生切換時,這個分布式鎖還會安全嗎?

試想:用戶端1在主庫上執行set指令加鎖成功,此時主庫異常當機,set指令還未同步到從庫上(主從複制是異步的)從庫被哨兵提升為新主庫,這個鎖在新的主庫上,丢失了!

那麼又怎樣解決這個問題呢?

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

來看看Redis作者是怎樣用紅鎖來解決主從切換後,鎖失效問題的:

不再需要部署從庫和哨兵執行個體,隻部署主庫,3、5、7。。。官方推薦至少5個執行個體,而且都是主庫,它們之間沒有任何關系,都是孤立的執行個體。

整體流程分為5步:

  1. 用戶端先擷取目前時間戳 T1
  2. 用戶端依次向這 5 個 Redis 執行個體發起加鎖請求,且每個請求會設定逾時時間(毫秒級,要遠小于鎖的有效時間),如果某一個執行個體加鎖失敗(包括網絡逾時、鎖被其它人持有等各種異常情況),就立即向下一個 Redis 執行個體申請加鎖
  3. 如果用戶端從 >=3 個(大多數)以上 Redis 執行個體加鎖成功,則再次擷取「目前時間戳 T2」,如果 T2 - T1 < 鎖的過期時間,此時,認為用戶端加鎖成功,否則認為加鎖失敗。
  4. 加鎖成功,去操作共享資源
  5. 加鎖失敗,向「全部節點」發起釋放鎖請求(前面講到的 Lua 腳本釋放鎖)

Redlock還會遇到三座大山:NPC問題

  • N:Network Delay,網絡延遲
  • P:Process Pause,程序暫停(GC)
  • C:Clock Drift,時鐘漂移

總體來說:Redlock還是不太建議用的,對于效率來說,Redlock比較重,沒必要同時部署那麼多台執行個體,對于正确性來說,Redlock是不夠安全的,時鐘假設不合理,該算法對系統時鐘做出了危險的假設(假設多個節點機器時鐘都是一緻的),如果不滿足這些假設,鎖就會失效。 無法保證正确性。

基于Zookeeper的鎖安全:

  1. 用戶端1和2都嘗試建立臨時節點 如/lock
  2. 假設用戶端1先到達,則加鎖成功,用戶端2加鎖失敗
  3. 用戶端1操作共享資源
  4. 用戶端1删除 /lock 節點,釋放鎖

它是采用了「臨時節點」,保證用戶端 1 拿到鎖後,隻要連接配接不斷,就可以一直持有鎖。

如果用戶端 1 異常崩潰了,那麼這個臨時節點會自動删除,保證了鎖一定會被釋放。

用戶端 1建立臨時節點後,Zookeeper 是如何保證讓這個用戶端一直持有鎖呢?

用戶端 1 此時會與 Zookeeper 伺服器維護一個 Session,這個 Session 會依賴用戶端「定時心跳」來維持連接配接。 如果 Zookeeper 長時間收不到用戶端的心跳,就認為這個 Session 過期了,也會把這個臨時節點删除。

總結

Redlock 隻有建立在「時鐘正确」的前提下,才能正常工作,如果你可以保證這個前提,那麼可以拿來使用。

但是時鐘偏移在現實中是存在的:

第一,從硬體角度來說,時鐘發生偏移是時有發生,無法避免。

第二,人為錯誤也是很難完全避免的。

是以,Redlock 盡量不用它,而且它的性能不如單機版 Redis,部署成本也高,優先考慮使用主從+ 哨兵的模式 實作分布式鎖。

優化方案:

1、使用分布式鎖,在上層完成「互斥」目的,雖然極端情況下鎖會失效,但它可以最大程度把并發請求阻擋在最上層,減輕操作資源層的壓力。

2、但對于要求資料絕對正确的業務,在資源層一定要做好「兜底」,設計思路可以借鑒 fencing token 的方案來做。

鍵的遷移

move

move key db
           

move 指令用于在 Redis 内部進行資料遷移,Redis 内部可以有多個資料庫,這裡隻需要知道 Redis 内部可以有多個資料庫,彼此在資料上是互相隔離的,move key db 就是把指定的鍵從源資料庫移動到目标資料庫中,但多資料庫功能不建議在生産環境使用。

dump + restore

dump key
restore key ttl value
           

dump + restore 可以實作在不同的 Redis 執行個體之間進行資料遷移的功能,整個遷移的過程分為兩步:

  1. 在源 Redis 上,dump 指令會将鍵值序列化,格式采用的是 RDB 格式。
  2. 在目标 Redis 上,restore 指令将上面序列化的值進行複原,其中 ttl 參數代表過期時間,如果 ttl=0 代表沒有過期時間。

需要注意的二點:

第一,整個遷移過程并非原子性的,而是通過用戶端分步完成的。

第二,遷移過程是開啟了兩個用戶端連接配接,是以 dump的結果不是在源 Redis 和目标 Redis 之間進行傳輸。

migrate

migrate host port key |"" destination-db timeout [copy] [replace] [keys key [key ...]]
           

migrate 指令也是用于在 Redis 執行個體間進行資料遷移的,實際上 migrate 指令就是将 dump,restore,del 三個指令進行組合,進而簡化了操作流程。migrate 指令具有原子性,而且從 Redis 3.0.6 版本以後已經支援遷移多個鍵的功能,有效地提高了遷移效率,migrate 在水準擴容中起到重要作用。

整個過程和 dump + restore 基本類似,但是有 3 點不太相同:

第一,整個過程是原子執行的,不需要在多個 Redis 執行個體上開啟用戶端的,隻需要在源 Redis 上執行 migrate 指令即可。

第二,migrate 指令的資料傳輸直接在源 Redis 和目标 Redis 上完成的。

第三,目标 Redis 完成 restore 後會發送 OK 給源 Redis,源 Redis 接收後會根據 migrate 對應的選項來決定是否在源 Redis 上删除對應的鍵。

migrate的參數:

host:目标 Redis 的 IP 位址。

port:目标 Redis 的端口。

keyl “”:在 Redis 3.0.6 版本之前,migrate 隻支援遷移一個鍵,是以此處是要遷移的鍵,但 Redis 3.0.6 版本之後支援遷移多個鍵,如果目前需要遷移多個鍵,此處為 空字元串""。

destination-db :目标 Redis 的資料庫索引,例如要遷移到 0 号資料庫,這裡就寫 0。

timeout:遷移的逾時時間(機關為毫秒)。

[copy] :如果添加此選項,遷移後并不删除源鍵。

[replace] :如果添加此選項,migrate 不管目标 Redis 是否存在該鍵都會正常遷移進行資料覆寫。

[ keys key [ key …]] :遷移多個鍵,例如要遷移 key1、key2、key3,此處填寫“keys key1 key2 key3”。

假設有兩個Redis,分别使用源6379端口、目标6380端口,現在将Redis源Redis的鍵hello遷移到目标Redis中,分為以下幾種情況:

情況1:源Redis有鍵hello,目标Redis沒有

migrate 127.0.0.1 6380 hello 0 1000OK
           

情況2:源Redis和目标Redis都有鍵hello

如果 migrate 指令沒有加 replace 選項會收到錯誤提示,如果加了 replace 會傳回 OK 表明遷移成功。

情況3:源Redis沒有鍵hello

此種情況會收到nokey的提示

情況4:源 Redis執行如下指令完成多個鍵的遷移

migrate 127.0.0.1 6380 "" 0 5000 keys key1 key2 key3