一致性 Hash 算法的實際應用

前言

記得一年前分享過一篇《一致性 Hash 算法分析》,當時只是分析了這個算法的實現原理、解決了什麼問題等。java

但沒有實際實現一個這樣的算法,畢竟要加深印象還得本身擼一遍,因而本次就當前的一個路由需求來着手實現一次。git

背景

看過《爲本身搭建一個分佈式 IM(即時通信) 系統》的朋友應該對其中的登陸邏輯有所印象。github

先給新來的朋友簡單介紹下 cim 是幹啥的:

其中有一個場景是在客戶端登陸成功後須要從可用的服務端列表中選擇一臺服務節點返回給客戶端使用。面試

而這個選擇的過程就是一個負載策略的過程;初版本作的比較簡單,默認只支持輪詢的方式。算法

雖然夠用,但不夠優雅😏。設計模式

所以個人規劃是內置多種路由策略供使用者根據本身的場景選擇,同時提供簡單的 API 供用戶自定義本身的路由策略。數組

先來看看一致性 Hash 算法的一些特色:服務器

  • 構造一個 0 ~ 2^32-1 大小的環。
  • 服務節點通過 hash 以後將自身存放到環中的下標中。
  • 客戶端根據自身的某些數據 hash 以後也定位到這個環中。
  • 經過順時針找到離他最近的一個節點,也就是此次路由的服務節點。
  • 考慮到服務節點的個數以及 hash 算法的問題致使環中的數據分佈不均勻時引入了虛擬節點。

自定義有序 Map

根據這些客觀條件咱們很容易想到經過自定義一個有序數組來模擬這個環。數據結構

這樣咱們的流程以下:分佈式

  1. 初始化一個長度爲 N 的數組。
  2. 將服務節點經過 hash 算法獲得的正整數,同時將節點自身的數據(hashcode、ip、端口等)存放在這裏。
  3. 完成節點存放後將整個數組進行排序(排序算法有多種)。
  4. 客戶端獲取路由節點時,將自身進行 hash 也獲得一個正整數;
  5. 遍歷這個數組直到找到一個數據大於等於當前客戶端的 hash 值,就將當前節點做爲該客戶端所路由的節點。
  6. 若是沒有發現比客戶端大的數據就返回第一個節點(知足環的特性)。

先不考慮排序所消耗的時間,單看這個路由的時間複雜度:

  • 最好是第一次就找到,時間複雜度爲O(1)
  • 最差爲遍歷完數組後才找到,時間複雜度爲O(N)

理論講完了來看看具體實踐。

我自定義了一個類:SortArrayMap

他的使用方法及結果以下:

可見最終會按照 key 的大小進行排序,同時傳入 hashcode = 101 時會按照順時針找到 hashcode = 1000 這個節點進行返回。


下面來看看具體的實現。

成員變量和構造函數以下:

其中最核心的就是一個 Node 數組,用它來存放服務節點的 hashcode 以及 value 值。

其中的內部類 Node 結構以下:


寫入數據的方法以下:

相信看過 ArrayList 的源碼應該有印象,這裏的寫入邏輯和它很像。

  • 寫入以前判斷是否須要擴容,若是須要則複製原來大小的 1.5 倍數組來存放數據。
  • 以後就寫入數組,同時數組大小 +1。

可是存放時是按照寫入順序存放的,遍歷時天然不會有序;所以提供了一個 Sort 方法,能夠把其中的數據按照 key 其實也就是 hashcode 進行排序。

排序也比較簡單,使用了 Arrays 這個數組工具進行排序,它實際上是使用了一個 TimSort 的排序算法,效率仍是比較高的。

最後則須要按照一致性 Hash 的標準順時針查找對應的節點:

代碼仍是比較簡單清晰的;遍歷數組若是找到比當前 key 大的就返回,沒有查到就取第一個。

這樣就基本實現了一致性 Hash 的要求。

ps:這裏並不包含具體的 hash 方法以及虛擬節點等功能(具體實現請看下文),這個能夠由使用者來定,SortArrayMap 可做爲一個底層的數據結構,提供有序 Map 的能力,使用場景也不侷限於一致性 Hash 算法中。

TreeMap 實現

SortArrayMap 雖然說是實現了一致性 hash 的功能,但效率還不夠高,主要體如今 sort 排序處。

下圖是目前主流排序算法的時間複雜度:

最好的也就是 O(N) 了。

這裏徹底能夠換一個思路,不用對數據進行排序;而是在寫入的時候就排好順序,只是這樣會下降寫入的效率。

好比二叉查找樹,這樣的數據結構 jdk 裏有現成的實現;好比 TreeMap 就是使用紅黑樹來實現的,默認狀況下它會對 key 進行天然排序。


來看看使用 TreeMap 如何來達到一樣的效果。

運行結果:

127.0.0.1000

效果和上文使用 SortArrayMap 是一致的。

只使用了 TreeMap 的一些 API:

  • 寫入數據候,TreeMap 能夠保證 key 的天然排序。
  • tailMap 能夠獲取比當前 key 大的部分數據。
  • 當這個方法有數據返回時取第一個就是順時針中的第一個節點了。
  • 若是沒有返回那就直接取整個 Map 的第一個節點,一樣也實現了環形結構。
ps:這裏一樣也沒有 hash 方法以及虛擬節點(具體實現請看下文),由於 TreeMap 和 SortArrayMap 同樣都是做爲基礎數據結構來使用的。

性能對比

爲了方便你們選擇哪個數據結構,我用 TreeMapSortArrayMap 分別寫入了一百萬條數據來對比。

先是 SortArrayMap

耗時 2237 毫秒。

TreeMap:

耗時 1316毫秒。

結果是快了將近一倍,因此仍是推薦使用 TreeMap 來進行實現,畢竟它不須要額外的排序損耗。

cim 中的實際應用

下面來看看在 cim 這個應用中是如何具體使用的,其中也包括上文提到的虛擬節點以及 hash 算法。

模板方法

在應用的時候考慮到就算是一致性 hash 算法都有多種實現,爲了方便其使用者擴展本身的一致性 hash 算法所以我定義了一個抽象類;其中定義了一些模板方法,這樣你們只須要在子類中進行不一樣的實現便可完成本身的算法。

AbstractConsistentHash,這個抽象類的主要方法以下:

  • add 方法天然是寫入數據的。
  • sort 方法用於排序,但子類也不必定須要重寫,好比 TreeMap 這樣自帶排序的容器就不用。
  • getFirstNodeValue 獲取節點。
  • process 則是面向客戶端的,最終只須要調用這個方法便可返回一個節點。

下面咱們來看看利用 SortArrayMap 以及 AbstractConsistentHash 是如何實現的。

就是實現了幾個抽象方法,邏輯和上文是同樣的,只是抽取到了不一樣的方法中。

只是在 add 方法中新增了幾個虛擬節點,相信你們也看得明白。

把虛擬節點的控制放到子類而沒有放到抽象類中也是爲了靈活性考慮,可能不一樣的實現對虛擬節點的數量要求也不同,因此不如自定義的好。

可是 hash 方法確是放到了抽象類中,子類不用重寫;由於這是一個基本功能,只須要有一個公共算法能夠保證他散列地足夠均勻便可。

所以在 AbstractConsistentHash 中定義了 hash 方法。

這裏的算法摘抄自 xxl_job,網上也有其餘不一樣的實現,好比 FNV1_32_HASH 等;實現不一樣可是目的都同樣。

這樣對於使用者來講就很是簡單了:

他只須要構建一個服務列表,而後把當前的客戶端信息傳入 process 方法中便可得到一個一致性 hash 算法的返回。


一樣的對於想經過 TreeMap 來實現也是同樣的套路:

他這裏不須要重寫 sort 方法,由於自身寫入時已經排好序了。

而在使用時對於客戶端來講只需求修改一個實現類,其餘的啥都不用改就能夠了。

運行的效果也是同樣的。

這樣你們想自定義本身的算法時只須要繼承 AbstractConsistentHash 重寫相關方法便可,客戶端代碼無須改動。

路由算法擴展性

但其實對於 cim 來講真正的擴展性是對路由算法來講的,好比它須要支持輪詢、hash、一致性hash、隨機、LRU等。

只是一致性 hash 也有多種實現,他們的關係就以下圖:

應用還須要知足對這一類路由策略的靈活支持,好比我也想自定義一個隨機的策略。

所以定義了一個接口:RouteHandle

public interface RouteHandle {

    /**
     * 再一批服務器裏進行路由
     * @param values
     * @param key
     * @return
     */
    String routeServer(List<String> values,String key) ;
}

其中只有一個方法,也就是路由方法;入參分別是服務列表以及客戶端信息便可。

而對於一致性 hash 算法來講也是隻須要實現這個接口,同時在這個接口中選擇使用 SortArrayMapConsistentHash 仍是 TreeMapConsistentHash 便可。

這裏還有一個 setHash 的方法,入參是 AbstractConsistentHash;這就是用於客戶端指定須要使用具體的那種數據結構。


而對於以前就存在的輪詢策略來講也是一樣的實現 RouteHandle 接口。

這裏我只是把以前的代碼搬過來了而已。

接下來看看客戶端究竟是如何使用以及如何選擇使用哪一種算法。

爲了使客戶端代碼幾乎不動,我將這個選擇的過程放入了配置文件。

  1. 若是想使用原有的輪詢策略,就配置實現了 RouteHandle 接口的輪詢策略的全限定名。
  2. 若是想使用一致性 hash 的策略,也只須要配置實現了 RouteHandle 接口的一致性 hash 算法的全限定名。
  3. 固然目前的一致性 hash 也有多種實現,因此一旦配置爲一致性 hash 後就須要再加一個配置用於決定使用 SortArrayMapConsistentHash 仍是 TreeMapConsistentHash 或是自定義的其餘方案。
  4. 一樣的也是須要配置繼承了 AbstractConsistentHash 的全限定名。

無論這裏的策略如何改變,在使用處依然保持不變。

只須要注入 RouteHandle,調用它的 routeServer 方法。

@Autowired
private RouteHandle routeHandle ;
String server = routeHandle.routeServer(serverCache.getAll(),String.valueOf(loginReqVO.getUserId()));

既然使用了注入,那其實這個策略切換的過程就在建立 RouteHandle bean 的時候完成的。

也比較簡單,須要讀取以前的配置文件來動態生成具體的實現類,主要是利用反射完成的。

這樣處理以後就比較靈活了,好比想新建一個隨機的路由策略也是一樣的套路;到時候只須要修改配置便可。

感興趣的朋友也可提交 PR 來新增更多的路由策略。

總結

但願看到這裏的朋友能對這個算法有所理解,同時對一些設計模式在實際的使用也能有所幫助。

相信在金三銀四的面試過程當中仍是能讓面試官眼前一亮的,畢竟根據我這段時間的面試過程來看聽過這個名詞的都在少數😂(可能也是和候選人都在 1~3 年這個層級有關)。

以上全部源碼:

https://github.com/crossoverJie/cim

若是本文對你有所幫助還請不吝轉發。

相關文章
相關標籤/搜索