hash簡介
說到底,他是一種hash算法,那什么是hash算法?
hash算法是一種散列算法,常用的比如MD5。抽象來說,他是將任意長度的輸入X,經(jīng)過hash算法后,變成固定長度的輸出Y(輸出Y稱為散列值),多個X可能對應同一個Y(hash碰撞,概率極小極小極小,MD5的碰撞概率為2/2的64次方),且從Y不能反推出X,對X的任意修改,都將導致輸出的Y完全不一樣。
基于以上的這些特性,hash算法可以有以下這些應用場景:
用hash算法對網(wǎng)站的登陸密碼使用散列值保存(利用的是不可反推這一特性,這樣即便淘寶的數(shù)據(jù)庫被盜了,誰也不會知道你到底在淘寶買了些什么...東西)
對比兩個文件是否完全一樣,假如要對比的txt文件中有上萬的字符,如何一個一個去作對比,效率很差,直接對比文件的散列值是否一致即可知道文件是否一致(利用的是輸出的散列值是固定長度)
-
負載均衡算法
作為負載均衡算法時,輸入X是客戶端請求的某個唯一ID或者key,hash之后的輸出Y是一個一定范圍內(nèi)的Long類型數(shù)字,然后我們對這個Long類型的數(shù)字進行取模,取模得到的余數(shù)就是需要訪問的節(jié)點唯一編號,利用取模的特性(假如集群中服務器數(shù)量為3臺,那么模數(shù)是3,可以將大量的數(shù)字,映射到[0-3)這個區(qū)間段)將key均勻的映射到集群中的機器上,這樣就達到了負載均衡的目的,算法公式:hash(key) % n,如下圖:
image.png
最終key1 - keyn會被均勻的分布在圖中的三個節(jié)點上,看似已經(jīng)達到了我們想要的負載均衡的效果了,但是,當客戶端請求在某一時段激增的時候(比如雙十一),我們常常需要增加服務器,雙十一過后,這些增加的服務器又需要下線,那么上圖中的算法,就不足以支撐這種情況,假如這時候node3需要被下線,那么客戶端請求對應的負載均衡算法就變成了hash(key) % 2,這時候客戶端所有請求和節(jié)點之間的映射關(guān)系就全變了,許多請求會被負載均衡到"錯誤的"節(jié)點上去從而拿不到想要的數(shù)據(jù)(如果是緩存,那么絕大部分的獲取緩存的請求都會失效,大量請求會打到DB),人為的去轉(zhuǎn)移數(shù)據(jù),那只能996.ICU網(wǎng)站見了。為了解決這個問題,所以,我們需要一致性hash算法
一致性hash算法原理
假設(shè)我們采用的hash算法的值空間為0-P,將0-P構(gòu)建成一個hash環(huán),如下圖:

在普通的hash算法中,我們僅僅對請求唯一標識做了hash,并且它是一個線性的hash空間,而在一致性hash算法中,還會使用同樣的hash算法對服務器標識做一次hash運算(一般對服務器IP或者主機名做hash運算),然后將兩種hash值映射在這個hash環(huán)(java中可以用TreeMap實現(xiàn))上面,如下圖:

一致性hash算法具備以下特性:
- 客戶端請求最終會落到他所在hash環(huán)的順時針方向的第一個節(jié)點上,在上圖中,key3,key14,key1024請求最終會落到node1上面,而key5,key76982,key18會落到node3上面。
- 當集群中刪除節(jié)點nodex時,原本應該落在nodex上面的請求,會被轉(zhuǎn)移到nodex順時針的下一個節(jié)點,新增節(jié)點同理,可以看到,無論新增還是刪除一個節(jié)點,受影響的都只有一個節(jié)點的數(shù)據(jù),如下圖:
image.png
image.png
以上的一致性hash算法相比較普通的hash算法有了很大的改進,但是依然存在問題,以刪除節(jié)點為例,刪除一個節(jié)點后,集群中大部分的請求key都會落到node2這個節(jié)點上,已經(jīng)并不是"負載均衡"了。一般的hash環(huán)空間會很大,而如果當集群中節(jié)點數(shù)量不是很多的時候,節(jié)點在環(huán)上面的位置可能會擠在很小的一部分區(qū)域,這樣就導致一大部分請求會落到某個節(jié)點上(數(shù)據(jù)傾斜),為了解決這個問題,一致性hash算法引入虛擬節(jié)點。
虛擬節(jié)點
對每一個服務器節(jié)點進行多次hash,將生成的hash值也分布在hash環(huán)上,這些新生成的節(jié)點稱為虛擬節(jié)點。假設(shè)我們用服務器IP地址來做hash,那么可以在IP地址后面增加編號來做hash運算生成虛擬節(jié)點,增加虛擬節(jié)點后,hash環(huán)如下圖:

從圖中看到,沒有增加虛擬節(jié)點的時候,大部分的請求會落到node2這個節(jié)點上面,導致node2節(jié)點請求壓力過大,當我們增加虛擬節(jié)點后,node1,node2,node3都能均勻的接收到客戶端的請求。而刪除某個節(jié)點nodex的時候,原本應該落在nodex上面的請求,也會被均勻的分配給其他節(jié)點。
java實現(xiàn)一致性hash算法
首先我們得需要一個hash算法,hash算法有很多種,這里提供兩種實現(xiàn),一種基于MD5,一種基于FNV1_32_HASH(這種其實是我抄來的,哈哈哈哈~~~~)
基于MD5:
/**
* hash 運算
* @param value
* @return
*/
public Long hash(String value){
MessageDigest md5;
try {
md5 = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 not supported", e);
}
md5.reset();
byte[] keyBytes = null;
try {
keyBytes = value.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Unknown string :" + value, e);
}
md5.update(keyBytes);
byte[] digest = md5.digest();
// hash code, Truncate to 32-bits
long hashCode = ((long) (digest[3] & 0xFF) << 24)
| ((long) (digest[2] & 0xFF) << 16)
| ((long) (digest[1] & 0xFF) << 8)
| (digest[0] & 0xFF);
long truncateHashCode = hashCode & 0xffffffffL;
return truncateHashCode;
}
基于FNV1_32_HASH:
public class HashUtil {
/**
* 計算Hash值, 使用FNV1_32_HASH算法
* @param str
* @return
*/
public static int getHash(String str) {
final int p = 16777619;
int hash = (int)2166136261L;
for (int i = 0; i < str.length(); i++) {
hash =( hash ^ str.charAt(i) ) * p;
}
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
if (hash < 0) {
hash = Math.abs(hash);
}
return hash;
}
}
接下來我們實現(xiàn)hash環(huán),負載均衡一般是讀多寫少(新增或者刪除節(jié)點畢竟少數(shù)),在讀多寫少的情況下,我們?nèi)菀紫氲接枚鏄鋪韺崿F(xiàn),在java中一般直接可以用TreeMap來實現(xiàn),TreeMap是由紅黑樹實現(xiàn)的,很適合作為hash環(huán)的存儲結(jié)構(gòu),先實現(xiàn)一個沒有虛擬節(jié)點的hash環(huán):
public class ConsistentHashingWithoutVirtualNode {
/**
* 集群地址列表
*/
private static String[] groups = {
"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
"192.168.0.3:111", "192.168.0.4:111"
};
/**
* 用于保存Hash環(huán)上的節(jié)點
*/
private static SortedMap<Integer, String> sortedMap = new TreeMap<>();
/**
* 初始化,將所有的服務器加入Hash環(huán)中
*/
static {
// 使用紅黑樹實現(xiàn),插入效率比較差,但是查找效率極高
for (String group : groups) {
int hash = HashUtil.getHash(group);
System.out.println("[" + group + "] launched @ " + hash);
sortedMap.put(hash, group);
}
}
/**
* 計算對應的請求應該落到哪一個節(jié)點上
*
* @param key
* @return
*/
private static String getServer(String key) {
int hash = HashUtil.getHash(key);
// 只取出所有大于該hash值的部分而不必遍歷整個Tree
SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
if (subMap == null || subMap.isEmpty()) {
// hash值在最尾部,應該映射到第一個group上
return sortedMap.get(sortedMap.firstKey());
}
return subMap.get(subMap.firstKey());
}
public static void main(String[] args) {
// 生成隨機數(shù)進行測試
Map<String, Integer> resMap = new HashMap<>();
for (int i = 0; i < 100000; i++) {
Integer widgetId = (int)(Math.random() * 10000);
String server = getServer(widgetId.toString());
if (resMap.containsKey(server)) {
resMap.put(server, resMap.get(server) + 1);
} else {
resMap.put(server, 1);
}
}
resMap.forEach(
(k, v) -> {
System.out.println("group " + k + ": " + v + "(" + v/1000.0D +"%)");
}
);
}
}
生成10000個隨機數(shù)字進行測試,最終得到的壓力分布情況如下:
[192.168.0.1:111] launched @ 8518713
[192.168.0.2:111] launched @ 1361847097
[192.168.0.3:111] launched @ 1171828661
[192.168.0.4:111] launched @ 1764547046
group 192.168.0.2:111: 8572(8.572%)
group 192.168.0.1:111: 18693(18.693%)
group 192.168.0.4:111: 17764(17.764%)
group 192.168.0.3:111: 27870(27.87%)
group 192.168.0.0:111: 27101(27.101%)
可以看到,請求分布負載不均衡,接下來我們引入虛擬節(jié)點:
public class ConsistentHashingWithVirtualNode {
/**
* 集群地址列表
*/
private static String[] groups = {
"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
"192.168.0.3:111", "192.168.0.4:111"
};
/**
* 真實集群列表
*/
private static List<String> realGroups = new LinkedList<>();
/**
* 虛擬節(jié)點映射關(guān)系
*/
private static SortedMap<Integer, String> virtualNodes = new TreeMap<>();
private static final int VIRTUAL_NODE_NUM = 1000;
static {
// 先添加真實節(jié)點列表
realGroups.addAll(Arrays.asList(groups));
// 將虛擬節(jié)點映射到Hash環(huán)上
for (String realGroup: realGroups) {
for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
String virtualNodeName = getVirtualNodeName(realGroup, i);
int hash = HashUtil.getHash(virtualNodeName);
System.out.println("[" + virtualNodeName + "] launched @ " + hash);
virtualNodes.put(hash, virtualNodeName);
}
}
}
private static String getVirtualNodeName(String realName, int num) {
return realName + "&&VN" + String.valueOf(num);
}
private static String getRealNodeName(String virtualName) {
return virtualName.split("&&")[0];
}
private static String getServer(String key) {
int hash = HashUtil.getHash(key);
// 只取出所有大于該hash值的部分而不必遍歷整個Tree
SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash);
String virtualNodeName;
if (subMap == null || subMap.isEmpty()) {
// hash值在最尾部,應該映射到第一個group上
virtualNodeName = virtualNodes.get(virtualNodes.firstKey());
}else {
virtualNodeName = subMap.get(subMap.firstKey());
}
return getRealNodeName(virtualNodeName);
}
public static void main(String[] args) {
// 生成隨機數(shù)進行測試
Map<String, Integer> resMap = new HashMap<>();
for (int i = 0; i < 100000; i++) {
Integer widgetId = i;
String group = getServer(widgetId.toString());
if (resMap.containsKey(group)) {
resMap.put(group, resMap.get(group) + 1);
} else {
resMap.put(group, 1);
}
}
resMap.forEach(
(k, v) -> {
System.out.println("group " + k + ": " + v + "(" + v/100000.0D +"%)");
}
);
}
}
在上面的代碼中,我給每個節(jié)點增加了1000個虛擬節(jié)點,測試結(jié)果如下:
group 192.168.0.2:111: 18354(18.354%)
group 192.168.0.1:111: 20062(20.062%)
group 192.168.0.4:111: 20749(20.749%)
group 192.168.0.3:111: 20116(20.116%)
group 192.168.0.0:111: 20719(20.719%)
可以看到負載已經(jīng)基本均衡了。我們試一下刪除和新增節(jié)點的情況:
private static void refreshHashCircle() {
// 當集群變動時,刷新hash環(huán),其余的集群在hash環(huán)上的位置不會發(fā)生變動
virtualNodes.clear();
for (String realGroup: realGroups) {
for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
String virtualNodeName = getVirtualNodeName(realGroup, i);
int hash = HashUtil.getHash(virtualNodeName);
System.out.println("[" + virtualNodeName + "] launched @ " + hash);
virtualNodes.put(hash, virtualNodeName);
}
}
}
private static void addGroup(String identifier) {
realGroups.add(identifier);
refreshHashCircle();
}
private static void removeGroup(String identifier) {
int i = 0;
for (String group:realGroups) {
if (group.equals(identifier)) {
realGroups.remove(i);
}
i++;
}
refreshHashCircle();
}
測試結(jié)果如下:
running the normal test.
group 192.168.0.2:111: 19144(19.144%)
group 192.168.0.1:111: 20244(20.244%)
group 192.168.0.4:111: 20923(20.923%)
group 192.168.0.3:111: 19811(19.811%)
group 192.168.0.0:111: 19878(19.878%)
removed a group, run test again.
group 192.168.0.2:111: 23409(23.409%)
group 192.168.0.1:111: 25628(25.628%)
group 192.168.0.4:111: 25583(25.583%)
group 192.168.0.0:111: 25380(25.38%)
add a group, run test again.
group 192.168.0.2:111: 15524(15.524%)
group 192.168.0.7:111: 16928(16.928%)
group 192.168.0.1:111: 16888(16.888%)
group 192.168.0.4:111: 16965(16.965%)
group 192.168.0.3:111: 16768(16.768%)
group 192.168.0.0:111: 16927(16.927%)
我們可以看到刪除和新增節(jié)點,一致性hash算法依然能保持良好的負載均衡。
redis中的一致性hash算法
未完,待續(xù)。。。。。睡覺!


