想要實作分布式鎖,Redis必須要有互斥能力,比如setnx指令,即如果key不存在,才會設定它的值。
用戶端1:
用戶端2:
此時,加鎖成功的用戶端就可以去操作共享資源。操作完成後,還要及時釋放鎖,給後來者讓出操作共享資源的機會,這裡我們可以使用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步:
- 用戶端先擷取目前時間戳 T1
- 用戶端依次向這 5 個 Redis 執行個體發起加鎖請求,且每個請求會設定逾時時間(毫秒級,要遠小于鎖的有效時間),如果某一個執行個體加鎖失敗(包括網絡逾時、鎖被其它人持有等各種異常情況),就立即向下一個 Redis 執行個體申請加鎖
- 如果用戶端從 >=3 個(大多數)以上 Redis 執行個體加鎖成功,則再次擷取「目前時間戳 T2」,如果 T2 - T1 < 鎖的過期時間,此時,認為用戶端加鎖成功,否則認為加鎖失敗。
- 加鎖成功,去操作共享資源
- 加鎖失敗,向「全部節點」發起釋放鎖請求(前面講到的 Lua 腳本釋放鎖)
Redlock還會遇到三座大山:NPC問題
- N:Network Delay,網絡延遲
- P:Process Pause,程序暫停(GC)
- C:Clock Drift,時鐘漂移
總體來說:Redlock還是不太建議用的,對于效率來說,Redlock比較重,沒必要同時部署那麼多台執行個體,對于正确性來說,Redlock是不夠安全的,時鐘假設不合理,該算法對系統時鐘做出了危險的假設(假設多個節點機器時鐘都是一緻的),如果不滿足這些假設,鎖就會失效。 無法保證正确性。
基于Zookeeper的鎖安全:
- 用戶端1和2都嘗試建立臨時節點 如/lock
- 假設用戶端1先到達,則加鎖成功,用戶端2加鎖失敗
- 用戶端1操作共享資源
- 用戶端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 執行個體之間進行資料遷移的功能,整個遷移的過程分為兩步:
- 在源 Redis 上,dump 指令會将鍵值序列化,格式采用的是 RDB 格式。
- 在目标 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