天天看點

一文了解 Consistent Hash

在分布式環境下面,我們經常會通過一定的規則來進行資料分布的定義,本文描述的取模算法和一緻性hash是通過一定規則産生一個key,對這個key進行一定規則的運算,得出這個資料該去哪兒。

本文首發于 vivo網際網路技術 微信公衆号 

連結:https://mp.weixin.qq.com/s/LGLqEOlGExKob8xEXXWckQ

作者:錢幸川

在分布式環境下面,我們經常會通過一定的規則來進行資料分布的定義,本文描述的取模算法和一緻性 Hash(Consistent Hash)是通過一定規則産生一個key,對這個key進行一定規則的運算,得出這個資料該去哪兒。

本文使用軟體環境:Java 8

一、資料分布接口定義

概述

在分布式環境下面,我們經常會通過一定的規則來進行資料分布的定義,比如使用者1的資料存儲到資料庫1、使用者2的資料存儲到資料庫2......

一般來說,有這麼幾種常用的方式:

  1. 有一個分布式環境中唯一的中心分發節點,每次在資料存儲的時候,都會詢問中心節點這個資料該去哪兒,這個分發節點明确告訴這個資料該去哪兒。
  2. 通過一定規則産生一個key,對這個key進行一定規則的運算,得出這個資料該去哪兒。本文描述的取模算法和一緻性Hash,就是這樣一種方式。

接口定義

/**
* 資料分布hash算法接口定義
* @author xingchuan.qxc
*
*/
public interface HashNodeService {

/**
* 叢集增加一個資料存儲節點
* @param node
*/
public void addNode(Node node);

/**
* 資料存儲時查找具體使用哪個節點來存儲
* @param key
* @return
*/
public Node lookupNode(String key);

/**
* hash的算法
* @param key
* @return
*/
public Long hash(String key);

/**
* 模拟意外情況斷掉一個節點,用于測試緩存命中率
* @param node
*/
public void removeNodeUnexpected(Node node);
}           

二、資料分布算法實作——取模算法

取模算法的應用場景描述如下:

需要在叢集中實作一個使用者資料存儲的負載均衡,叢集中有n個存儲節點,如何均勻的把各個資料分布到這n個節點呢?

實作步驟大概分成兩步:

  1. 通過使用者的key來取一個hash值
  2. 通過這個hash值來對存儲節點數n進行取模,得出一個index
  3. 上面這個index就是待存儲的節點辨別

Note:本文例子我生成hash值的方式,我采用CRC32的方式。

代碼實作:

/**
* 取模資料分布算法實作
* @author xingchuan.qxc
*
*/
public class NormalHashNodeServiceImpl implements HashNodeService{

/**
* 存儲節點清單
*/
private List<Node> nodes = new ArrayList<>();

@Override
public void addNode(Node node) {
this.nodes.add(node);
}
@Override
public Node lookupNode(String key) {
long k = hash(key);
int index = (int) (k % nodes.size());
return nodes.get(index);
}
@Override
public Long hash(String key) {
CRC32 crc32 = new CRC32();
crc32.update(key.getBytes());
return crc32.getValue();
}
@Override
public void removeNodeUnexpected(Node node) {
nodes.remove(node);
}
}           

通過上述例子我們可以看到,lookupNode的時候,是要先去取這個key的CRC32的值,然後對叢集中節點數進行取模得到r,最後傳回下标為r的Node。

測試代碼如下:

HashNodeService nodeService = new NormalHashNodeServiceImpl();
Node addNode1 = new Node("xingchuan.node1", "192.168.0.11");
Node addNode2 = new Node("xingchuan.node2", "192.168.0.12");
Node addNode3 = new Node("xingchuan.node3", "192.168.0.13");
Node addNode4 = new Node("xingchuan.node4", "192.168.0.14");
Node addNode5 = new Node("xingchuan.node5", "192.168.0.15");
Node addNode6 = new Node("xingchuan.node6", "192.168.0.16");
Node addNode7 = new Node("xingchuan.node7", "192.168.0.17");
Node addNode8 = new Node("xingchuan.node8", "192.168.0.18");
nodeService.addNode(addNode1);
nodeService.addNode(addNode2);
nodeService.addNode(addNode3);
nodeService.addNode(addNode4);
nodeService.addNode(addNode5);
nodeService.addNode(addNode6);
nodeService.addNode(addNode7);
nodeService.addNode(addNode8);

//用于檢查資料分布情況
Map<String, Integer> countmap = new HashMap<>();
Node node = null;
for (int i = 1; i <= 100000; i++) {
String key = String.valueOf(i);
node = nodeService.lookupNode(key);
node.cacheString(key, "TEST_VALUE");
String k = node.getIp();
Integer count = countmap.get(k);
if (count == null) {
count = 1;
countmap.put(k, count);
} else {
count++;
countmap.put(k, count);
}

}
System.out.println("初始化資料分布情況:" + countmap);           

運作結果如下:

初始化資料分布情況:{192.168.0.11=12499, 192.168.0.12=12498, 192.168.0.13=12500, 192.168.0.14=12503, 192.168.0.15=12500, 192.168.0.16=12502, 192.168.0.17=12499, 192.168.0.18=12499}           

可以看到,每個節點的存儲分布數量是大緻一樣的。

缺點

我們可以很清楚的看到,取模算法是通過資料存儲節點個數來進行運算的,是以,當存儲節點個數變化了,就會造成災難性的緩存失效。

舉例:

初始叢集裡面隻有4個存儲節點(Node0,Node1,Node2,Node3),這時候我要存儲id為1~10的使用者,我可以通過id % 4來運算得出各個ID的分布節點

一文了解 Consistent Hash

這時候,如果叢集新增一個存儲節點Node4,會發生什麼呢?

一文了解 Consistent Hash

這裡我們會發現,大量的存儲節點的key和原先的對應不上了,這時候我們如果在生産環境,就需要做大量的資料遷移。

删除一個節點,原理同上,不再贅述。

代碼模拟一個分布式緩存存儲,使用取模的方式,新增一個節點帶來的問題。測試代碼如下:

HashNodeService nodeService = new NormalHashNodeServiceImpl();
Node addNode1 = new Node("xingchuan.node1", "192.168.0.11");
Node addNode2 = new Node("xingchuan.node2", "192.168.0.12");
Node addNode3 = new Node("xingchuan.node3", "192.168.0.13");
Node addNode4 = new Node("xingchuan.node4", "192.168.0.14");
Node addNode5 = new Node("xingchuan.node5", "192.168.0.15");
Node addNode6 = new Node("xingchuan.node6", "192.168.0.16");
Node addNode7 = new Node("xingchuan.node7", "192.168.0.17");
Node addNode8 = new Node("xingchuan.node8", "192.168.0.18");
nodeService.addNode(addNode1);
nodeService.addNode(addNode2);
nodeService.addNode(addNode3);
nodeService.addNode(addNode4);
nodeService.addNode(addNode5);
nodeService.addNode(addNode6);
nodeService.addNode(addNode7);
nodeService.addNode(addNode8);

//用于檢查資料分布情況
Map<String, Integer> countmap = new HashMap<>();
Node node = null;
for (int i = 1; i <= 100000; i++) {
String key = String.valueOf(i);
node = nodeService.lookupNode(key);
node.cacheString(key, "TEST_VALUE");
String k = node.getIp();
Integer count = countmap.get(k);
if (count == null) {
count = 1;
countmap.put(k, count);
} else {
count++;
countmap.put(k, count);
}

}
System.out.println("初始化資料分布情況:" + countmap);
// 正常情況下的去擷取資料,命中率
int hitcount = 0;
for (int i = 1; i <= 100000; i++) {
String key = String.valueOf(i);
node = nodeService.lookupNode(key);
if (node != null) {
String value = node.getCacheValue(key);
if (value != null) {
hitcount++;
}
}
}
double h = Double.parseDouble(String.valueOf(hitcount))/ Double.parseDouble(String.valueOf(100000));
System.out.println("初始化緩存命中率:"+ h);
// 移除一個節點
Node addNode9 = new Node("xingchuan.node0", "192.168.0.19");
nodeService.addNode(addNode9);
hitcount = 0;
for (int i = 1; i <= 100000; i++) {
String key = String.valueOf(i);
node = nodeService.lookupNode(key);
if (node != null) {
String value = node.getCacheValue(key);
if (value != null) {
hitcount++;
}
}
}
h = Double.parseDouble(String.valueOf(hitcount))/ Double.parseDouble(String.valueOf(100000));
System.out.println("增加一個節點後緩存命中率:"+ h);           
初始化資料分布情況:{192.168.0.11=12499, 192.168.0.12=12498, 192.168.0.13=12500, 192.168.0.14=12503, 192.168.0.15=12500, 192.168.0.16=12502, 192.168.0.17=12499, 192.168.0.18=12499}
初始化緩存命中率:1.0
增加一個節點後緩存命中率:0.11012           

三、分布式資料分布算法——一緻性Hash

取模算法的劣勢很明顯,當新增節點和删除節點的時候,會涉及大量的資料遷移問題。為了解決這一問題,引入了一緻性Hash。

一緻性Hash算法的原理很簡單,描述如下:

  1. 想象有一個巨大的環,比如這個環的值的分布可以是 0 ~ 4294967296
    一文了解 Consistent Hash
  2. 還是在取模算法中的那個例子,這時候我們假定我們的4個節點通過一些key的hash,分布在了這個巨大的環上面。
  3. 使用者資料來了,需要存儲到哪個節點呢?通過key的hash,得出一個值r,順時針找到最近的一個Node節點對應的hash值nodeHash,這次使用者資料也就存儲在對應的這個Node上。

那麼問題來了,如果隻有4個節點,可能會造成資料分布不均勻的情況,舉個例子,上圖中的Node3和Node4離的很近,這時候,Node1的壓力就會很大了。如何解決這個問題呢?虛拟節點能解決這個問題。

什麼是虛拟節點?

簡單說,就是在環上模拟很多個不存在的節點,這時候這些節點是可以盡可能均勻分布在環上的,在key的hash後,順時針找最近的存儲節點,存儲完成之後,叢集中的資料基本上就配置設定均勻了。唯一要做的,必須要維護一個虛拟節點到真實節點的關系。

一緻性Hash的實作

下面,我們就來通過兩個進階,實作一個一緻性Hash。

進階一我們不引入虛拟節點,進階二我們引入虛拟節點

一緻性Hash實作,進階一,關鍵代碼如下:

@Override
public void addNode(Node node) {
nodeList.add(node);
long crcKey = hash(node.getIp());
nodeMap.put(crcKey, node);
}

@Override
public Node lookupNode(String key) {
long crcKey = hash(key);
Node node = findValidNode(crcKey);
if(node == null){
return findValidNode(0);
}
return node;
}

/**
  * @param crcKey
  */
  private Node findValidNode(long crcKey) {
  //順時針找到最近的一個節點
  Map.Entry<Long,Node> entry = nodeMap.ceilingEntry(crcKey);
   if(entry != null){
   return entry.getValue();
   }
   return null;
}

@Override
public Long hash(String key) {
CRC32 crc = new CRC32();
crc.update(key.getBytes());
return crc.getValue();
}           

這裡我們發現,計算key的hash的算法和取模算法例子裡是一樣的,這不是重點,重點是,在addNode的時候,我們通過ip位址來進行一次hash,并且丢到了一個TreeMap裡面,key是一個Long,是可以自動排序的。

在lookupNode的時候,我們是順時針去找最近的一個節點,如果沒有找到,資料就會存在環上順時針數第一個節點。

和取模算法的一樣,唯一不同的,就是把算法實作的那一行改掉
HashNodeService nodeService = new ConsistentHashNodeServiceImpl();           
初始化資料分布情況:{192.168.0.11=2495, 192.168.0.12=16732, 192.168.0.13=1849, 192.168.0.14=32116, 192.168.0.15=2729, 192.168.0.16=1965, 192.168.0.17=38413, 192.168.0.18=3701}
初始化緩存命中率:1.0
增加一個節點後緩存命中率:0.97022           

這裡我們可以看到,資料分布是不均勻的,同時我們也發現,某一個節點失效了,對于緩存命中率的影響,要比取模算法的場景,要好得多。

一緻性Hash的實作,進階2,引入虛拟節點,代碼如圖:

一文了解 Consistent Hash

我們在新增節點的時候,每個真實節點對應128個虛拟節點

删除節點的代碼如下,對應的虛拟節點也一并删掉。

一文了解 Consistent Hash

再次測試資料分布和緩存命中率

測試代碼不變,運作結果如下:

初始化資料分布情況:{192.168.0.11=11610, 192.168.0.12=14600, 192.168.0.13=13472, 192.168.0.14=11345, 192.168.0.15=11166, 192.168.0.16=12462, 192.168.0.17=14477, 192.168.0.18=10868}
初始化緩存命中率:1.0
增加一個節點後緩存命中率:0.91204           

這時,我們發現資料分布的情況已經比上面沒有引入虛拟節點的情況好太多了。

總結

我了解一緻性Hash就是為了解決在分布式存儲擴容的時候涉及到的資料遷移的問題。

但是,一緻性Hash中如果每個節點的資料都很平均,每個都是熱點,在資料遷移的時候,還是會有比較大資料量遷移。

更多内容敬請關注 vivo 網際網路技術 微信公衆号

一文了解 Consistent Hash

注:轉載文章請先與微信号:labs2020 聯系。

分享 vivo 網際網路技術幹貨與沙龍活動,推薦最新行業動态與熱門會議。

繼續閱讀