一致性Hash演算法的實現
一致性hash作為一個負載均衡演算法,可以用在分散式快取、資料庫的分庫分表等場景中,還可以應用在負載均衡器中作為作為負載均衡演算法。在有多臺伺服器時,對於某個請求資源通過hash演算法,對映到某一個臺伺服器,當增加或減少一臺伺服器時,可能會改變這些資源對應的hash值,這樣可能導致一部分快取或資料失效了。一致性hash就是儘可能在將同一個資源請求路由到同一臺伺服器中。
本篇文章將模擬實現一個分散式快取系統來探討在使用了一致性hash以及普通hash在增加、刪除節點之後,對資料分佈、快取命中率的影響
節點&叢集設計
在一個分散式快取系統中,每臺機器可以認為是一個節點,節點作為資料儲存的地方,由一些節點來組成一個叢集。我們先來設計我們的節點和叢集。
節點
@Data
public class Node {
private String domain;
private String ip;
private Map<String, Object> data;
public <T> void put(String key, T value) {
data.put(key, value);
}
public void remove(String key){
data.remove(key);
}
public <T> T get(String key) {
return (T) data.get(key);
}
}
一個節點包括domain(域名),ip(IP地址),data(節點儲存資料),節點可以存放、刪除、獲取資料。
叢集
public abstract class Cluster {
protected List<Node> nodes;
public Cluster() {
this.nodes = new ArrayList<>();
}
public abstract void addNode(Node node);
public abstract void removeNode(Node node);
public abstract Node get(String key);
}
在一個叢集中包含多個節點,可以在一個叢集中,增加、刪除節點。還可以通過key獲取資料儲存的節點。
取模
在使用取模的場景中,當一個請求資源,請求某個叢集時,通過對請求資源進行hash得到的值,然後對儲存叢集的節點數取模來得到,該請求資源,應該儲存到哪一個儲存節點。
具體實現如下:
public class NormalHashCluster extends Cluster {
public NormalHashCluster() {
super();
}
@Override
public void addNode(Node node) {
this.nodes.add(node);
}
@Override
public void removeNode(Node node) {
this.nodes.removeIf(o -> o.getIp().equals(node.getIp()) ||
o.getDomain().equals(node.getDomain()));
}
@Override
public Node get(String key) {
long hash = hash(key);
long index = hash % nodes.size();
return nodes.get((int)index);
}
}
下面我們對該演算法,在資料分佈、增加一個節點、刪除一個節點對快取的命中率影響做一個測試
Cluster cluster = new NormalHashCluster();
cluster.addNode(new Node("c1.yywang.info", "192.168.0.1"));
cluster.addNode(new Node("c2.yywang.info", "192.168.0.2"));
cluster.addNode(new Node("c3.yywang.info", "192.168.0.3"));
cluster.addNode(new Node("c4.yywang.info", "192.168.0.4"));
IntStream.range(0, DATA_COUNT)
.forEach(index -> {
Node node = cluster.get(PRE_KEY + index);
node.put(PRE_KEY + index, "Test Data");
});
System.out.println("資料分佈情況:");
cluster.nodes.forEach(node -> {
System.out.println("IP:" + node.getIp() + ",資料量:" + node.getData().size());
});
//快取命中率
long hitCount = IntStream.range(0, DATA_COUNT)
.filter(index -> cluster.get(PRE_KEY + index).get(PRE_KEY + index) != null)
.count();
System.out.println("快取命中率:" + hitCount * 1f / DATA_COUNT);
在初始狀態下,資料的分佈和快取命中率如下:
資料分佈情況:
IP:192.168.0.1,資料量:12499
IP:192.168.0.2,資料量:12501
IP:192.168.0.3,資料量:12499
IP:192.168.0.4,資料量:12501
快取命中率:1.0
從以上資料可以看出,資料分佈較均勻,在不增不減節點的情況下,快取全部命中
我們新增一個節點
//增加一個節點
cluster.addNode(new Node("c5.yywang.info", "192.168.0.5"));
這時快取命中率
增加一個節點的快取命中率:0.19808
我們來刪除一個節點
cluster.removeNode(new Node("c4.yywang.info", "192.168.0.4"));
這時快取命中率
刪除快取命中率:0.25196
從以上可以看出,通過取模演算法,在增加節點、刪除節點時,將對快取命中率產生極大的影響,所以在該場景中如果使用取模運算將會產生很多的資料遷移量。
一致性hash
為了解決以上取模運算的缺點,我們引入一致性hash演算法,一致性hash演算法的原理如下:
首先我們把2的32次方想象成一個環,比如:
假如我們有四臺伺服器分佈這個環上,其中Node1,Node2,Node3,Node4就表示這四臺伺服器在環上的位置,一致性hash演算法就是,在快取的Key的值計算後得到的hash值,對映到這個環上的點,然後這些點按照順時針方向找,找到離自己最近的一個物理節點就是自己要儲存的節點。
當我們增加了一個節點如下:
我們增加了Node5放在Node3和Node4之間,這時我們可以看到增加了一個節點只會影響Node3至Node5之間的資料,其他節點的資料不會受到影響。同時我們還可以看到,Node4和Node5的壓力要小於其他節點,大約是其他節點的一半。這樣就帶了壓力分佈均勻的情況,假定Node4和Node5的機器配置和其它的節點機器配置相同,那麼Node4和Node5的機器資源就浪費了一半,那麼怎麼解決這個問題呢?
我們引入虛擬節點,簡單來說,虛擬節點就是不存在的點,這些虛擬節點儘量的分佈在環上,需要做的就是把這些虛擬節點需要對映到物理節點。
在引入虛擬節點後,我們把虛擬節點上均勻的分佈到環上,然後把虛擬節點對映到物理節點,當增加了新的機器後,我們只需要把虛擬節點對映到新的機器即可,這樣就解決了機器壓力分佈不均勻的情況
上面我們說了一致性hash的基本演算法,下面我們來看下具體實現
public class ConsistencyHashCluster extends Cluster {
private SortedMap<Long, Node> virNodes = new TreeMap<Long, Node>();
private static final int VIR_NODE_COUNT = 512;
private static final String SPLIT = "#";
public ConsistencyHashCluster() {
super();
}
@Override
public void addNode(Node node) {
this.nodes.add(node);
IntStream.range(0, VIR_NODE_COUNT)
.forEach(index -> {
long hash = hash(node.getIp() + SPLIT + index);
virNodes.put(hash, node);
});
}
@Override
public void removeNode(Node node) {
nodes.removeIf(o -> node.getIp().equals(o.getIp()));
IntStream.range(0, VIR_NODE_COUNT)
.forEach(index -> {
long hash = hash(node.getIp() + SPLIT + index);
virNodes.remove(hash);
});
}
@Override
public Node get(String key) {
long hash = hash(key);
SortedMap<Long, Node> subMap = hash >= virNodes.lastKey() ? virNodes.tailMap(0L) : virNodes.tailMap(hash);
if (subMap.isEmpty()) {
return null;
}
return subMap.get(subMap.firstKey());
}
}
下面我們同樣對一致性hash演算法,在資料分佈、增加一個節點、刪除一個節點對快取的命中率影響做一個測試
測試程式碼很簡單,我們只需要把以上的程式碼替換成ConsistencyHashCluster實現即可
// Cluster cluster = new NormalHashCluster();
Cluster cluster=new ConsistencyHashCluster();
在初始狀態下,資料的分佈和快取命中率如下:
資料分佈情況:
IP:192.168.0.1,資料量:15345
IP:192.168.0.2,資料量:14084
IP:192.168.0.3,資料量:10211
IP:192.168.0.4,資料量:10360
快取命中率:1.0
從以上資料可以看出,資料分佈相對均勻,沒有取模演算法的均勻,在不增不減節點的情況下,快取全部命中
我們增加一個節點
cluster.addNode(new Node("c" + 5 + ".yywang.info", "192.168.0." + 5));
這時快取命中率
增加一個節點的快取命中率:0.82154
可以看出快取命中率明顯高於取模運算的命中率
我們刪除一個節點
cluster.removeNode(new Node("c4.yywang.info", "192.168.0.4"));
這時快取命中率
刪除一個節點的快取命中率:0.7928
從以上可以看出,我們可以看出使用一致性hash演算法,可以極大的提高快取的命中率,減少在增加節點、刪除節點時,資料遷移的成本。