前言
一致性哈希算法在分布式系统的应用中是十分广泛的。常见的应用场景是分布式缓存。它主要解决了哈希取模算法在分布式系统中存在的动态伸缩等问题。
哈希取模算法的局限性
在分布式缓存集群中,当新增加缓存服务器或其中一台挂掉后,由路由算法发生改变,导致大量的缓存数据不能命中。从而造成数据库面临巨大压力而崩溃,可能导致整个系统崩溃。
一致性哈希算法原理
一致性哈希算法通过一个叫作一致性哈希环的数据结构实现。这个环的起点是,终点是,并且起点和终点相连接,故这个环的整数分布范围是。
将服务器节点和key放到哈希环上
我们将服务器节点和key的hash值放置到哈希环上,如下图:
服务器节点分别是NODE0、NODE1、NODE2。key分别代表key1 ~ key8。
将key和服务器节点都放置到同一个哈希环后,在哈希环上顺时针查找距离这个 key 的 hash 值最近的机器,即是这个key所属的机器。
key1、key8在节点NODE0上;key2、key5在节点NODE1上;key3、key4、key6在节点NODE2上。
增加服务器(扩容)
由于业务需要,如缓存集群压力过大,我们需要增加一台服务器(NODE3)。经过hash函数计算,NODE3节点落在NODE1和NODE2之间。如下图:
对上述情况,只有NODE1和NODE2节点之间的key需要重新分配。key4没有变,还在NODE2节点。只是key3、key6重新分配新的节点NODE3上。我们发现,一致性哈希算法只需要很少部分key需要重新分配。而哈希取模方式则大部分缓存会失效。
减少服务器(缩容)
由于某个服务器出现故障导致下线,如NODE3下线,这时原本key3、key6存储在NODE3上,需要重新被分配至NODE2节点上,其它key不受此影响。
服务器节点分布不均匀
- 如果服务器节点不是均匀的分布在哈希环上,那么有可能造成服务节点负载压力不均衡。
- 当新增加一台服务器时,节点NODE3只是分担了节点NODE2的压力,造成服务器压力不均摊。显然这个结果不是我们期望的。
针对这个问题,我们可以通过引入虚拟节点来解决负载不均衡的问题。即将每台物理服务器虚拟为一组虚拟服务器,将虚拟服务器放置到哈希环上,如果要确定key所在的服务器,需先确定key所在的虚拟服务器,再由虚拟服务器确定物理服务器。
基于虚拟节点的一致性哈希
一台物理服务器,虚拟成若干个虚拟节点,随机分布在环上,压力近似均衡分摊。如有三台物理服务器,每台物理服务器虚拟出150个虚拟节点,随机分配在Hash环上的150个位置上。最后可使三台物理服务器近似均摊压力。当增加一台服务器时,先虚拟150个节点,然后散落在哈希环上。
一致性哈希算法实现
Java代码:
public class ConsistentHashing {
private SortedMap hashCircle = new TreeMap<>();
private int virtualNums; // 虚拟节点数
public ConsistentHashing(Node[] nodes, int virtualNums) {
this.virtualNums = virtualNums;
// 初始化一致性hash环
for (Node node : nodes) {
// 创建虚拟节点
add(node);
}
}
/**
* 添加服务器节点
*
* @param node the server
*/
public void add(Node node) {
for (int i = 0; i < virtualNums; i++) {
hashCircle.put(hash(node.toString() + i), node);
}
}
/**
* 删除服务器节点
*
* @param node the server
*/
public void remove(Node node) {
for (int i = 0; i < virtualNums; i++) {
hashCircle.remove(hash(node.toString() + i));
}
}
/**
* 获取服务器节点
*
* @param key the key
* @return the server
*/
public Node getNode(String key) {
if (key == null || hashCircle.isEmpty())
return null;
int hash = hash(key);
if (!hashCircle.containsKey(hash)) {
// 未命中对应的节点
SortedMap tailMap = hashCircle.tailMap(hash);
hash = tailMap.isEmpty() ? hashCircle.firstKey() : tailMap.firstKey();
}
return hashCircle.get(hash);
}
/**
* FNV1_32_HASH算法
*
* @param key the key
* @return
*/
private int hash(String key) {
final int p = 16777619;
int hash = (int) 2166136261L;
for (int i = 0; i < key.length(); i++) {
hash = (hash ^ key.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;
}
/**
* 集群节点的机器地址
*/
public static class Node {
private String ipAddr;
private int port;
private String name;
public Node(String ipAddr, int port, String name) {
this.ipAddr = ipAddr;
this.port = port;
this.name = name;
}
@Override
public String toString() {
return name + ":<" + ipAddr + ":" + port + ">";
}
}
}
评估服务器节点的负载均衡性
我们通过方差计算服务器节点的均衡性,代码如下:
public class ConsistentHashingTest {
public static void main(String[] args) {
ConsistentHashing.Node[] nodes = new ConsistentHashing.Node[4];
Map> map = new HashMap<>();
// make nodes 4台服务器节点
for (int i = 0; i < nodes.length; i++) {
nodes[i] = new ConsistentHashing.Node("10.1.32.2" + i, 8070, "myNode" + i);
}
ConsistentHashing ch = new ConsistentHashing(nodes, 160);
// make keys 100万个key
String[] keys = new String[1_000_000];
for (int i = 0; i < keys.length; i++) {
keys[i] = "key" + (i + 17) + "ss" + (i * 19);
}
// make results
for (String key : keys) {
ConsistentHashing.Node n = ch.getNode(key);
List list = map.computeIfAbsent(n, k -> new ArrayList<>());
list.add(key);
}
// 统计标准差,评估服务器节点的负载均衡性
int[] loads = new int[nodes.length];
int x = 0;
for (Iterator i = map.keySet().iterator(); i.hasNext(); ) {
ConsistentHashing.Node key = i.next();
List list = map.get(key);
loads[x++] = list.size();
}
int min = Integer.MAX_VALUE;
int max = 0;
for (int load : loads) {
min = Math.min(min, load);
max = Math.max(max, load);
}
System.out.println("最小值: " + min + "; 最大值: " + max);
System.out.println("方差:" + variance(loads));
}
public static double variance(int[] data) {
double variance = 0;
double expect = (double) sum(data) / data.length;
for (double datum : data) {
variance += (Math.pow(datum - expect, 2));
}
variance /= data.length;
return Math.sqrt(variance);
}
private static int sum(int[] data) {
int sum = 0;
for (int i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
}
测试结果:
# 虚拟节点是 120
最小值: 243919; 最大值: 253236
方差:3692.7378054771234
# 虚拟节点是 130
最小值: 240190; 最大值: 257384
方差:7432.346466628153
# 虚拟节点是 150
最小值: 233002; 最大值: 260369
方差:10227.744937179456
# 虚拟节点是 160
最小值: 241154; 最大值: 253743
方差:5150.429156876153
# 虚拟节点是 170
最小值: 235938; 最大值: 260044
方差:9244.906895150432
# 虚拟节点是 200
最小值: 233187; 最大值: 263222
方差:11395.83342717855
通过测试,每台物理服务的虚拟节点在120到200之间,均衡性更好。
总结
一致性hash算法解决了分布式环境下机器增加或者减少时,简单的取模运算无法获取较高命中率的问题。通过虚拟节点的使用,一致性hash算法可以均匀分担机器的负载,使得这一算法更具现实的意义。正因如此,一致性hash算法被广泛应用于分布式系统中。
参考资源
- 维基百科
- InfoQ - 一致性 hash 算法