[譯] 我們是如何高效實現一致性雜湊的

yqian1991發表於2018-07-22

Ably 的實時平臺分佈在超過 14 個物理資料中心和 100 多個節點上。為了保證負載和資料都能夠均勻並且一致的分佈到所有的節點上,我們採用了一致性雜湊演算法。

在這篇文章中,我們將會理解一致性雜湊到底是怎麼回事,為什麼它是可伸縮的分散式系統架構中的一個重要工具。然後更進一步,我們會介紹可以用來高效率規模化實現一致性雜湊演算法的資料結構。最後,我們也會帶大家看一看用這個演算法實現的一個可工作例項。

再談雜湊

還記得大學裡學的那個古老而原始的雜湊方法嗎?通過使用雜湊函式,我們確保了計算機程式所需要的資源可以通過一種高效的方式儲存在記憶體中,也確保了記憶體資料結構能被均勻載入。我們也確保了這種資源儲存策略使資訊檢索變得更高效,從而讓程式執行得更快。

經典的雜湊方法用一個雜湊函式來生成一個偽隨機數,然後這個偽隨機數被記憶體空間大小整除,從而將一個隨機的數值標識轉換成可用記憶體空間裡的一個位置。就如同下面這個函式所示:

location = hash(key) mod size

[譯] 我們是如何高效實現一致性雜湊的

既然如此,我們為什麼不能用同樣的方法來處理網路請求呢?

在各種不同的程式、計算機或者使用者從多個伺服器請求資源的場景裡,我們需要一種機制來將請求均勻地分佈到可用的伺服器上,從而保證負載均衡,並且保持穩定一致的效能。我們可以將這些伺服器節點看做是一個或多個請求可以被對映到的位置。

現在讓我們先退一步。在傳統的雜湊方法中,我們總是假設:

  • 記憶體位置的數量是已知的,並且
  • 這個數量從不改變

例如,在 Ably,我們一整天裡通常需要擴大或者縮減叢集的大小,而且我們也要處理一些意外的故障。但是,如果我們考慮前面提到的這些場景的話,我們就不能保證伺服器數量是不變的。如果其中一個伺服器發生意外故障了怎麼辦?如果繼續使用最簡單的雜湊方法,結果就是我們需要對每個雜湊鍵重新計算雜湊值,因為新的對映完全決定於伺服器節點或者記憶體地址的數量,如下圖所示:

[譯] 我們是如何高效實現一致性雜湊的

節點變化之前

[譯] 我們是如何高效實現一致性雜湊的

節點變化之後

在分散式系統中使用簡單再雜湊存在的問題 — 每個雜湊鍵的存放位置都會變化 — 就是因為每個節點都存放了一個狀態;哪怕只是叢集數目的一個非常小的變化,都可能導致需要重新排列叢集上的所有資料,從而產生巨大的工作量。隨著叢集的增長,重新雜湊的方法是沒法持續使用的,因為重新雜湊所需要的工作量會隨著叢集的大小而線性地增長。這就是一致性雜湊的概念被引入的場景。

一致性雜湊 — 它到底是什麼?

一致性雜湊可以用下面的方式描述:

  • 它用虛擬環形的結構來表示資源請求者(為了敘述方便,後文將稱之為“請求”)和伺服器節點,這個環通常被稱作一個 hashring
  • 儲存位置的數量不再是確定的,但是我們認為這個環上有無窮多個點並且伺服器節點可以被放置到環上的任意位置。當然,我們仍然可以使用雜湊函式來選擇這個隨機數,但是之前的第二個步驟,也就是除以儲存位置數量的那一步被省略了,因為儲存位置的數量不再是一個有限的數值。
  • 請求,例如使用者,計算機或者無服務(serverless)程式,這些就等同於傳統雜湊方法中的鍵,也使用同樣的雜湊函式被放置到同樣的環上。

[譯] 我們是如何高效實現一致性雜湊的

那麼它到底是如何決定請求被哪個伺服器所服務呢?如果我們假設這個環是有序的,而且在環上進行順時針遍歷就對應著儲存地址的增長順序,每個請求可以被順時針遍歷過程中所遇到的第一個節點所服務;也就是說,第一個在環上的地址比請求的地址大的伺服器會服務這個請求。如果請求的地址比節點中的最大地址還大,那它會反過來被擁有最小地址的那個伺服器服務,因為在這個環上的遍歷是以迴圈的方式進行的。方法用下圖進行了闡明:

[譯] 我們是如何高效實現一致性雜湊的

理論上,每個伺服器‘擁有’雜湊環(hashring)上的一段區間範圍,任何對映到這個範圍裡的請求都將被同一個伺服器服務。現在好了,如果其中一個伺服器出現故障了怎麼辦,就以節點 3 為例吧,這個時候下一個伺服器節點在環上的地址範圍就會擴大,並且對映到這個範圍的任何請求會被分派給新的伺服器。僅此而已。只有對應到故障節點的區間範圍內的雜湊需要被重新分配,而雜湊環上其餘的部分和請求 - 伺服器的分配仍然不會受到影響。這跟傳統的雜湊技術正好是相反的,在傳統的雜湊中,雜湊表大小的變化會影響 全部 的對映。因為有了 一致性雜湊,只有一部分(這跟環的分佈因子有關)請求會受已知的雜湊環變化的影響。(節點增加或者刪除會導致環的變化,從而引起一些請求 - 伺服器之間的對映發生改變。)

[譯] 我們是如何高效實現一致性雜湊的

一種高效的實現方法

現在我們對什麼是雜湊環已經熟悉了...

我們需要實現以下內容來讓它工作:

  1. 一個從雜湊空間到叢集上所有伺服器節點之間的對映,讓我們能找到可以服務指定請求的節點。
  2. 一個叢集上每個節點所服務的請求的集合。在後面,這個集合可以讓我們找到哪些雜湊因為節點的增加或者刪除而受到了影響。

對映

要完成上述的第一個部分,我們需要以下內容:

  • 一個雜湊函式,用來計算已知請求的標識(ID)在環上對應的位置。
  • 一種方法,用來尋找轉換為雜湊值的請求標識所對應的節點。

為了找到與特定請求相對應的節點,我們可以用一種簡單的資料結構來闡釋,它由以下內容組成:

  • 一個與環上的節點一一對應的雜湊陣列。
  • 一張圖(雜湊表),用來尋找與已知請求相對應的伺服器節點。

這實際上就是一個有序圖的原始表示。

為了能在以上資料結構中找到可以服務於已知雜湊值的節點,我們需要:

  • 執行修改過的二分搜尋,在陣列中查詢到第一個等於或者大於(≥)你要查詢的雜湊值所對應的節點 — 雜湊對映。
  • 查詢在圖中發現的節點 — 雜湊對映所對應的那個節點。

節點的增加或者刪除

在這篇文章的開頭我們已經看到了,當一個節點被新增,雜湊環上的一部分割槽間範圍,以及它所包括的各種請求,都必須被分配到這個新節點。反過來,當一個節點被刪除,過去被分配到這個節點的請求都將需要被其他節點處理。

如何尋找到被雜湊環的改變所影響的那些請求?

一種解決方法就是遍歷分配到一個節點的所有請求。對每個請求,我們判斷它是否處在環發生變化的區間範圍內,如果有需要的話,把它轉移到其他地方。

然而,這麼做所需要的工作量會隨著節點上請求數量的增加而增加。讓情況變得更糟糕的是,隨著節點數量的增加,環上發生變化的數量也可能會增加。最壞的情況是,由於環的變化通常與區域性故障有關,與環變化相關聯的瞬時負載也可能增加其他受影響節點發生故障的可能性,有可能導致整個系統發生級聯故障。

考慮到這個因素,我們希望請求的重定位做到儘可能高效。最理想的情況是,我們可以將所有請求儲存在一種資料結構裡,這樣我們能找到環上任何地方發生雜湊變化時受到影響的請求。

高效查詢受影響的雜湊值

在叢集上增加或者刪除一個節點將改變環上一部分請求的分配,我們稱之為 受影響範圍affected range)。如果我們知道受影響範圍的邊界,我們就可以把請求轉移到正確的位置。

為了尋找受影響範圍的邊界,我們從增加或者刪除掉的一個節點的雜湊值 H 開始,從 H 開始繞著環向後移動(圖中的逆時針方向),直到找到另外一個節點。讓我們將這個節點的雜湊值定義為 S(作為開始)。從這個節點開始逆時針方向上的請求會被指定給它(S),因此它們不會受到影響。

注意:這只是實際將發生的情況的一個簡化描述;在實踐中,資料結構和演算法都更加複雜,因為我們使用的複製因子(replication factors)數目大於 1,並且當任意給定的請求都只有一部分節點可用的情況下,我們還會使用專門的複製策略。

那些雜湊值在被找到的節點和增加(或者刪除)的節點範圍之間的請求就是需要被移動的。

高效查詢受影響範圍內的請求

一種解決方法就是簡單的遍歷對應於一個節點的所有請求,並且更新那些雜湊值對映到此範圍內的請求。

在 JavaScript 中類似這樣:

for (const request of requests) {
  if (contains(S, H, request.hash)) {
    /* 這個請求受環變化的影響 */
    request.relocate();
  }
}
function contains(lowerBound, upperBound, hash) {
   const wrapsOver = upperBound < lowerBound;
   const aboveLower = hash >= lowerBound;
   const belowUpper = upperBound >= hash;
   if (wrapsOver) {
     return aboveLower || belowUpper;
   } else {
     return aboveLower && belowUpper;
   }
}
複製程式碼

由於雜湊環是環狀的,僅僅查詢 S <= r < H 之間的請求是不夠的,因為 S 可能比 H 大(表明這個區間範圍包含了雜湊環的最頂端的開始部分)。函式 contains() 可以處理這種情況。

只要請求數量相對較少,或者節點的增加或者刪除的情況也相對較少出現,遍歷一個給定節點的所有請求還是可行的。

然而,隨著節點上的請求數量的增加,所需的工作量也隨之增加,更糟糕的是,隨著節點的增加,環變化也可能發生得更頻繁,無論是因為在自動節點伸縮(automated scaling)或者是故障轉換(failover)的情況下為了重新均衡訪問請求而觸發的整個系統上的併發負載。

最糟的情況是,與這些變化相關的負載可能增加其它節點發生故障的可能性,有可能導致整個系統範圍的級聯故障。

為了減輕這種影響,我們也可以將請求儲存到類似於之前討論過的一個單獨的環狀資料結構中,在這個環裡,一個雜湊值直接對映到這個雜湊對應的請求。

這樣我們就能通過以下步驟來定位受影響範圍內的所有請求:

  • 定位從 S 開始的第一個請求。
  • 順時針遍歷直到你找到了這個範圍以外的一個雜湊值。
  • 重新定位落在這個範圍之內的請求。

當一個雜湊更新時所需要遍歷的請求數量平均是 R/N,R 是定位到這個節點範圍內的請求數量,N 是環上雜湊值的數量,這裡我們假設請求是均勻分佈的。


讓我們通過一個可工作的例子將以上解釋付諸實踐:

假設我們有一個包含節點 A 和 B 的叢集。

讓我們隨機的產生每個節點的 ‘雜湊分配’:(假設是32位的雜湊),因此我們得到了

A:0x5e6058e5

B:0xa2d65c0

在此我們將節點放到一個虛擬的環上,數值 0x00x10x2... 是被連續放置到環上的直到 0xffffffff,就這樣在環上繞一個圈後 0xffffffff 的後面正好跟著的就是 0x0

由於節點 A 的雜湊是 0x5e6058e5,它負責的就是從 0xa2d65c0+10xffffffff,以及從 0x00x5e6058e5 範圍裡的任何請求,如下圖所示:

[譯] 我們是如何高效實現一致性雜湊的

另一方面,B 負責的是從 0x5e6058e5+10xa2d65c0 的範圍。如此,整個雜湊空間都被劃分了。

從節點到它們的雜湊之間的對映在整個叢集上是共享的,這樣保證了每次環計算的結果總是一致的。因此,任何節點在需要服務請求的時候都可以判斷請求放在哪裡。

比如我們需要尋找 (或者建立)一個新的請求,這個請求的識別符號是 ‘bobs.blog@example.com’。

  1. 我們計算這個標識的雜湊 H ,比如得到的是 0x89e04a0a
  2. 我們在環上尋找擁有比 H 大的雜湊值的第一個節點。這裡我們找到了 B。

因此 B 是負責這個請求的節點。如果我們再次需要這個請求,我們將重複以上步驟並且又會得到同樣的節點,它會包含我們需要的的狀態。

這個例子是過於簡單了。在實際情況中,只給每個節點一個雜湊可能導致負載非常不均勻的分佈。你可能已經注意到了,在這個例子中,B 負責環的 (0xa2d656c0-0x5e6058e5)/232 = 26.7%,同時 A 負責剩下的部分。理想的情況是,每個節點可以負責環上同等大小的一部分。

讓分佈更均衡合理的一種方法是為每個節點產生多個隨機雜湊,像下面這樣:

[譯] 我們是如何高效實現一致性雜湊的

事實上,我們發現這樣做的結果照樣令人不滿意,因此我們將環分成 64 個同樣大小的片段並且確保每個節點都會被放到每個片段中的某個位置;這個的細節就不是那麼重要了。反正目的就是確保每個節點能負責環上同等大小的一部分,因此保證負載是均勻分佈的。(為每個節點產生多個雜湊的另一個優勢就是雜湊可以在環上逐漸的被增加或者刪除,這樣就避免了負載的突然間的變化。)

假設我們現在在環上增加一個新節點叫做 C,我們為 C 產生一個隨機雜湊值。

A:0x5e6058e5

B:0xa2d65c0

C:0xe12f751c

現在,0xa2d65c0 + 10xe12f751c (以前是屬於A的部分)之間的環空間被分配給了 C。所有其他的請求像以前一樣繼續被雜湊到同樣的節點。為了處理節點職責的變化,這個範圍內的已經分配給 A 的所有請求需要將它們的所有狀態轉移給 C。

[譯] 我們是如何高效實現一致性雜湊的

現在你理解了為什麼在分散式系統中均衡負載是需要雜湊的。然而我們需要一致性雜湊來確保在環發生任何變化的時候最小化叢集上所需要的工作量。

另外,節點需要存在於環上的多個地方,這樣可以從統計學的角度保證負載被均勻分佈。每次環發生變化都遍歷整個雜湊環的效率是不高的,隨著你的分散式系統的伸縮,有一種更高效的方法來決定什麼發生了變化是很必要的,它能幫助你儘可能的最小化環變化帶來的效能上的影響。我們需要新的索引和資料型別來解決這個問題。


構建分散式系統是很難的事情。但是我們熱愛它並且我們喜歡談論它。如果你需要依靠一種分散式系統的話,選擇 Ably。如果你想跟我們談一談的話,聯絡我們!

在此特別感謝 Ably 的分散式系統工程師 John Diamond 對本文的貢獻。


[譯] 我們是如何高效實現一致性雜湊的

Srushtika 是 Ably Realtime的軟體開發顧問

[譯] 我們是如何高效實現一致性雜湊的

感謝 John DiamondMatthew O'Riordan

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章