参考资料:
写在开头:本文为学习后的总结,可能有不到位的地方,错误的地方,欢迎各位指正。
目录
一、传统Hash
1、传统Hash是如何工作的
假设当前服务集群中存在 3 个节点:Node-A,Node-B, Node-C;而客户端存在 Key1,Key2,Key3 需要映射到对应的服务节点。传统 hash 算法思路:
- 先计算 key 对应的 hash 值
- 将 hash 值和服务节点的数量取模,算出对应节点的下标,即 Hash(Key) % NodeSize
假设现在分别有key1、key2、key3可以映射到三个节点上,正如下图这样。(在之前的文章中Redis分片(《Redis:集群(分片)》)的时候,我们介绍了Redis Cluster利用Hash运算计算每个key位于哪个哈希槽上,正是这样的方式)
2、可能产生的问题
上一节我们介绍了正常情况下的节点分配,但如果此时有一个服务器宕机了,会发生什么呢?正如下图这样:
- 如果 Node-C 节点宕机了,Hash(Key) % NodeSize 公式的取模对象发生变化,最终可能导致 Key1,Key2 的映射到的服务节点都发生变化(Key3 肯定会改变)。
- 原本 Key1 映射到 Node-A 变为映射到 Node-B,因为数据之前存储在 Node-A,则导致 Key1 无法正常命中数据;Key2,Key3 … KeyN 都可能出现这种情况。
传统 hash算法的局限性主要体现在:
- 节点数量发生变化,导致 key -> 节点的映射关系发生变化,最终导致数据存储服务不可用(之前存储的 key 无法正常命中数据)。
- 节点数量发生变化,整体数据 Rehash 的成本较高。
而一致性 hash 算法则是将这种因节点数量变化所需要花费的调整成本,降至最低。
二、一致性Hash
1、整体思路
将key值哈希到 [0, 2^32) 的一个数字空间中,我们假设这个是个首尾连接的环形空间,如下图:
假设我们现在有key1,key2,key3,key4 4个key值,我们通过一定的hash算法,将其对应到上面的环形hash空间中。
k1=hash(key1);
k2=hash(key2);
k3=hash(key3);
k4=hash(key4);
同样的,假设我们有3台cache服务器,把缓存服务器通过hash算法,加入到上述的环中。一般情况下是根据机器的IP地址或者唯一的计算机别名进行哈希。
c1=hash(cache1);
c2=hash(cache2);
c3=hash(cache3);
接下来就是数据如何存储到cache服务器上了,key值哈希之后的结果顺时针找上述环形hash空间中,距离自己最近的机器节点,然后将数据存储到上面, 如上图所示,k1 存储到 c3 服务器上, k4,k3存储到c1服务器上, k2存储在c2服务器上。用图表示如下:
总结一下,一致性Hash一共三步:
(1)一致性哈希算法将整个哈希值空间按照顺时针方向组织成一个虚拟的圆环,称为 Hash 环;
(2)接着将各个服务器使用 Hash 函数进行哈希,具体可以选择服务器的IP或主机名作为关键字进行哈希,从而确定每台机器在哈希环上的位置
(3)最后使用算法定位数据访问到相应服务器:将数据key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针寻找,第一台遇到的服务器就是其应该定位到的服务器
2、节点变动如何应对
增加节点
新增C4节点之后,原先存储到C1的k4,迁移到了C4,分担了C1上的存储压力和流量压力。
删除节点
假设cache3服务器宕机,这时候需要从集群中将其摘除。那么,之前存储再c3上的k1,将会顺时针寻找距离它最近的一个节点,也就是c1节点,这样,k1就会存储到c1上了,看一看下下面的图,比较清晰。
3、Hash环偏斜
3.1、问题描述
上面的简单的一致性hash的方案在某些情况下但依旧存在问题。
(1)首先在节点较少的情况下数据的分布可能是不平衡的情况。如下图,可以发现C至A之间的节点明显偏多,这将导致A节点的负载变得很大。
(2)一个节点宕机之后,数据需要落到距离他最近的节点上,会导致下个节点的压力突然增大,可能导致雪崩,整个服务挂掉。如下图,当C3节点宕机后,将导致较多数据偏移至C1节点。
3.2、解决方案
我们通过引入虚拟节点,是节点最大程度的分布均匀,解决数据倾斜的问题。
“虚拟节点”( virtual node )是实际节点(机器)在 hash 空间的复制品( replica ),一实际个节点(机器)对应了若干个“虚拟节点”,这个对应个数也成为“复制个数”,“虚拟节点”在 hash 空间中以hash值排列。
假设存在以下的真实节点和虚拟节点的对应关系:
Visual100—> Real1
Visual101—> Real1
Visual200—> Real2
Visual201—> Real2
Visual300—> Real3
Visual301—> Real3
同样的,hash之后的结果如下:
hash(Visual100)—> V100 —> Real1
hash(Visual101)—> V101 —> Real1
hash(Visual200)—> V200 —> Real2
hash(Visual201)—> V201 —> Real2
hash(Visual300)—> V300 —> Real3
hash(Visual301)—> V301 —> Real3
这样就成功的将所有数据较为均匀的分不到了所有的节点上,即使发生了某个节点宕机的情况,也不会对下游节点突然造成巨大负载。
三、一致性Hash的实现
以下内容参考自《对一致性Hash算法,Java代码实现的深入研究》
1、不带虚拟节点版本
这里使用FNV1_32_HASH来进行Hash运算,这种算法的特点是:能快速hash大量数据并保持较小的冲突率。它的高度分散使它适用于hash一些非常相近的字符串,比如URL,hostname,文件名,text,IP地址等。
/**
* 不带虚拟节点的一致性Hash算法
* @author 五月的仓颉http://www.cnblogs.com/xrq730/
*
*/
public class ConsistentHashingWithoutVirtualNode
{
/**
* 待添加入Hash环的服务器列表
*/
private static String[] servers = {"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"};
/**
* key表示服务器的hash值,value表示服务器的名称
*/
private static SortedMap<Integer, String> sortedMap =
new TreeMap<Integer, String>();
/**
* 程序初始化,将所有的服务器放入sortedMap中
*/
static
{
for (int i = 0; i < servers.length; i++)
{
int hash = getHash(servers[i]);
System.out.println("[" + servers[i] + "]加入集合中, 其Hash值为" + hash);
sortedMap.put(hash, servers[i]);
}
System.out.println();
}
/**
* 使用FNV1_32_HASH算法计算服务器的Hash值
*/
private 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;
}
/**
* 得到应当路由到的结点
*/
private static String getServer(String node)
{
// 得到带路由的结点的Hash值
int hash = getHash(node);
// 得到大于该Hash值的所有Map
SortedMap<Integer, String> subMap =
sortedMap.tailMap(hash);
// 第一个Key就是顺时针过去离node最近的那个结点
Integer i = subMap.firstKey();
// 返回对应的服务器名称
return subMap.get(i);
}
public static void main(String[] args)
{
String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
for (int i = 0; i < nodes.length; i++)
System.out.println("[" + nodes[i] + "]的hash值为" +
getHash(nodes[i]) + ", 被路由到结点[" + getServer(nodes[i]) + "]");
}
}
2、带虚拟节点版本
编程方面需要考虑的问题是:
- 一个真实结点如何对应成为多个虚拟节点
- 虚拟节点找到后如何还原为真实结点
这两个问题其实有很多解决办法,这里使用了一种简单的办法,给每个真实结点后面根据虚拟节点加上后缀再取Hash值,比如"192.168.0.0:111"就把它变成"192.168.0.0:111&&VN0"到"192.168.0.0:111&&VN4",VN就是Virtual Node的缩写,还原的时候只需要从头截取字符串到"&&"的位置就可以了。
/**
* 带虚拟节点的一致性Hash算法
* @author 五月的仓颉 http://www.cnblogs.com/xrq730/
*/
public class ConsistentHashingWithVirtualNode
{
/**
* 待添加入Hash环的服务器列表
*/
private static String[] servers = {"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"};
/**
* 真实结点列表,考虑到服务器上线、下线的场景,即添加、删除的场景会比较频繁,这里使用LinkedList会更好
*/
private static List<String> realNodes = new LinkedList<String>();
/**
* 虚拟节点,key表示虚拟节点的hash值,value表示虚拟节点的名称
*/
private static SortedMap<Integer, String> virtualNodes =
new TreeMap<Integer, String>();
/**
* 虚拟节点的数目,这里写死,为了演示需要,一个真实结点对应5个虚拟节点
*/
private static final int VIRTUAL_NODES = 5;
static
{
// 先把原始的服务器添加到真实结点列表中
for (int i = 0; i < servers.length; i++)
realNodes.add(servers[i]);
// 再添加虚拟节点,遍历LinkedList使用foreach循环效率会比较高
for (String str : realNodes)
{
for (int i = 0; i < VIRTUAL_NODES; i++)
{
String virtualNodeName = str + "&&VN" + String.valueOf(i);
int hash = getHash(virtualNodeName);
System.out.println("虚拟节点[" + virtualNodeName + "]被添加, hash值为" + hash);
virtualNodes.put(hash, virtualNodeName);
}
}
System.out.println();
}
/**
* 使用FNV1_32_HASH算法计算服务器的Hash值,这里不使用重写hashCode的方法,最终效果没区别
*/
private 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;
}
/**
* 得到应当路由到的结点
*/
private static String getServer(String node)
{
// 得到带路由的结点的Hash值
int hash = getHash(node);
// 得到大于该Hash值的所有Map
SortedMap<Integer, String> subMap =
virtualNodes.tailMap(hash);
// 第一个Key就是顺时针过去离node最近的那个结点
Integer i = subMap.firstKey();
// 返回对应的虚拟节点名称,这里字符串稍微截取一下
String virtualNode = subMap.get(i);
return virtualNode.substring(0, virtualNode.indexOf("&&"));
}
public static void main(String[] args)
{
String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
for (int i = 0; i < nodes.length; i++)
System.out.println("[" + nodes[i] + "]的hash值为" +
getHash(nodes[i]) + ", 被路由到结点[" + getServer(nodes[i]) + "]");
}
}