一緻性Hash原理及應用
一、背景
在業務開發中,我們常把資料持久化到資料庫中。如果需要讀取這些資料,除了直接從資料庫中讀取外,為了減輕資料庫的通路壓力以及提高通路速度,我們更多地引入緩存來對資料進行存取。對于隻有單個緩存伺服器來說我們可以直接定位,那麼對于分布式情境下,我們又該如何處理呢?
二、引入
我們在使用Redis的時候,為了保證Redis的高可用,提高Redis的讀寫性能,最簡單的方式我們會做主從複制,組成Master-Master或者Master-Slave的形式,或者搭建Redis叢集,進行資料的讀寫分離,類似于資料庫的主從複制和讀寫分離。當我們的系統更加龐大,業務更加繁雜時,類似資料庫一樣,隻通過讀寫分離也無法滿足需求,這時我們可能需要分布式緩存:
假如目前沒有任何規則,那麼所有資料的存放都是随機的,讀取資料時并不确定在哪一台redis上,是以我們隻有通過周遊的方式才能找到,很顯然,這個過程是沒有必要的。這個時候我們可以回想Jdk1.7中Hashmap的源代碼中indexfor實作:IndexFor方法 (此處是位運算,跟取模的結果一樣) 是為了确定目前元素在數組上的位置,再去定位在連結清單上的位置,跟目前場景一樣為了定位,而我們要找的資料的key可能是字元串類型,我們可以通過取hash值再取模的方式來存取。那麼新的問題來了,如果我們其中的一台伺服器宕掉了或要添加新的伺服器,這個時候會發生什麼呢
三、原理
我們發現,使用取模的方式如1 mod 4 = 3, 2 mod 4 = 2...,如果我們加入一台伺服器,之前存儲在第3台伺服器上的資料現在取數都被分發到 1 mod 5 = 4 第4台伺服器上,結果就是找不到資料,如果這時候有大量的請求過來會全部去讀取資料庫,造成的結果就是緩存雪崩,對資料庫會帶來災難行的後果。為了解決這些問題,一緻性Hash算法出現了。
一緻性Hash算法也是通過取模來完成,不過它不再是對例如伺服器的數量來取模,而是對2^32 取模。算法原理即先構造一個2^32的整數環,根據伺服器節點名稱(或IP)的Hash值将伺服器節點放在這個環上,然後根據資料的key值計算得到起哈希值,接着在環上順時針查找距離這個key的Hash值最近的伺服器節點,完成key到伺服器的映射:
按照以上的假設,如果我們B節點宕掉了,按照規則B到A之間的資料會按照順時針去尋找下一個離它最近的節點,受影響的也隻有也隻會是此環空間中的一台伺服器的資料,增加一台伺服器同理。
四、問題
一緻性基本解決了以上問題,但是又暴露出另外一個問題---資料傾斜,即當服務節點過少而分部不均導緻大量的資料集中到其中一個節點,為了解決這種問題,一緻性Hash算法引入了虛拟節點機制,即将一個實體節點拆分為多個虛拟節點,并且同一個實體節點盡量均勻分布在Hash環上。
五、無虛拟節點代碼實作
public class ConsistentHashTest { static List<ServerNode> serverNodes= new ArrayList<ServerNode>(); // 真實機器節點 static class ServerNode{ private String nodeName; private long nodeHash; public ServerNode(String nodeName, long nodeHash) { super(); this.nodeName = nodeName; this.nodeHash = nodeHash; } public String getNodeName() { return nodeName; } public void setNodeName(String serverNodeName) { this.nodeName = serverNodeName; } public long getNodeHash() { return nodeHash; } public void setNodeHash(long serverNodeHash) { this.nodeHash = serverNodeHash; } } public long hash(String key){ int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } //得到應當路由到的伺服器結點 public ServerNode getServerNode(String key){ long hash = hash(key); for(ServerNode node: serverNodes){ //已排序,第一次找到大于這個節點即可 if(node.getNodeHash() > hash){ return node; } } //沒找到則在最後,依賴的即第一個 return serverNodes.get(0); } //添加伺服器節點 public void addServerNode(String serverNodeName){ long serverNodeHash = hash(serverNodeName); ServerNode serverNode = new ServerNode(serverNodeName,serverNodeHash); serverNodes.add(serverNode); //将serverNodes進行排序 Collections.sort(serverNodes, new Comparator<ServerNode>() { @Override public int compare(ServerNode node1, ServerNode node2) { if(node1.getNodeHash() < node2.getNodeHash()){ return -1; } return 1; } }); } //删除伺服器節點 public void deleteServerNode(String serverName){ for(ServerNode node: serverNodes){ if(serverName.equals(node.getNodeName())){ serverNodes.remove(node); return; } } } public static void main(String[] args){ ConsistentHashTest cht = new ConsistentHashTest(); //添加一系列的伺服器節點 String[] servers = {"10.0.0.20", "10.0.0.21","10.0.0.22", "10.0.0.23"}; for(String server: servers){ cht.addServerNode(server); } //列印輸出一下伺服器節點 System.out.println("所有的伺服器節點資訊如下:"); for(ServerNode node: serverNodes){ System.out.println(node.getNodeName()+":"+node.getNodeHash()); } //看看下面的用戶端節點會被路由到哪個伺服器節點 String[] nodes = {"a.png", "b.png", "c.png"}; System.out.println("此時,各個用戶端的路由情況如下:"); for(String node:nodes){ ServerNode serverNode = cht.getServerNode(node); System.out.println(node + "路由到" + serverNode.getNodeName()+","+serverNode.getNodeHash()); } //删除節點,發現所有用戶端都在23上,然後就删除23節點 cht.deleteServerNode("10.0.0.20"); System.out.println("删除20節點後,再看看同樣的用戶端的路由情況,如下:"); for(String node:nodes){ ServerNode serverNode = cht.getServerNode(node); System.out.println(node + "路由到" + serverNode.getNodeName()+","+serverNode.getNodeHash()); } //删除20節點沒有影響,然後就删除23節點 cht.deleteServerNode("10.0.0.23"); System.out.println("删除23節點後,再看看同樣的用戶端的路由情況,如下:"); for(String node:nodes){ ServerNode serverNode = cht.getServerNode(node); System.out.println(node + "路由到" + serverNode.getNodeName()+","+serverNode.getNodeHash()); } }
參考資料:
https://blog.csdn.net/u010412719/article/details/53863219