前言
記得一年前分享過一篇《一致性 Hash 演算法分析》,當時只是分析了這個演算法的實現原理、解決了什麼問題等。
但沒有實際實現一個這樣的演算法,畢竟要加深印象還得自己擼一遍,於是本次就當前的一個路由需求來著手實現一次。
背景
看過《為自己搭建一個分散式 IM(即時通訊) 系統》的朋友應該對其中的登入邏輯有所印象。
先給新來的朋友簡單介紹下 cim 是幹啥的:
其中有一個場景是在客戶端登入成功後需要從可用的服務端列表中選擇一臺服務節點返回給客戶端使用。
而這個選擇的過程就是一個負載策略的過程;第一版本做的比較簡單,預設只支援輪詢的方式。
雖然夠用,但不夠優雅?。
因此我的規劃是內建多種路由策略供使用者根據自己的場景選擇,同時提供簡單的 API 供使用者自定義自己的路由策略。
先來看看一致性 Hash 演算法的一些特點:
- 構造一個
0 ~ 2^32-1
大小的環。 - 服務節點經過 hash 之後將自身存放到環中的下標中。
- 客戶端根據自身的某些資料 hash 之後也定位到這個環中。
- 通過順時針找到離他最近的一個節點,也就是這次路由的服務節點。
- 考慮到服務節點的個數以及 hash 演算法的問題導致環中的資料分佈不均勻時引入了虛擬節點。
自定義有序 Map
根據這些客觀條件我們很容易想到通過自定義一個有序陣列來模擬這個環。
這樣我們的流程如下:
- 初始化一個長度為 N 的陣列。
- 將服務節點通過 hash 演算法得到的正整數,同時將節點自身的資料(hashcode、ip、埠等)存放在這裡。
- 完成節點存放後將整個陣列進行排序(排序演算法有多種)。
- 客戶端獲取路由節點時,將自身進行 hash 也得到一個正整數;
- 遍歷這個陣列直到找到一個資料大於等於當前客戶端的 hash 值,就將當前節點作為該客戶端所路由的節點。
- 如果沒有發現比客戶端大的資料就返回第一個節點(滿足環的特性)。
先不考慮排序所消耗的時間,單看這個路由的時間複雜度:
- 最好是第一次就找到,時間複雜度為
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 一樣都是作為基礎資料結構來使用的。
效能對比
為了方便大家選擇哪一個資料結構,我用 TreeMap
和 SortArrayMap
分別寫入了一百萬條資料來對比。
先是 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
介面。
這裡我只是把之前的程式碼搬過來了而已。
接下來看看客戶端到底是如何使用以及如何選擇使用哪種演算法。
為了使客戶端程式碼幾乎不動,我將這個選擇的過程放入了配置檔案。
- 如果想使用原有的輪詢策略,就配置實現了
RouteHandle
介面的輪詢策略的全限定名。 - 如果想使用一致性 hash 的策略,也只需要配置實現了
RouteHandle
介面的一致性 hash 演算法的全限定名。 - 當然目前的一致性 hash 也有多種實現,所以一旦配置為一致性 hash 後就需要再加一個配置用於決定使用
SortArrayMapConsistentHash
還是TreeMapConsistentHash
或是自定義的其他方案。 - 同樣的也是需要配置繼承了
AbstractConsistentHash
的全限定名。
不管這裡的策略如何改變,在使用處依然保持不變。
只需要注入 RouteHandle
,呼叫它的 routeServer
方法。
@Autowired
private RouteHandle routeHandle ;
String server = routeHandle.routeServer(serverCache.getAll(),String.valueOf(loginReqVO.getUserId()));
複製程式碼
既然使用了注入,那其實這個策略切換的過程就在建立 RouteHandle bean
的時候完成的。
也比較簡單,需要讀取之前的配置檔案來動態生成具體的實現類,主要是利用反射完成的。
這樣處理之後就比較靈活了,比如想新建一個隨機的路由策略也是同樣的套路;到時候只需要修改配置即可。
感興趣的朋友也可提交 PR 來新增更多的路由策略。
總結
希望看到這裡的朋友能對這個演算法有所理解,同時對一些設計模式在實際的使用也能有所幫助。
相信在金三銀四的面試過程中還是能讓面試官眼前一亮的,畢竟根據我這段時間的面試過程來看聽過這個名詞的都在少數?(可能也是和候選人都在 1~3 年這個層級有關)。
以上所有原始碼:
如果本文對你有所幫助還請不吝轉發。