一. 簡述一致性雜湊演算法
- 這裡不詳細介紹一致性雜湊演算法的起源了, 網上能方便地搜到許多介紹一致性雜湊演算法的好文章. 本文主要想動手實現一致性雜湊演算法, 並搭建一個環境進行實戰測試.
- 在開始之前先整理一下演算法的思路:
- 一致性雜湊演算法通過把每臺伺服器的雜湊值打在雜湊環上, 把雜湊環分成不同的段, 然後對到來的請求計算雜湊值從而得知該請求所歸屬的伺服器. 這個辦法解決了傳統伺服器增減機器時需要重新計算雜湊的麻煩.
- 但如果伺服器的數量較少, 可能導致計算出的雜湊值相差較小, 在雜湊環上分佈不均勻, 導致某臺伺服器過載. 為了解決負載均衡問題, 我們引入虛擬節點技術, 為每臺伺服器分配一定數量的節點, 通過節點的雜湊值在雜湊環上進行劃分. 這樣一來, 我們就可以根據機器的效能為其分配節點, 效能好就多分配一點, 差就少一點, 從而達到負載均衡.
二. 實現一致性雜湊演算法.
- 奠定了整體思路後我們開始考慮實現的細節
- 雜湊演算法的選擇
- 選擇能雜湊出32位整數的FNV演算法, 由於該雜湊函式可能產生負數, 需要作取絕對值處理.
- 請求節點在雜湊環上尋找對應伺服器的策略
- 策略為: 新節點尋找最近比且它大的節點, 比如說現在已經有環[0, 5, 7, 10], 來了個雜湊值為6的節點, 那麼它應該由雜湊值為7對應的伺服器處理. 如果請求節點所計算的雜湊值大於環上的所有節點, 那麼就取第一個節點. 比如來了個11, 將分配到0所對應的節點.
- 雜湊環的組織結構
- 開始的時候想過用順序儲存的結構存放, 但是在一致性雜湊中, 最頻繁的操作是在集合中查詢最近且比目標大的數. 如果用順序儲存結構的話, 時間複雜度是收斂於O(N)的, 而樹形結構則為更優的O(logN).
- 但凡事有兩面, 採用樹形結構儲存的代價是資料初始化的效率較低, 而且執行期間如果有節點插入刪除的話效率也比較低. 但是在現實中, 伺服器在一開始註冊後基本上就不怎麼變了, 期間增減機器, 當機, 機器修復等事件的頻率相比起節點的查詢簡直是微不足道. 所以本案例決定使用使用樹形結構儲存.
- 貼合上述要求, 並且提供有序儲存的首先想到的是紅黑樹, 而且Java中提供了紅黑樹的實現
TreeMap
.
- 虛擬節點與真實節點的對映關係
-
如何確定一個虛擬節點對應的真實節點也是個問題. 理論上應該維護一張表記錄真實節點與虛擬節點的對映關係. 本引入案例為了演示採用簡單的字串處理. 比方說伺服器
192.168.0.1:8888
分配了1000個虛擬節點, 那麼它的虛擬節點名稱從192.168.0.1:8888@1
一直到192.168.0.1:8888@1000
. 通過這樣的處理, 我們在通過虛擬節點找真實節點時只需要裁剪字串即可. -
計劃定製好後, 下面開始懟程式碼
public class ConsistentHashTest {
/**
* 伺服器列表,一共有3臺伺服器提供服務, 將根據效能分配虛擬節點
*/
public static String[] servers = {
"192.168.0.1#100", //伺服器1: 效能指數100, 將獲得1000個虛擬節點
"192.168.0.2#100", //伺服器2: 效能指數100, 將獲得1000個虛擬節點
"192.168.0.3#30" //伺服器3: 效能指數30, 將獲得300個虛擬節點
};
/**
* 真實伺服器列表, 由於增加與刪除的頻率比遍歷高, 用連結串列儲存比較划算
*/
private static List<String> realNodes = new LinkedList<>();
/**
* 虛擬節點列表
*/
private static TreeMap<Integer, String> virtualNodes = new TreeMap<>();
static{
for(String s : servers){
//把伺服器加入真實伺服器列表中
realNodes.add(s);
String[] strs = s.split("#");
//伺服器名稱, 省略埠號
String name = strs[0];
//根據伺服器效能給每臺真實伺服器分配虛擬節點, 並把虛擬節點放到虛擬節點列表中.
int virtualNodeNum = Integer.parseInt(strs[1]) * 10;
for(int i = 1; i <= virtualNodeNum; i++){
virtualNodes.put(FVNHash(name + "@" + i), name + "@" + i);
}
}
}
public static void main(String[] args) {
new Thread(new RequestProcess()).start();
}
static class RequestProcess implements Runnable{
@Override
public void run() {
String client = null;
while(true){
//模擬產生一個請求
client = getN() + "." + getN() + "." + getN() + "." + getN() + ":" + (1000 + (int)(Math.random() * 9000));
//計算請求的雜湊值
int hash = FVNHash(client);
//判斷請求將由哪臺伺服器處理
System.out.println(client + " 的請求將由 " + getServer(client) + " 處理");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
private static String getServer(String client) {
//計算客戶端請求的雜湊值
int hash = FVNHash(client);
//得到大於該雜湊值的所有map集合
SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash);
//找到比該值大的第一個虛擬節點, 如果沒有比它大的虛擬節點, 根據雜湊環, 則返回第一個節點.
Integer targetKey = subMap.size() == 0 ? virtualNodes.firstKey() : subMap.firstKey();
//通過該虛擬節點獲得真實節點的名稱
String virtualNodeName = virtualNodes.get(targetKey);
String realNodeName = virtualNodeName.split("@")[0];
return realNodeName;
}
public static int getN(){
return (int)(Math.random() * 128);
}
public static int FVNHash(String data){
final int p = 16777619;
int hash = (int)2166136261L;
for(int i = 0; i < data.length(); i++)
hash = (hash ^ data.charAt(i)) * p;
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
return hash < 0 ? Math.abs(hash) : hash;
}
}
/* 執行結果片段
55.1.13.47:6240 的請求將由 192.168.0.1 處理
5.49.56.126:1105 的請求將由 192.168.0.1 處理
90.41.8.88:6884 的請求將由 192.168.0.2 處理
26.107.104.81:2989 的請求將由 192.168.0.2 處理
114.66.6.56:8233 的請求將由 192.168.0.1 處理
123.74.52.94:5523 的請求將由 192.168.0.1 處理
104.59.60.2:7502 的請求將由 192.168.0.2 處理
4.94.30.79:1299 的請求將由 192.168.0.1 處理
10.44.37.73:9332 的請求將由 192.168.0.2 處理
115.93.93.82:6333 的請求將由 192.168.0.2 處理
15.24.97.66:9177 的請求將由 192.168.0.2 處理
100.39.98.10:1023 的請求將由 192.168.0.2 處理
61.118.87.26:5108 的請求將由 192.168.0.2 處理
17.79.104.35:3901 的請求將由 192.168.0.1 處理
95.36.5.25:8020 的請求將由 192.168.0.2 處理
126.74.56.71:7792 的請求將由 192.168.0.2 處理
14.63.56.45:8275 的請求將由 192.168.0.1 處理
58.53.44.71:2089 的請求將由 192.168.0.3 處理
80.64.57.43:6144 的請求將由 192.168.0.2 處理
46.65.4.18:7649 的請求將由 192.168.0.2 處理
57.35.27.62:9607 的請求將由 192.168.0.2 處理
81.114.72.3:3444 的請求將由 192.168.0.1 處理
38.18.61.26:6295 的請求將由 192.168.0.2 處理
71.75.18.82:9686 的請求將由 192.168.0.2 處理
26.11.98.111:3781 的請求將由 192.168.0.1 處理
62.86.23.37:8570 的請求將由 192.168.0.3 處理
*/
複製程式碼
- 經過上面的測試我們可以看到效能較好的伺服器1和伺服器2分擔了大部分的請求, 只有少部分請求落到了效能較差的伺服器3上, 已經初步實現了負載均衡.
- 下面我們將結合zookeeper, 搭建一個更加逼真的伺服器叢集, 看看在部分伺服器上線下線的過程中, 一致性雜湊演算法是否仍能夠實現負載均衡.
三. 結合zookeeper搭建環境
環境介紹
- 首先會通過啟動多臺虛擬機器模擬伺服器叢集, 各臺伺服器都提供一個相同的介面供消費者消費.
- 同時會有一個消費者執行緒不斷地向伺服器叢集發起請求, 這些請求會經過一致性雜湊演算法均衡負載到各個伺服器.
- 為了能夠模擬上述場景, 我們必須在客戶端維護一個伺服器列表, 使得客戶端能夠通過一致性雜湊演算法選擇伺服器傳送. (現實中可能會把一致性雜湊演算法實現在前端伺服器, 客戶先訪問前端伺服器, 再路由到後端伺服器叢集).
- 但是我們的重點是模擬伺服器的當機和上線, 看看一致性雜湊演算法是否仍能實現負載均衡. 所以客戶端必須能夠感知伺服器端的變化並動態地調整它的伺服器列表.
- 為了完成這項工作, 我們引入
zookeeper
,zookeeper
的資料一致性演算法保證資料實時, 準確, 客戶端能夠通過zookeeper
得知實時的伺服器情況. - 具體操作是這樣的: 伺服器叢集先以臨時節點的方式連線到
zookeeper
, 並在zookeeper
上註冊自己的介面服務(註冊節點). 客戶端連線上zookeeper
後, 把已註冊的節點(伺服器)新增到自己的伺服器列表中. - 如果有伺服器當機的話, 由於當初註冊的是瞬時節點的原因, 該臺伺服器節點會從
zookeeper
中登出. 客戶端監聽到伺服器節點有變時, 也會動態調整自己的伺服器列表, 把當當機的伺服器從伺服器列表中刪除, 因此不會再向該伺服器傳送請求, 負載均衡的任務將交到剩餘的機器身上. - 當有伺服器從新連線上叢集后, 客戶端的伺服器列表也會更新, 雜湊環也將做出相應的變化以提供負載均衡.
具體操作:
I. 搭建zookeeper
叢集環境:
- 建立3個
zookeeper
服務, 構成叢集. 在各自的data
資料夾中新增一個myid
檔案, 各個id分別為1, 2, 3
. - 重新複製一份配置檔案, 在配置檔案中配置各個
zookeeper
的埠號. 本案例中三臺zookeeper
分別在2181, 2182, 2183
埠 - 啟動
zookeeper
叢集
由於zookeeper不是本案例的重點, 細節暫不展開講了.
II. 建立伺服器叢集, 提供RPC遠端呼叫服務
- 首先建立一個伺服器專案(使用Maven), 新增
zookeeper
依賴 - 建立常量介面, 用於儲存連線
zookeeper
的資訊
public interface Constant {
//zookeeper叢集的地址
String ZK_HOST = "192.168.117.129:2181,192.168.117.129:2182,192.168.117.129:2183";
//連線zookeeper的超時時間
int ZK_TIME_OUT = 5000;
//伺服器所釋出的遠端服務在zookeeper中的註冊地址, 也就是說這個節點中儲存了各個伺服器提供的介面
String ZK_REGISTRY = "/provider";
//zookeeper叢集中註冊服務的url地址的瞬時節點
String ZK_RMI = ZK_REGISTRY + "/rmi";
}
複製程式碼
3.封裝操作zookeeper
和釋出遠端服務的介面供自己呼叫, 本案例中釋出遠端服務使用Java自身提供的rmi
包完成, 如果沒有了解過可以參考這篇
public class ServiceProvider {
private CountDownLatch latch = new CountDownLatch(1);
/**
* 連線zookeeper叢集
*/
public ZooKeeper connectToZK(){
ZooKeeper zk = null;
try {
zk = new ZooKeeper(Constant.ZK_HOST, Constant.ZK_TIME_OUT, new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
//如果連線上了就喚醒當前執行緒.
latch.countDown();
}
});
latch.await();//還沒連線上時當前執行緒等待
} catch (Exception e) {
e.printStackTrace();
}
return zk;
}
/**
* 建立znode節點
* @param zk
* @param url 節點中寫入的資料
*/
public void createNode(ZooKeeper zk, String url){
try{
//要把寫入的資料轉化為位元組陣列
byte[] data = url.getBytes();
zk.create(Constant.ZK_RMI, data, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 釋出rmi服務
*/
public String publishService(Remote remote, String host, int port){
String url = null;
try{
LocateRegistry.createRegistry(port);
url = "rmi://" + host + ":" + port + "/rmiService";
Naming.bind(url, remote);
} catch (Exception e) {
e.printStackTrace();
}
return url;
}
/**
* 釋出rmi服務, 並且將服務的url註冊到zookeeper叢集中
*/
public void publish(Remote remote, String host, int port){
//呼叫publishService, 得到服務的url地址
String url = publishService(remote, host, port);
if(null != url){
ZooKeeper zk = connectToZK();//連線到zookeeper
if(null != zk){
createNode(zk, url);
}
}
}
}
複製程式碼
4. 自定義遠端服務. 服務提供一個簡單的方法: 客戶端發來一個字串, 伺服器在字串前面新增上Hello
, 並返回字串.
//UserService
public interface UserService extends Remote {
public String helloRmi(String name) throws RemoteException;
}
//UserServiceImpl
public class UserServiceImpl implements UserService {
public UserServiceImpl() throws RemoteException{
super();
}
@Override
public String helloRmi(String name) throws RemoteException {
return "Hello " + name + "!";
}
}
複製程式碼
5. 修改埠號, 啟動多個java虛擬機器, 模擬伺服器叢集. 為了方便演示, 自定義7777, 8888, 9999埠開啟3個伺服器程式, 到時會模擬7777埠的伺服器當機和修復重連.
public static void main(String[] args) throws RemoteException {
//建立工具類物件
ServiceProvider sp = new ServiceProvider();
//建立遠端服務物件
UserService userService = new UserServiceImpl();
//完成釋出
sp.publish(userService, "localhost", 9999);
}
複製程式碼
III. 編寫客戶端程式(運用一致性雜湊演算法實現負載均衡
- 封裝客戶端介面.
public class ServiceConsumer {
/**
* 提供遠端服務的伺服器列表, 只記錄遠端服務的url
*/
private volatile List<String> urls = new LinkedList<>();
/**
* 遠端服務對應的虛擬節點集合
*/
private static TreeMap<Integer, String> virtualNodes = new TreeMap<>();
public ServiceConsumer(){
ZooKeeper zk = connectToZK();//客戶端連線到zookeeper
if(null != zk){
//連線上後關注zookeeper中的節點變化(伺服器變化)
watchNode(zk);
}
}
private void watchNode(final ZooKeeper zk) {
try{
//觀察/provider節點下的子節點是否有變化(是否有伺服器登入或登出)
List<String> nodeList = zk.getChildren(Constants.ZK_REGISTRY, new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
//如果伺服器節點有變化就重新獲取
if(watchedEvent.getType() == Event.EventType.NodeChildrenChanged){
System.out.println("伺服器端有變化, 可能有舊伺服器當機或者新伺服器加入叢集...");
watchNode(zk);
}
}
});
//將獲取到的伺服器節點資料儲存到集合中, 也就是獲得了遠端服務的訪問url地址
List<String> dataList = new LinkedList<>();
TreeMap<Integer, String> newVirtualNodesList = new TreeMap<>();
for(String nodeStr : nodeList){
byte[] data = zk.getData(Constants.ZK_REGISTRY + "/" + nodeStr, false, null);
//放入伺服器列表的url
String url = new String(data);
//為每個伺服器分配虛擬節點, 為了方便模擬, 預設開啟在9999埠的伺服器效能較差, 只分配300個虛擬節點, 其他分配1000個.
if(url.contains("9999")){
for(int i = 1; i <= 300; i++){
newVirtualNodesList.put(FVNHash(url + "@" + i), url + "@" + i);
}
}else{
for(int i = 1; i <= 1000; i++){
newVirtualNodesList.put(FVNHash(url + "@" + i), url + "@" + i);
}
}
dataList.add(url);
}
urls = dataList;
virtualNodes = newVirtualNodesList;
dataList = null;//好讓垃圾回收器儘快收集
newVirtualNodesList = null;
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 根據url獲得遠端服務物件
*/
public <T> T lookUpService(String url){
T remote = null;
try{
remote = (T)Naming.lookup(url);
} catch (Exception e) {
//如果該url連線不上, 很有可能是該伺服器掛了, 這時使用伺服器列表中的第一個伺服器url重新獲取遠端物件.
if(e instanceof ConnectException){
if (urls.size() != 0){
url = urls.get(0);
return lookUpService(url);
}
}
}
return remote;
}
/**
* 通過一致性雜湊演算法, 選取一個url, 最後返回一個遠端服務物件
*/
public <T extends Remote> T lookUp(){
T service = null;
//隨機計算一個雜湊值
int hash = FVNHash(Math.random() * 10000 + "");
//得到大於該雜湊值的所有map集合
SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash);
//找到比該值大的第一個虛擬節點, 如果沒有比它大的虛擬節點, 根據雜湊環, 則返回第一個節點.
Integer targetKey = subMap.size() == 0 ? virtualNodes.firstKey() : subMap.firstKey();
//通過該虛擬節點獲得伺服器url
String virtualNodeName = virtualNodes.get(targetKey);
String url = virtualNodeName.split("@")[0];
//根據伺服器url獲取遠端服務物件
service = lookUpService(url);
System.out.print("提供本次服務的地址為: " + url + ", 返回結果: ");
return service;
}
private CountDownLatch latch = new CountDownLatch(1);
public ZooKeeper connectToZK(){
ZooKeeper zk = null;
try {
zk = new ZooKeeper(Constants.ZK_HOST, Constants.ZK_TIME_OUT, new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
//判斷是否連線zk叢集
latch.countDown();//喚醒處於等待狀態的當前執行緒
}
});
latch.await();//沒有連線上的時候當前執行緒處於等待狀態.
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
return zk;
}
public static int FVNHash(String data){
final int p = 16777619;
int hash = (int)2166136261L;
for(int i = 0; i < data.length(); i++)
hash = (hash ^ data.charAt(i)) * p;
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
return hash < 0 ? Math.abs(hash) : hash;
}
}
複製程式碼
2. 啟動客戶端進行測試
public static void main(String[] args){
ServiceConsumer sc = new ServiceConsumer();//建立工具類物件
while(true){
//獲得rmi遠端服務物件
UserService userService = sc.lookUp();
try{
//呼叫遠端方法
String result = userService.helloRmi("炭燒生蠔");
System.out.println(result);
Thread.sleep(100);
}catch(Exception e){
e.printStackTrace();
}
}
}
複製程式碼
3. 客戶端跑起來後, 在顯示臺不斷進行列印...下面將對資料進行統計.
IV. 對伺服器呼叫資料進行統計分析
- 重溫一遍模擬的過程: 首先分別在7777, 8888, 9999埠啟動了3臺伺服器. 然後啟動客戶端進行訪問. 7777, 8888埠的兩臺伺服器設定效能指數為1000, 而9999埠的伺服器效能指數設定為300.
- 在客戶端執行期間, 我手動關閉了8888埠的伺服器, 客戶端正常列印出伺服器變化資訊. 此時理論上不會有訪問被路由到8888埠的伺服器. 當我重新啟動8888埠伺服器時, 客戶端列印出伺服器變化資訊, 訪問能正常到達8888埠伺服器.
- 下面對各伺服器的訪問量進行統計, 看是否實現了負載均衡.
- 測試程式如下:
public class DataStatistics {
private static float ReqToPort7777 = 0;
private static float ReqToPort8888 = 0;
private static float ReqToPort9999 = 0;
public static void main(String[] args) {
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader("C://test.txt"));
String line = null;
while(null != (line = br.readLine())){
if(line.contains("7777")){
ReqToPort7777++;
}else if(line.contains("8888")){
ReqToPort8888++;
}else if(line.contains("9999")){
ReqToPort9999++;
}else{
print(false);
}
}
print(true);
} catch (Exception e) {
e.printStackTrace();
}finally {
if(null != br){
try {
br.close();
} catch (IOException e) {
e.printStackTrace();
}
br = null;
}
}
}
private static void print(boolean isEnd){
if(!isEnd){
System.out.println("------------- 伺服器叢集發生變化 -------------");
}else{
System.out.println("------------- 最後一次統計 -------------");
}
System.out.println("擷取自上次伺服器變化到現在: ");
float total = ReqToPort7777 + ReqToPort8888 + ReqToPort9999;
System.out.println("7777埠伺服器訪問量為: " + ReqToPort7777 + ", 佔比" + (ReqToPort7777 / total));
System.out.println("8888埠伺服器訪問量為: " + ReqToPort8888 + ", 佔比" + (ReqToPort8888 / total));
System.out.println("9999埠伺服器訪問量為: " + ReqToPort9999 + ", 佔比" + (ReqToPort9999 / total));
ReqToPort7777 = 0;
ReqToPort8888 = 0;
ReqToPort9999 = 0;
}
}
/* 以下是輸出結果
------------- 伺服器叢集發生變化 -------------
擷取自上次伺服器變化到現在:
7777埠伺服器訪問量為: 198.0, 佔比0.4419643
8888埠伺服器訪問量為: 184.0, 佔比0.4107143
9999埠伺服器訪問量為: 66.0, 佔比0.14732143
------------- 伺服器叢集發生變化 -------------
擷取自上次伺服器變化到現在:
7777埠伺服器訪問量為: 510.0, 佔比0.7589286
8888埠伺服器訪問量為: 1.0, 佔比0.0014880953
9999埠伺服器訪問量為: 161.0, 佔比0.23958333
------------- 最後一次統計 -------------
擷取自上次伺服器變化到現在:
7777埠伺服器訪問量為: 410.0, 佔比0.43248945
8888埠伺服器訪問量為: 398.0, 佔比0.41983122
9999埠伺服器訪問量為: 140.0, 佔比0.14767933
*/
複製程式碼
V. 結果
- 從測試資料可以看出, 不管是8888埠伺服器當機之前, 還是當機之後, 三臺伺服器接收的訪問量和效能指數成正比. 成功地驗證了一致性雜湊演算法的負載均衡作用.
四. 擴充套件思考
- 初識一致性雜湊演算法的時候, 對這種奇特的思路佩服得五體投地. 但是一致性雜湊演算法除了能夠讓後端伺服器實現負載均衡, 還有一個特點可能是其他負載均衡演算法所不具備的.
- 這個特點是基於雜湊函式的, 我們知道通過雜湊函式, 固定的輸入能夠產生固定的輸出. 換句話說, 同樣的請求會路由到相同的伺服器. 這點就很牛逼了, 我們可以結合一致性雜湊演算法和快取機制提供後端伺服器的效能.
- 比如說在一個分散式系統中, 有一個伺服器叢集提供查詢使用者資訊的方法, 每個請求將會帶著使用者的
uid
到達, 我們可以通過雜湊函式進行處理(從上面的演示程式碼可以看到, 這點是可以輕鬆實現的), 使同樣的uid
路由到某個獨定的伺服器. 這樣我們就可以在伺服器上對該的uid
背後的使用者資訊進行快取, 從而減少對資料庫或其他中介軟體的操作, 從而提高系統效率. - 當然如果使用該策略的話, 你可能還要考慮快取更新等操作, 但作為一種優良的策略, 我們可以考慮在適當的場合靈活運用.
- 以上思考受啟發於
Dubbo
框架中對其實現的四種負載均衡策略的描述.