- 原文地址:How we implemented consistent hashing efficiently
- 原文做者:Srushtika Neelakantam
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:yqian1991
- 校對者:Starrier
在這篇文章中,咱們將會理解一致性哈希究竟是怎麼回事,爲何它是可伸縮的分佈式系統架構中的一個重要工具。而後更進一步,咱們會介紹能夠用來高效率規模化實現一致性哈希算法的數據結構。最後,咱們也會帶你們看一看用這個算法實現的一個可工做實例。前端
還記得大學裏學的那個古老而原始的哈希方法嗎?經過使用哈希函數,咱們確保了計算機程序所須要的資源能夠經過一種高效的方式存儲在內存中,也確保了內存數據結構能被均勻加載。咱們也確保了這種資源存儲策略使信息檢索變得更高效,從而讓程序運行得更快。android
經典的哈希方法用一個哈希函數來生成一個僞隨機數,而後這個僞隨機數被內存空間大小整除,從而將一個隨機的數值標識轉換成可用內存空間裏的一個位置。就如同下面這個函數所示:ios
location = hash(key) mod size
git
在各類不一樣的程序、計算機或者用戶從多個服務器請求資源的場景裏,咱們須要一種機制來將請求均勻地分佈到可用的服務器上,從而保證負載均衡,而且保持穩定一致的性能。咱們能夠將這些服務器節點看作是一個或多個請求能夠被映射到的位置。github
如今讓咱們先退一步。在傳統的哈希方法中,咱們老是假設:算法
例如,在 Ably,咱們一成天裏一般須要擴大或者縮減集羣的大小,並且咱們也要處理一些意外的故障。可是,若是咱們考慮前面提到的這些場景的話,咱們就不能保證服務器數量是不變的。若是其中一個服務器發生意外故障了怎麼辦?若是繼續使用最簡單的哈希方法,結果就是咱們須要對每一個哈希鍵從新計算哈希值,由於新的映射徹底決定於服務器節點或者內存地址的數量,以下圖所示:後端
節點變化以前數組
節點變化以後bash
在分佈式系統中使用簡單再哈希存在的問題 — 每一個哈希鍵的存放位置都會變化 — 就是由於每一個節點都存放了一個狀態;哪怕只是集羣數目的一個很是小的變化,均可能致使須要從新排列集羣上的全部數據,從而產生巨大的工做量。隨着集羣的增加,從新哈希的方法是無法持續使用的,由於從新哈希所須要的工做量會隨着集羣的大小而線性地增加。這就是一致性哈希的概念被引入的場景。服務器
一致性哈希能夠用下面的方式描述:
那麼它究竟是如何決定請求被哪一個服務器所服務呢?若是咱們假設這個環是有序的,並且在環上進行順時針遍歷就對應着存儲地址的增加順序,每一個請求能夠被順時針遍歷過程當中所遇到的第一個節點所服務;也就是說,第一個在環上的地址比請求的地址大的服務器會服務這個請求。若是請求的地址比節點中的最大地址還大,那它會反過來被擁有最小地址的那個服務器服務,由於在這個環上的遍歷是以循環的方式進行的。方法用下圖進行了闡明:
理論上,每一個服務器‘擁有’哈希環(hashring)上的一段區間範圍,任何映射到這個範圍裏的請求都將被同一個服務器服務。如今好了,若是其中一個服務器出現故障了怎麼辦,就以節點 3 爲例吧,這個時候下一個服務器節點在環上的地址範圍就會擴大,而且映射到這個範圍的任何請求會被分派給新的服務器。僅此而已。只有對應到故障節點的區間範圍內的哈希須要被從新分配,而哈希環上其他的部分和請求 - 服務器的分配仍然不會受到影響。這跟傳統的哈希技術正好是相反的,在傳統的哈希中,哈希表大小的變化會影響 所有 的映射。由於有了 一致性哈希,只有一部分(這跟環的分佈因子有關)請求會受已知的哈希環變化的影響。(節點增長或者刪除會致使環的變化,從而引發一些請求 - 服務器之間的映射發生改變。)
如今咱們對什麼是哈希環已經熟悉了...
咱們須要實現如下內容來讓它工做:
要完成上述的第一個部分,咱們須要如下內容:
爲了找到與特定請求相對應的節點,咱們能夠用一種簡單的數據結構來闡釋,它由如下內容組成:
這實際上就是一個有序圖的原始表示。
爲了能在以上數據結構中找到能夠服務於已知哈希值的節點,咱們須要:
在這篇文章的開頭咱們已經看到了,當一個節點被添加,哈希環上的一部分區間範圍,以及它所包括的各類請求,都必須被分配到這個新節點。反過來,當一個節點被刪除,過去被分配到這個節點的請求都將須要被其餘節點處理。
一種解決方法就是遍歷分配到一個節點的全部請求。對每一個請求,咱們判斷它是否處在環發生變化的區間範圍內,若是有須要的話,把它轉移到其餘地方。
然而,這麼作所須要的工做量會隨着節點上請求數量的增長而增長。讓狀況變得更糟糕的是,隨着節點數量的增長,環上發生變化的數量也可能會增長。最壞的狀況是,因爲環的變化一般與局部故障有關,與環變化相關聯的瞬時負載也可能增長其餘受影響節點發生故障的可能性,有可能致使整個系統發生級聯故障。
考慮到這個因素,咱們但願請求的重定位作到儘量高效。最理想的狀況是,咱們能夠將全部請求保存在一種數據結構裏,這樣咱們能找到環上任何地方發生哈希變化時受到影響的請求。
在集羣上增長或者刪除一個節點將改變環上一部分請求的分配,咱們稱之爲 受影響範圍(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)的狀況下爲了從新均衡訪問請求而觸發的整個系統上的併發負載。
最糟的狀況是,與這些變化相關的負載可能增長其它節點發生故障的可能性,有可能致使整個系統範圍的級聯故障。
爲了減輕這種影響,咱們也能夠將請求存儲到相似於以前討論過的一個單獨的環狀數據結構中,在這個環裏,一個哈希值直接映射到這個哈希對應的請求。
這樣咱們就能經過如下步驟來定位受影響範圍內的全部請求:
當一個哈希更新時所須要遍歷的請求數量平均是 R/N,R 是定位到這個節點範圍內的請求數量,N 是環上哈希值的數量,這裏咱們假設請求是均勻分佈的。
讓咱們經過一個可工做的例子將以上解釋付諸實踐:
假設咱們有一個包含節點 A 和 B 的集羣。
讓咱們隨機的產生每一個節點的 ‘哈希分配’:(假設是32位的哈希),所以咱們獲得了
A:0x5e6058e5
B:0xa2d65c0
在此咱們將節點放到一個虛擬的環上,數值 0x0
、0x1
和 0x2
... 是被連續放置到環上的直到 0xffffffff
,就這樣在環上繞一個圈後 0xffffffff
的後面正好跟着的就是 0x0
。
因爲節點 A 的哈希是 0x5e6058e5
,它負責的就是從 0xa2d65c0+1
到 0xffffffff
,以及從 0x0
到 0x5e6058e5
範圍裏的任何請求,以下圖所示:
另外一方面,B 負責的是從 0x5e6058e5+1
到 0xa2d65c0
的範圍。如此,整個哈希空間都被劃分了。
從節點到它們的哈希之間的映射在整個集羣上是共享的,這樣保證了每次環計算的結果老是一致的。所以,任何節點在須要服務請求的時候均可以判斷請求放在哪裏。
好比咱們須要尋找 (或者建立)一個新的請求,這個請求的標識符是 ‘bobs.blog@example.com’。
0x89e04a0a
所以 B 是負責這個請求的節點。若是咱們再次須要這個請求,咱們將重複以上步驟而且又會獲得一樣的節點,它會包含咱們須要的的狀態。
這個例子是過於簡單了。在實際狀況中,只給每一個節點一個哈希可能致使負載很是不均勻的分佈。你可能已經注意到了,在這個例子中,B 負責環的 (0xa2d656c0-0x5e6058e5)/232 = 26.7%
,同時 A 負責剩下的部分。理想的狀況是,每一個節點能夠負責環上同等大小的一部分。
讓分佈更均衡合理的一種方法是爲每一個節點產生多個隨機哈希,像下面這樣:
事實上,咱們發現這樣作的結果照樣使人不滿意,所以咱們將環分紅 64 個一樣大小的片斷而且確保每一個節點都會被放到每一個片斷中的某個位置;這個的細節就不是那麼重要了。反正目的就是確保每一個節點能負責環上同等大小的一部分,所以保證負載是均勻分佈的。(爲每一個節點產生多個哈希的另外一個優點就是哈希能夠在環上逐漸的被增長或者刪除,這樣就避免了負載的忽然間的變化。)
假設咱們如今在環上增長一個新節點叫作 C,咱們爲 C 產生一個隨機哈希值。
A:0x5e6058e5
B:0xa2d65c0
C:0xe12f751c
如今,0xa2d65c0 + 1
和 0xe12f751c
(之前是屬於A的部分)之間的環空間被分配給了 C。全部其餘的請求像之前同樣繼續被哈希到一樣的節點。爲了處理節點職責的變化,這個範圍內的已經分配給 A 的全部請求須要將它們的全部狀態轉移給 C。
如今你理解了爲何在分佈式系統中均衡負載是須要哈希的。然而咱們須要一致性哈希來確保在環發生任何變化的時候最小化集羣上所須要的工做量。
另外,節點須要存在於環上的多個地方,這樣能夠從統計學的角度保證負載被均勻分佈。每次環發生變化都遍歷整個哈希環的效率是不高的,隨着你的分佈式系統的伸縮,有一種更高效的方法來決定什麼發生了變化是很必要的,它能幫助你儘量的最小化環變化帶來的性能上的影響。咱們須要新的索引和數據類型來解決這個問題。
構建分佈式系統是很難的事情。可是咱們熱愛它而且咱們喜歡談論它。若是你須要依靠一種分佈式系統的話,選擇 Ably。若是你想跟咱們談一談的話,聯繫咱們!
在此特別感謝 Ably 的分佈式系統工程師 John Diamond 對本文的貢獻。
Srushtika 是 Ably Realtime的軟件開發顧問
感謝 John Diamond 和 Matthew O'Riordan。
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。