使用 Redis 實現分散式系統輕量級協調技術

發表於2015-08-11

在分散式系統中,各個程式(本文使用程式來描述分散式系統中的執行主體,它們可以在同一個物理節點上也可以在不同的物理節點上)相互之間通常是需要協調進行運作的,有時是不同程式所處理的資料有依賴關係,必須按照一定的次序進行處理,有時是在一些特定的時間需要某個程式處理某些事務等等,人們通常會使用分散式鎖、選舉演算法等技術來協調各個程式之間的行為。因為分散式系統本身的複雜特性,以及對於容錯性的要求,這些技術通常是重量級的,比如 Paxos 演算法,欺負選舉演算法,ZooKeeper 等,側重於訊息的通訊而不是共享記憶體,通常也是出了名的複雜和難以理解,當在具體的實現和實施中遇到問題時都是一個挑戰。

Redis 經常被人們認為是一種 NoSQL 軟體,但其本質上是一種分散式的資料結構伺服器軟體,提供了一個分散式的基於記憶體的資料結構儲存服務。在實現上,僅使用一個執行緒來處理具體的記憶體資料結構,保證它的資料操作命令的原子特性;它同時還支援基於 Lua 的指令碼,每個 Redis 例項使用同一個 Lua 直譯器來解釋執行 Lua 指令碼,從而 Lua 指令碼也具備了原子特性,這種原子操作的特性使得基於共享記憶體模式的分散式系統的協調方式成了可能,而且具備了很大的吸引力,和複雜的基於訊息的機制不同,基於共享記憶體的模式對於很多技術人員來說明顯容易理解的多,特別是那些已經瞭解多執行緒或多程式技術的人。在具體實踐中,也並不是所有的分散式系統都像分散式資料庫系統那樣需要嚴格的模型的,而所使用的技術也不一定全部需要有堅實的理論基礎和數學證明,這就使得基於 Redis 來實現分散式系統的協調技術具備了一定的實用價值,實際上,人們也已經進行了不少嘗試。本文就其中的一些協調技術進行介紹。

 

signal/wait 操作

在分散式系統中,有些程式需要等待其它程式的狀態的改變,或者通知其它程式自己的狀態的改變,比如,程式之間有操作上的依賴次序時,就有程式需要等待,有程式需要發射訊號通知等待的程式進行後續的操作,這些工作可以通過 Redis 的 Pub/Sub 系列命令來完成,比如:

用這個方法很容易進行擴充套件實現其它的等待策略,比如 try wait,wait 超時,wait 多個訊號時是要等待全部訊號還是任意一個訊號到達即可返回等等。因為 Redis 本身支援基於模式匹配的訊息訂閱(使用 psubscribe 命令),設定 wait 訊號時也可以通過模式匹配的方式進行。

和其它的資料操作不同,訂閱訊息是即時易逝的,不在記憶體中儲存,不進行持久化儲存,如果客戶端到服務端的連線斷開的話也是不會重發的,但是在配置了 master/slave 節點的情況下,會把 publish 命令同步到 slave 節點上,這樣我們就可以同時在 master 以及 slave 節點的連線上訂閱某個頻道,從而可以同時接收到釋出者釋出的訊息,即使 master 在使用過程中出故障,或者到 master 的連線出了故障,我們仍然能夠從 slave 節點獲得訂閱的訊息,從而獲得更好的魯棒性。另外,因為資料不用寫入磁碟,這種方法在效能上也是有優勢的。

上面的方法中訊號是廣播的,所有在 wait 的程式都會收到訊號,如果要將訊號設定成單播,只允許其中一個收到訊號,則可以通過約定頻道名稱模式的方式來實現,比如:

頻道名稱 = 頻道名字首 (channel) + 訂閱者全域性唯一 ID(myid)

其中唯一 ID 可以是 UUID,也可以是一個隨機數字符串,確保全域性唯一即可。在傳送 signal 之前先使用“pubsub channels channel*”命令獲得所有的訂閱者訂閱的頻道,然後傳送訊號給其中一個隨機指定的頻道;等待的時候需要傳遞自己的唯一 ID,將頻道名字首和唯一 ID 合併為一個頻道名稱,然後同前面例子一樣進行 wait。示例如下:

 

分散式鎖 Distributed Locks

分散式鎖的實現是人們探索的比較多的一個方向,在 Redis 的官方網站上專門有一篇文件介紹基於 Redis 的分散式鎖,其中提出了 Redlock 演算法,並列出了多種語言的實現案例,這裡作一簡要介紹。

Redlock 演算法著眼於滿足分散式鎖的三個要素:

  • 安全性:保證互斥,任何時間至多隻有一個客戶端可以持有鎖
  • 免死鎖:即使當前持有鎖的客戶端崩潰或者從叢集中被分開了,其它客戶端最終總是能夠獲得鎖。
  • 容錯性:只要大部分的 Redis 節點線上,那麼客戶端就能夠獲取和釋放鎖。

鎖的一個簡單直接的實現方法就是用 SET NX 命令設定一個設定了存活週期 TTL 的 Key 來獲取鎖,通過刪除 Key 來釋放鎖,通過存活週期來保證避免死鎖。不過這個方法存在單點故障風險,如果部署了 master/slave 節點,則在特定條件下可能會導致安全性方面的衝突,比如:

  1. 客戶端 A 從 master 節點獲得鎖
  2. master 節點在將 key 複製到 slave 節點之前崩潰了
  3. slave 節點提升為新的 master 節點
  4. 客戶端 B 從新的 master 節點獲得了鎖,而這個鎖實際上已經由客戶端 A 所持有,導致了系統中有兩個客戶端在同一時間段內持有同一個互斥鎖,破壞了互斥鎖的安全性。

在 Redlock 演算法中,通過類似於下面這樣的命令進行加鎖:

這裡的 my_random_value 為全域性不同的隨機數,每個客戶端需要自己產生這個隨機數並且記住它,後面解鎖的時候需要用到它。
解鎖則需要通過一個 Lua 指令碼來執行,不能簡單地直接刪除 Key,否則可能會把別人持有的鎖給釋放了:

這個 ARGV[1] 的值就是前面加鎖的時候的 my_random_value 的值。

如果需要更好的容錯性,可以建立一個有 N(N 為奇數)個相互獨立完備的 Redis 冗餘節點的叢集,這種情況下,一個客戶端獲得鎖和釋放鎖的演算法如下:

  1. 先獲取當前時間戳 timestamp_1,以毫秒為單位。
  2. 以相同的 Key 和隨機數值,依次從 N 個節點獲取鎖,每次獲取鎖都設定一個超時,超時時限要保證小於所有節點上該鎖的自動釋放時間,以免在某個節點上耗時過長,通常都設的比較短。
  3. 客戶端將當前時間戳減去第一步中的時間戳 timestamp_1,計算獲取鎖總消耗時間。只有當客戶端獲得了半數以上節點的鎖,而且總耗時少於鎖存活時間,該客戶端才被認為已經成功獲得了鎖。
  4. 如果獲得了鎖,則其存活時間為開始預設鎖存活時間減去獲取鎖總耗時間。
  5. 如果客戶端不能獲得鎖,則應該馬上在所有節點上解鎖。
  6. 如果要重試,則在隨機延時之後重新去獲取鎖。
  7. 獲得了鎖的客戶端要釋放鎖,簡單地在所有節點上解鎖即可。

Redlock 演算法不需要保證 Redis 節點之間的時鐘是同步的(不論是物理時鐘還是邏輯時鐘),這點和傳統的一些基於同步時鐘的分散式鎖演算法有所不同。Redlock 演算法的具體的細節可以參閱 Redis 的官方文件,以及文件中列出的多種語言版本的實現。

 

選舉演算法

在分散式系統中,經常會有些事務是需要在某個時間段內由一個程式來完成,或者由一個程式作為 leader 來協調其它的程式,這個時候就需要用到選舉演算法,傳統的選舉演算法有欺負選舉演算法(霸道選舉演算法)、環選舉演算法、Paxos 演算法、Zab 演算法 (ZooKeeper) 等,這些演算法有些依賴於訊息的可靠傳遞以及時鐘同步,有些過於複雜,難以實現和驗證。新的 Raft 演算法相比較其它演算法來說已經容易了很多,不過它仍然需要依賴心跳廣播和邏輯時鐘,leader 需要不斷地向 follower 廣播訊息來維持從屬關係,節點擴充套件時也需要其它演算法配合。

選舉演算法和分散式鎖有點類似,任意時刻最多隻能有一個 leader 資源。當然,我們也可以用前面描述的分散式鎖來實現,設定一個 leader 資源,獲得這個資源鎖的為 leader,鎖的生命週期過了之後,再重新競爭這個資源鎖。這是一種競爭性的演算法,這個方法會導致有比較多的空檔期內沒有 leader 的情況,也不好實現 leader 的連任,而 leader 的連任是有比較大的好處的,比如 leader 執行任務可以比較準時一些,檢視日誌以及排查問題的時候也方便很多,如果我們需要一個演算法實現 leader 可以連任,那麼可以採用這樣的方法:

這個演算法鼓勵連任,只有當前的 leader 發生故障或者執行某個任務所耗時間超過了任期、或者 Redis 節點發生故障恢復之後才需要重新選舉出新的 leader。在 master/slave 模式下,如果 master 節點發生故障,某個 slave 節點提升為新的 master 節點,即使當時 master_selector 值尚未能同步成功,也不會導致出現兩個 leader 的情況。如果某個 leader 一直連任,則 master_selector 的值會一直遞增下去,考慮到 master_selector 是一個 64 位的整型型別,在可預見的時間內是不可能溢位的,加上每次進行 leader 更換的時候 master_selector 會重置為從 1 開始,這種遞增的方式是可以接受的,但是碰到 Redis 客戶端(比如 Node.js)不支援 64 位整型型別的時候就需要針對這種情況作處理。如果當前 leader 程式處理時間超過了任期,則其它程式可以重新生成新的 leader 程式,老的 leader 程式處理完畢事務後,如果新的 leader 的程式經歷的任期次數超過或等於老的 leader 程式的任期次數,則可能會出現兩個 leader 程式,為了避免這種情況,每個 leader 程式在處理完任期事務之後都應該檢查一下自己的處理時間是否超過了任期,如果超過了任期,則應當先設定 local_selector 為 0 之後再呼叫 master 檢查自己是否是 leader 程式。

 

訊息佇列

訊息佇列是分散式系統之間的通訊基本設施,通過訊息可以構造複雜的程式間的協調操作和互操作。Redis 也提供了構造訊息佇列的原語,比如 Pub/Sub 系列命令,就提供了基於訂閱/釋出模式的訊息收發方法,但是 Pub/Sub 訊息並不在 Redis 內保持,從而也就沒有進行持久化,適用於所傳輸的訊息即使丟失了也沒有關係的場景。

如果要考慮到持久化,則可以考慮 list 系列操作命令,用 PUSH 系列命令(LPUSH, RPUSH 等)推送訊息到某個 list,用 POP 系列命令(LPOP, RPOP,BLPOP,BRPOP 等)獲取某個 list 上的訊息,通過不同的組合方式可以得到 FIFO,FILO,比如:

如果用 BLPOP,BRPOP 命令替代 LPOP, RPOP,則在 list 為空的時候還支援阻塞等待。不過,即使按照這種方式實現了持久化,如果在 POP 訊息返回的時候網路故障,則依然會發生訊息丟失,針對這種需求 Redis 提供了 RPOPLPUSH 和 BRPOPLPUSH 命令來先將提取的訊息儲存在另外一個 list 中,客戶端可以先從這個 list 檢視和處理訊息資料,處理完畢之後再從這個 list 中刪除訊息資料,從而確保了訊息不會丟失,示例如下:

如果使用 BRPOPLPUSH 命令替代 RPOPLPUSH 命令,則可以在 q 為空的時候阻塞等待。

 

結語

使用 Redis 作為分散式系統的共享記憶體,以共享記憶體模式為基礎來實現分散式系統協調技術,雖然不像傳統的基於訊息傳遞的技術那樣有著堅實的理論證明的基礎,但是它在一些要求不苛刻的情況下不失為一種簡單實用的輕量級解決方案,畢竟不是每個系統都需要嚴格的容錯性等要求,也不是每個系統都會頻繁地發生程式異常,而且 Redis 本身已經經受了工業界的多年實踐和考驗。另外,用 Redis 技術還有一些額外的好處,比如在開發過程中和生產環境中都可以直接觀察到鎖、佇列的內容,實施的時候也不需要額外的特別配置過程等,它足夠簡單,在除錯問題的時候邏輯清晰,進行排查和臨時干預也比較方便。在可擴充套件性方面也比較好,可以動態擴充套件分散式系統的程式數目,而不需要事先預定好程式數目。

Redis 支援基於 Key 值 hash 的叢集,在叢集中應用本文所述技術時建議另外部署專用 Redis 節點(或者冗餘 Redis 節點叢集)來使用,因為在基於 Key 值 hash 的叢集中,不同的 Key 值會根據 hash 值被分佈到不同的叢集節點上,而且對於 Lua 指令碼的支援也受到限制,難以保證一些操作的原子性,這一點是需要考慮到的。使用專用節點還有一個好處是專用節點的資料量會少很多,當應用了 master/slave 部署或者 AOF 模式的時候,因為資料量少,master 和 slave 之間的同步會少很多,AOF 模式實時寫入磁碟的資料也少很多,這樣子也可以大大提高可用性。

本文示例所列 Python 程式碼在 Python3.4 下執行,Redis 客戶端採用 redis 2.10.3,Redis 服務端版本為 3.0.1 版。

相關文章