Hash一致性算法(分片机制)

一 哈希简介
1.1 简介
我们首先来简单介绍一下什么是哈希(以下简称hash),hash本质来说就是映射,或者说是键值对key-value,不同的hash之间不过就是实现key-value映射的算法不同,例如java中计算对象的hashcode值会有不同的算法,常用于各种分布式存储分片的id取模算法等,都属于hash算法。
分布式系统中,假设有 n 个节点,传统方案使用 mod(key, n) 映射数据和节点。
当扩容或缩容时(哪怕只是增减1个节点),映射关系变为 mod(key, n+1) / mod(key, n-1),绝大多数数据的映射关系都会失效。

1.2算法原理:
映射方案
在这里插入图片描述
1.2.1公用哈希函数和哈希环
设计哈希函数 Hash(key),要求取值范围为 [0, 2^32)
各哈希值在上图 Hash 环上的分布:时钟12点位置为0,按顺时针方向递增,临近12点的左侧位置为2^32-1。

1.2.2 节点(Node)映射至哈希环
如图哈希环上的绿球所示,四个节点 Node A/B/C/D,
其 IP 地址或机器名,经过同一个 Hash() 计算的结果,映射到哈希环上。

1.2.3 对象(Object)映射于哈希环
如图哈希环上的黄球所示,四个对象 Object A/B/C/D,
其键值,经过同一个 Hash() 计算的结果,映射到哈希环上。

1.2.4 对象(Object)映射至节点(Node)
在对象和节点都映射至同一个哈希环之后,要确定某个对象映射至哪个节点,
只需从该对象开始,沿着哈希环顺时针方向查找,找到的第一个节点,即是。
可见,Object A/B/C/D 分别映射至 Node A/B/C/D。
删除节点
现实场景:服务器缩容时删除节点,或者有节点宕机。如下图,要删除节点 Node C:
只会影响欲删除节点(Node C)与上一个(顺时针为前进方向)节点(Node B)与之间的对象,也就是 Object C,
这些对象的映射关系,按照 2.1.4 的规则,调整映射至欲删除节点的下一个节点 Node D。
其他对象的映射关系,都无需调整。

在这里插入图片描述
增加节点
现实场景:服务器扩容时增加节点。比如要在 Node B/C 之间增加节点 Node X:
只会影响欲新增节点(Node X)与上一个(顺时针为前进方向)节点(Node B)与之间的对象,也就是 Object C,
这些对象的映射关系,按照 2.1.4 的规则,调整映射至新增的节点 Node X。
其他对象的映射关系,都无需调整。
在这里插入图片描述
虚拟节点
对于前面的方案,节点数越少,越容易出现节点在哈希环上的分布不均匀,导致各节点映射的对象数量严重不均衡(数据倾斜);相反,节点数越多越密集,数据在哈希环上的分布就越均匀。
但实际部署的物理节点有限,我们可以用有限的物理节点,虚拟出足够多的虚拟节点(Virtual Node),最终达到数据在哈希环上均匀分布的效果:
如下图,实际只部署了2个节点 Node A/B,
每个节点都复制成3倍,结果看上去是部署了6个节点。
可以想象,当复制倍数为 2^32 时,就达到绝对的均匀,通常可取复制倍数为32或更高。
虚拟节点哈希值的计算方法调整为:对“节点的IP(或机器名)+虚拟节点的序号(1~N)”作哈希。
在这里插入图片描述 算法实现
一致性哈希算法有多种具体的实现,包括 Chord 算法,KAD 算法等,都比较复杂。
这里给出一个简易实现及其演示,可以看到一致性哈希的均衡性和单调性的优势。
单调性在本例中没有统计数据,但根据前面原理可知,增删节点后只有很少量的数据需要调整映射关系。

3.1 源码
public class ConsistentHashing {
// 物理节点
private Set physicalNodes = new TreeSet() {
{
add(“192.168.1.101”);
add(“192.168.1.102”);
add(“192.168.1.103”);
add(“192.168.1.104”);
}
};

//虚拟节点
private final int VIRTUAL_COPIES = 1048576; // 物理节点至虚拟节点的复制倍数
private TreeMap<Long, String> virtualNodes = new TreeMap<>(); // 哈希值 => 物理节点

// 32位的 Fowler-Noll-Vo 哈希算法
// https://en.wikipedia.org/wiki/Fowler–Noll–Vo_hash_function
private static Long FNVHash(String key) {
    final int p = 16777619;
    Long hash = 2166136261L;
    for (int idx = 0, num = key.length(); idx < num; ++idx) {
        hash = (hash ^ key.charAt(idx)) * 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 ConsistentHashing() {
    for (String nodeIp : physicalNodes) {
        addPhysicalNode(nodeIp);
    }
}

// 添加物理节点
public void addPhysicalNode(String nodeIp) {
    for (int idx = 0; idx < VIRTUAL_COPIES; ++idx) {
        long hash = FNVHash(nodeIp + "#" + idx);
        virtualNodes.put(hash, nodeIp);
    }
}

// 删除物理节点
public void removePhysicalNode(String nodeIp) {
    for (int idx = 0; idx < VIRTUAL_COPIES; ++idx) {
        long hash = FNVHash(nodeIp + "#" + idx);
        virtualNodes.remove(hash);
    }
}

// 查找对象映射的节点
public String getObjectNode(String object) {
    long hash = FNVHash(object);
    SortedMap<Long, String> tailMap = virtualNodes.tailMap(hash); // 所有大于 hash 的节点
    Long key = tailMap.isEmpty() ? virtualNodes.firstKey() : tailMap.firstKey();
    return virtualNodes.get(key);
}

// 统计对象与节点的映射关系
public void dumpObjectNodeMap(String label, int objectMin, int objectMax) {
    // 统计
    Map<String, Integer> objectNodeMap = new TreeMap<>(); // IP => COUNT
    for (int object = objectMin; object <= objectMax; ++object) {
        String nodeIp = getObjectNode(Integer.toString(object));
        Integer count = objectNodeMap.get(nodeIp);
        objectNodeMap.put(nodeIp, (count == null ? 0 : count + 1));
    }

    // 打印
    double totalCount = objectMax - objectMin + 1;
    System.out.println("======== " + label + " ========");
    for (Map.Entry<String, Integer> entry : objectNodeMap.entrySet()) {
        long percent = (int) (100 * entry.getValue() / totalCount);
        System.out.println("IP=" + entry.getKey() + ": RATE=" + percent + "%");
    }
}

public static void main(String[] args) {
    ConsistentHashing ch = new ConsistentHashing();

    // 初始情况
    ch.dumpObjectNodeMap("初始情况", 0, 65536);

    // 删除物理节点
    ch.removePhysicalNode("192.168.1.103");
    ch.dumpObjectNodeMap("删除物理节点", 0, 65536);

    // 添加物理节点
    ch.addPhysicalNode("192.168.1.108");
    ch.dumpObjectNodeMap("添加物理节点", 0, 65536);
}

}

3.2 复制倍数为 1 时的均衡性
修改代码中 VIRTUAL_COPIES = 1(相当于没有虚拟节点),运行结果如下(可见各节点负荷很不均衡):

======== 初始情况 ========
IP=192.168.1.101: RATE=45%
IP=192.168.1.102: RATE=3%
IP=192.168.1.103: RATE=28%
IP=192.168.1.104: RATE=22%
======== 删除物理节点 ========
IP=192.168.1.101: RATE=45%
IP=192.168.1.102: RATE=3%
IP=192.168.1.104: RATE=51%
======== 添加物理节点 ========
IP=192.168.1.101: RATE=45%
IP=192.168.1.102: RATE=3%
IP=192.168.1.104: RATE=32%
IP=192.168.1.108: RATE=18%
3.2 复制倍数为 32 时的均衡性
修改代码中 VIRTUAL_COPIES = 32,运行结果如下(可见各节点负荷比较均衡):

======== 初始情况 ========
IP=192.168.1.101: RATE=29%
IP=192.168.1.102: RATE=21%
IP=192.168.1.103: RATE=25%
IP=192.168.1.104: RATE=23%
======== 删除物理节点 ========
IP=192.168.1.101: RATE=39%
IP=192.168.1.102: RATE=37%
IP=192.168.1.104: RATE=23%
======== 添加物理节点 ========
IP=192.168.1.101: RATE=35%
IP=192.168.1.102: RATE=20%
IP=192.168.1.104: RATE=23%
IP=192.168.1.108: RATE=20%

3.2 复制倍数为 1M 时的均衡性
修改代码中 VIRTUAL_COPIES = 1048576,运行结果如下(可见各节点负荷非常均衡):

======== 初始情况 ========
IP=192.168.1.101: RATE=24%
IP=192.168.1.102: RATE=24%
IP=192.168.1.103: RATE=25%
IP=192.168.1.104: RATE=25%
======== 删除物理节点 ========
IP=192.168.1.101: RATE=33%
IP=192.168.1.102: RATE=33%
IP=192.168.1.104: RATE=33%
======== 添加物理节点 ========
IP=192.168.1.101: RATE=25%
IP=192.168.1.102: RATE=24%
IP=192.168.1.104: RATE=24%
IP=192.168.1.108: RATE=24%

二 面临的问题
一个算法的出现一定是为了解决某个问题或者是某类问题,理解算法解决了什么样的问题非常有助于我们理解算法本身,那么一致性哈希是为了解决什么样的问题呢?我们首先来看一下普通的hash算法会遇到什么样的问题,我们以id取模算法为例,这种算法经常被用到分布式存储的分片算法中:
在这里插入图片描述
如图所示,假如我们以id % 3作为分片条件,有1-20这些元素,这样这20个元素会按照与3取模的结果分布在0、1、2这三个片中,一切看起来都简单又和谐,但随着业务的发展,我们可能需要扩容,需要再加一个片,我们需要把算法换成id%4,这个时候会发生什么样的变化呢?
在这里插入图片描述
对比两个图,我们发现,扩容了一个分片之后,百分之七八十的的数据都发生了迁移,大规模的数据迁移就是这个算法的缺点所在。如果是我们示例中的这种小规模数据,可能影响还不是很大,但是在企业级应用中,可能需要操作的是十亿百亿规模的数据,这时候要迁移它们当中百分之七八十的数据,复杂度和危险性都是非常高的。

有一种方法能够减小数据迁移的规模,就是成倍扩容,例如示例中的3个片我们直接扩容成6个片,这样可以将数据迁移的规模减小到50%,如果读者阅读过HashMap的源码,会发现,HashMap在扩容时调用的resize方法就是将容量扩容为原来的2倍,笔者当时在阅读HashMap源码时就没搞懂为什么一定要扩容两倍,原因就是在这了,就是为了减少数据迁移的规模。

但是这种方式又会引入另外两个问题,一个是资源浪费,可能我们的业务发展和体量暂时不需要扩容一倍,所以直接扩容一倍之后会造成一定的资源浪费。另一个是成本问题,扩容意味着增加服务器,成倍扩容无疑意味着需要更多的服务器,成本还是很高的。这两个问题在大规模集群中尤为明显。
一致性哈希
下面我们来看一致性哈希是如何解决这些问题的,首先我们来看网上经常能看到的有关一致性哈希的一张图:
在这里插入图片描述
idmod = id % 100;
if (idmod >= 0 && idmod < 25) {
return db1;
} else if (idmod >= 25 && idmod < 50) {
return db2;
} else if (idmod >= 50 && idmod < 75) {
return db3;
} else {
return db4;
}
我们用id % 100的结果作为分片的依据,并将集群分为四个片,每个片对应一段区间,这时,假如我们发现db3对应的区间也就是idmod在50-75之间的数据发生了热点情况,我们需要对这个片进行扩容,那我们可以将算法改造成这样:

idmod = id % 100;
if (idmod >= 0 && idmod < 25) {
return db1;
} else if (idmod >= 25 && idmod < 50) {
return db2;
} else if (idmod >= 50 && idmod < 65) {
return db3;
} else if (idmod >= 65 && idmod < 75) {
return db5;
} else {
return db4;
}
我们扩容了一个分片db5,并将原来的热点分片db3中的一部分区间中的数据迁移到db5中,这样就可以在不影响其他分片的情况下完成数据迁移,扩容的节点数量也可以进行控制,这就是一致性哈希。

我们再回头看一下上面这样图,大概的意思就是这样,原本有四个节点node1-node4,图中粉色的点就是我们id取模之后的值落到这个环上的位置,这就是所谓的哈希环,然后扩容了一个蓝色的节点node5,扩容只会影响原来node2-node4之间的数据。

一致性哈希也同样有它的问题所在,我们上面提到,一致性哈希可以解决热点问题,那如果我们的数据分布的很均匀,没有热点问题,还是需要扩容,怎么办,按照上文的理解就需要为每个节点都扩容一个节点,这不又是成倍的扩容了么,又遇到了这个n -> 2n的问题,该怎么解决呢?

虚拟节点
我们来做这样一个映射,首先将id % 65536,这样可以得到0-65535这样一个区间,然后做一个这样的映射:

hash id node id
0 0
1 1
2 2
3 3
4 0
5 1
… …
65535 3
这个时候如果我们需要扩容节点,增加一个节点node id为4,我们只需要调整这张虚拟节点的映射表,随意的按照我们的需求来调整,比如我们可以将hash id为5、6、7的数据映射到node id为4的节点上,所以虚拟节点的关键就是我们要维护好这张映射表。这里id与多少取模选择了65536,实际应用中取多少合适呢?很显然,这个值越大,分布就会越均匀,我们可以调整的空间也越大,但是实现和维护的难度也会上升,所以实际应用中到底应该取什么值还是需要结合实际业务来做出权衡。

总结
无论是哪种算法,它们要解决的问题都是尽量的减少数据迁移的规模,还有就是减少扩容的成本,那是不是说我们就一定要选择虚拟节点的这种算法呢?恰恰相反,我们推荐尽量使用的简单的方法来解决问题,不要一开始就使用复杂的方式,这样很容易产生过度设计,虚拟节点的算法虽然可以解决n -> 2n和数据迁移规模的问题,但它的缺点就是比较复杂,实现复杂,维护也复杂,所以我们推荐应用一开始尽量优先选用id取模的算法也就是n -> 2n的方式进行扩容,当集群到达一定规模之后,我们可以做一张如上的虚拟节点映射表,将原来的取模算法平滑的切换为虚拟节点算法,对应用没有任何影响,然后再按照虚拟节点的方式进行扩容,这是我们最推荐的方式。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值