歡迎光臨
每天分享高質量文章

如何實現靠譜的分散式鎖技術?

分散式鎖,是用來控制分散式系統中互斥訪問共享資源的一種手段,從而避免並行導致的結果不可控。基本的實現原理和單行程鎖是一致的,透過一個共享標識來確定唯一性,對共享標識進行修改時能夠保證原子性和和對鎖服務呼叫方的可見性。由於分散式環境需要考慮各種異常因素,為實現一個靠譜的分散式鎖服務引入了一定的複雜度。

分散式鎖服務一般需要能夠保證以下幾點。

  1. 同一時刻只能有一個執行緒持有鎖

  2. 鎖能夠可重入

  3. 不會發生死鎖

  4. 具備阻塞鎖特性,且能夠及時從阻塞狀態被喚醒

  5. 鎖服務保證高效能和高可用

當前使用較多的分散式鎖方案主要基於 Redis、ZooKeeper 提供的功能特性加以封裝來實現的,下麵我們會簡要分析下這兩種鎖方案的處理流程以及它們各自的問題。

1. 基於 Redis 實現的鎖服務

加鎖流程

SET resource_name my_random_value 
NX PX max-lock-time

註:資源不存在時才能夠成功執行 set 操作,用於保證鎖持有者的唯一性;同時設定過期時間用於防止死鎖;記錄鎖的持有者,用於防止解鎖時解掉了不符合預期的鎖。

解鎖流程

if redis.get("resource_name") ==  
" my_random_value"return redis.del
("resource_name")else return 0

註:使用 Lua 指令碼保證獲取鎖的所有者、對比解鎖者是否所有者、解鎖是一個原子操作。


該方案的問題在於:


  1. 透過過期時間來避免死鎖,過期時間設定多長對業務來說往往比較頭疼,時間短了可能會造成:持有鎖的執行緒 A 任務還未處理完成,鎖過期了,執行緒 B 獲得了鎖,導致同一個資源被 A、B 兩個執行緒併發訪問;時間長了會造成:持有鎖的行程宕機,造成其他等待獲取鎖的行程長時間的無效等待。

  2. Redis 的主從非同步複製機制可能丟失資料,會出現如下場景:A 執行緒獲得了鎖,但鎖資料還未同步到 slave 上,master 掛了,slave 頂成主,執行緒 B 嘗試加鎖,仍然能夠成功,造成 A、B 兩個執行緒併發訪問同一個資源。


2、基於 ZooKeeper 實現的鎖服務


加鎖流程


  1. 首先在/resource_name節點下建立臨時有序節點 。

  2. 獲取當前執行緒建立的節點及 /resource_name 目錄下的所有子節點,確定當前節點序號是否最小,是則加鎖成功。否則監聽序號較小的前一個節點。

註:ZAB 一致性協議保證了鎖資料的安全性,不會因為資料丟失造成多個鎖持有者;心跳保活機制解決死鎖問題,防止由於行程掛掉或者僵死導致的鎖長時間被無效佔用。具備阻塞鎖特性,並透過 Watch 機制能夠及時從阻塞狀態被喚醒。


解鎖流程是刪除當前執行緒建立的臨時接點。


該方案的問題在於透過心跳保活機制解決死鎖會造成鎖的不安全性,可能會出現如下場景:


持有鎖的執行緒 A 僵死或網路故障,導致服務端長時間收不到來自客戶端的保活心跳,服務端認為客戶端行程不存活主動釋放鎖,執行緒 B 搶到鎖,執行緒 A 恢復,同時有兩個執行緒訪問共享資源。


基於上訴對現有鎖方案的討論,我們能看到,一個理想的鎖設計標的主要應該解決如下問題:


  1. 鎖資料本身的安全性。

  2. 不發生死鎖。

  3. 不會有多個執行緒同時持有相同的鎖。


而為了實現不發生死鎖的標的,又需要引入一種機制,當持有鎖的行程因為宕機、GC 活者網路故障等各種原因無法主動過釋放鎖時,能夠有其他手段釋放掉鎖,主流的做法有兩種:


  1. 鎖設定過期時間,過期之後 Server 端自動釋放鎖。

  2. 對鎖的持有行程進行探活,發現持鎖行程不存活時 Server 端自動釋放。


實際上不管採用哪種方式,都可能造成鎖的安全性被破壞,導致多個執行緒同時持有同一把鎖的情況出現。因此我們認為鎖設計方案應在預防死鎖和鎖的安全性上取得平衡,沒有一種方案能夠絕對意義上保證不發生死鎖並且是安全的。


而鎖一般的用途又可以分為兩種,實際應用場景下,需要根據具體需求出發,權衡各種因素,選擇合適的鎖服務實現模型。無論選擇哪一種模型,需要我們清楚地知道它在安全性上有哪些不足,以及它會帶來什麼後果。


  1. 為了效率,主要是避免一件事被重覆的做多次,用於節省 IT 成本,即使鎖偶然失效,也不會造成資料錯誤,該種情況首要考慮的是如何防止死鎖。

  2. 為了正確性,在任何情況下都要保證共享資源的互斥訪問,一旦發生就意味著資料可能不一致,造成嚴重的後果,該種情況首要考慮的是如何保證鎖的安全。


下麵主要介紹一下 SharkLock 的一些設計選擇。

鎖資訊設計如下


  • lockBy:Client 唯一標識。

  • condition:Client 在加鎖時傳給 Server,用於定義 Client 期望 Server 的行為方式。

  • lockTime:加鎖時間。

  • txID:全域性自增 ID。

  • lease:租約。


如何保證鎖資料的可靠性


SharkLock 底層儲存使用的是 SharkStore,SharkStore 是一個分散式的持久化 Key-Value 儲存系統。採用多副本來保證資料安全,同時使用 raft 來保證各個副本之間的資料一致性。


如何預防死鎖


  1. Client 定時向 Server 傳送心跳包,Server 收到心跳包之後,維護 Server 端 Session 並立即回覆,Client 收到心跳包響應後,維護 Client 端 Session。心跳包同時承擔了延長 Session 租約的功能。

  2. 當鎖持有方發生故障時,Server 會在 Session 租約到期後,自動刪除該 Client 持有的鎖,以避免鎖長時間無法釋放而導致死鎖。Client 會在 Session 租約到期後,進行回呼,可選擇性的決策是否要結束對當前持有資源的訪問。

  3. 對於未設定過期的鎖,也就意味著無法透過租約自動釋放故障 Client 持有的鎖。因此額外提供了一種協商機制,在加鎖的時候傳遞一些 condition 到服務端,用於約定 Client 端期望 Server 端對異常情況的處理,包括什麼情況下能夠釋放鎖。譬如可以透過這種機制實現 Server 端在未收到十個心跳請求後自動釋放鎖,Client 端在未收到五個心跳響應後主動結束對共享資源的訪問。

  4. 盡最大程度保證鎖被加鎖行程主動釋放。

  • 行程正常關閉時呼叫鉤子來嘗試釋放鎖

  • 未釋放的鎖資訊寫檔案,行程重啟後讀取鎖資訊,並嘗試釋放鎖。


如何確保鎖的安全性


1. 儘量不打破誰加鎖誰解鎖的約束,盡最大程度保證鎖被加鎖行程主動釋放。


  • a)行程正常關閉時呼叫鉤子來嘗試釋放鎖。

  • b)未釋放的鎖資訊寫檔案,行程重啟後讀取鎖資訊,並嘗試釋放鎖。


2. 依靠自動續約來維持鎖的持有狀態,在正常情況下,客戶端可以持有鎖任意長的時間,這可以確保它做完所有需要的資源訪問操作之後再釋放鎖。一定程度上防止如下情況發生。


  • a)執行緒 A 獲取鎖,進行資源訪問。

  • b)鎖已經過期,但 A 執行緒未執行完成。

  • c)執行緒 B 獲得了鎖,導致同時有兩個執行緒在訪問共享資源。


3. 提供一種安全檢測機制,用於對安全性要求極高的業務場景。


  • a)對於同一把鎖,每一次獲取鎖操作,都會得到一個全域性增長的版本號。

  • b)對外暴露檢測 API checkVersion(lock_name,version),用於檢測持鎖行程的鎖是不是已經被其他行程搶佔(鎖已經有了更新的版本號)。

  • c)加鎖成功的客戶端與後端資源伺服器通訊的時候可帶上版本號,後端資源伺服器處理請求前,呼叫 checkVersion 去檢查鎖是否依然有效。有效則認為此客戶端依舊是鎖的持有者,可以為其提供服務。

  • d)該機制能在一定程度上解決持鎖 A 執行緒發生故障,Server 主動釋放鎖,執行緒 B 獲取鎖成功,A 恢復了認為自己仍舊持有鎖而發起修改資源的請求,會因為鎖的版本號已經過期而失敗,從而保障了鎖的安全性。


下麵對 SharkLock 依賴的 SharkStore 做一個簡單的介紹。


SharkStore 基本模組


  • Master Server 叢集分片路由等元資料管理、擴容和 Failover 排程等。

  • Data Server 資料儲存節點,提供 RPC 服務訪問其上的 KV 資料。

  • Gateway Server 閘道器節點,負責使用者接入。


Sharding


SharkStore 採用多副本的形式來保證資料的可靠性和高可用。同一份資料會儲存多份,少於一半的副本宕機仍然可以正常服務。 SharkStore 的資料分佈如下圖所示。


擴容方案


當某個分片的大小到達一定閾值,就會觸發分裂操作,由一個分片變成兩個,以達到擴容的目的。

Dataserver 上 range 的 leader 自己觸發。 leader 維持寫入操作位元組計數,每到達 check size 大小,就非同步遍歷其負責範圍內的資料,計算大小並同時找出分裂時的中間 key 如果大小到達 split size,向 master 發起 AskSplit 請求,同意後提交一個分裂命令。分裂命令也會透過 raft 複製到其他副本。


本地分裂。分裂是一個本地操作,在本地新建一個 range,把原始 range 的部分資料劃撥給新 range,原始 range 仍然保留,只是負責的範圍減半。分裂是一個輕量級的操作。


Failover 方案


failover 以 range 的級別進行。range 的 leader 定時向其他副本傳送心跳,一段時間內收不到副本的心跳回應,就判斷副本宕機,透過 range 心跳上報給 master。由 master 發起 failover 排程。 Master 會先刪除宕機的副本然後選擇一個合適的新節點,新增到 range 組內之後透過 raft 複製協議來完成新節點的資料同步。


Balance 方案


dataserver 上的 range leader 會透過 range 心跳上報一些資訊,每個 dataserver 還會有一個節點級別的 Node 心跳。 Master 收集這些資訊來執行 balance 操作。Balance 透過在流量低的節點上增加副本,流量高的節點上減少副本促使整個叢集比較均衡,維護叢集的穩定和效能。


Raft實踐: MultiRaft


1. 心跳合併


以標的 dataserver 為維度,合併 dataserver 上所有 Raft 心跳 心跳只攜帶 range ids,心跳只用來維護 leader 的權威和副本健康檢測 range ids 的壓縮,比如差量 + 整型變長 Leader 類似跟蹤複製進度,跟蹤 follower commit 位置。


2. 快照管理控制


建立 ACK 機制,在對端處理能力之內傳送快照 ; 控制傳送和應用快照的併發度,以及限速 ; 減少對正常業務的衝擊。Raft 實踐 -PreVote。


Raft 演演算法中,leader 收到來自其他成員 term 比較高的投票請求會退位變成 follower因此,在節點分割槽後重加入、網路閃斷等異常情況下,日誌進度落後的副本發起選舉,但其本身並無法被選舉為 leader,導致叢集在若干個心跳內丟失 leader,造成效能波動 ;針對這種情況,在 raft 作者的博士論文中,提出了 prevote 演演算法: 在發起選舉前,先進行一次預選舉 Pre-Candidate, 如果預選舉時能得到大多數的投票,再增加 term,進行正常的選舉。 


prevote 會導致選舉時間變長 (多了一輪 RPC),然而這個影響在實踐中是非常小的, 可以有利於叢集的穩定,是非常值得的實踐。


Raft 實踐: NonVoter


一個新的 raft 成員加入後,其日誌進度為空 ; 新成員的加入可能會導致 quorum 增加,並且同時引入了一個進度異常的副本 ; 新成員在跟上 leader 日誌進度之前,新寫入的日誌都無法複製給它 ; 如果此時再有原叢集內一個成員宕機, 很有可能導致叢集內可寫副本數到不到 quorum,使得叢集變得不可寫。 很多 raft 的實現中,都會引入了一種特殊身份的 raft 成員 (non-voting 或者 learner) Learner 在計算 quorum 時不計入其內,只被動接收日誌複製,選舉超時不發起選舉 ; 在計算寫入操作是否複製給大多數 (commit 位置) 時,也忽略 learner。 


Sharkstore raft 會在 leader 端監測 learner 的日誌進度, 當 learner 的進度跟 leader 的差距小於一定百分比 (適用於日誌總量比較大) 或者小於一定條數時 (適用於日誌總量比較小), 由 leader 自動發起一次 raft 成員變更,提升 leaner 成員為正常成員。


本號涉及技術和總結(20+)

可識別小程式獲取電子書詳細資訊

溫馨提示:

請搜尋“ICT_Architect”“掃一掃”二維碼關註公眾號,點選原文連結瞭解更多電子書詳情

求知若渴, 虛心若愚

贊(0)

分享創造快樂