图解一致性哈希算法

要了解一致性哈希,首先咱们必须了解传统的哈希及其在大规模分布式系统中的局限性。简单地说,哈希就是一个键值对存储,在给定键的状况下,能够很是高效地找到所关联的值。假设咱们要根据其邮政编码查找城市中的街道名称。一种最简单的实现方式是将此信息以哈希字典的形式进行存储 <Zip Code,Street Name>java

当数据太大而没法存储在一个节点或机器上时,问题变得更加有趣,系统中须要多个这样的节点或机器来存储它。好比,使用多个 Web 缓存中间件的系统。那如何肯定哪一个 key 存储在哪一个节点上?针对该问题,最简单的解决方案是使用哈希取模来肯定。 给定一个 key,先对 key 进行哈希运算,将其除以系统中的节点数,而后将该 key 放入该节点。一样,在获取 key 时,对 key 进行哈希运算,再除以节点数,而后转到该节点并获取值。上述过程对应的哈希算法定义以下:node

node_number = hash(key) % N # 其中 N 为节点数。

下图描绘了多节点系统中的传统的哈希取模算法,基于该算法能够实现简单的负载均衡。面试

traditional-hashing.png

阅读更多关于 Angular、TypeScript、Node.js/Java 、Spring 等技术文章,欢迎访问个人我的博客 ——全栈修仙之路

1、传统哈希取模算法的局限性

下面咱们来分析一下传统的哈希及其在大规模分布式系统中的局限性。这里咱们直接使用我以前所写文章 布隆过滤器你值得拥有的开发利器 中定义的 SimpleHash 类,而后分别对 semlinker、kakuqo 和 test 3 个键进行哈希运算并取余,具体代码以下:算法

public class SimpleHash {
    private int cap;
    private int seed;

    public SimpleHash(int cap, int seed) {
        this.cap = cap;
        this.seed = seed;
    }

    public int hash(String value) {
        int result = 0;
        int len = value.length();
        for (int i = 0; i < len; i++) {
            result = seed * result + value.charAt(i);
        }
        return (cap - 1) & result;
    }

    public static void main(String[] args) {
        SimpleHash simpleHash = new SimpleHash(2 << 12, 8);
        System.out.println("node_number=hash(\"semlinker\") % 3 -> " + 
          simpleHash.hash("semlinker") % 3);
        System.out.println("node_number=hash(\"kakuqo\") % 3 -> " + 
          simpleHash.hash("kakuqo") % 3);
        System.out.println("node_number=hash(\"test\") % 3 -> " + 
          simpleHash.hash("test") % 3);
    }
}

以上代码成功运行后,在控制台会输出如下结果:shell

node_number=hash("semlinker") % 3 -> 1
node_number=hash("kakuqo") % 3 -> 2
node_number=hash("test") % 3 -> 0

基于以上的输出结果,咱们能够建立如下表格:segmentfault

ch-three-nodes-hash.jpg

1.1 节点减小的场景

在分布式多节点系统中,出现故障很常见。任何节点均可能在没有任何事先通知的状况下挂掉,针对这种状况咱们指望系统只是出现性能下降,正常的功能不会受到影响。 对于原始示例,当节点出现故障时会发生什么?原始示例中有的 3 个节点,假设其中 1 个节点出现故障,这时节点数发生了变化,节点个数从 3 减小为 2,此时表格的状态发生了变化:缓存

ch-two-nodes-hash.jpg

很明显节点的减小会致使键与节点的映射关系发生变化,这个变化对于新的键来讲并不会产生任何影响,但对于已有的键来讲,将致使节点映射错误,以 “semlinker” 为例,变化前系统有 3 个节点,该键对应的节点编号为 1,当出现故障时,节点数减小为 2 个,此时该键对应的节点编号为 0。服务器

1.2 节点增长的场景

在分布式多节点系统中,对于某些场景好比节日大促,就须要对服务节点进行扩容,以应对突发的流量。 对于原始示例,当增长节点会发生什么?原始示例中有的 3 个节点,假设进行扩容临时增长了 1 个节点,这时节点数发生了变化,节点个数从 3 增长为 4 个,此时表格的状态发生了变化:数据结构

ch-four-nodes-hash.jpg

很明显节点的增长也会致使键与节点的映射关系发生变化,这个变化对于新的键来讲并不会产生任何影响,但对于已有的键来讲,将致使节点映射错误,一样以 “semlinker” 为例,变化前系统有 3 个节点,该键对应的节点编号为 1,当增长节点时,节点数增长为 4 个,此时该键对应的节点编号为 2。负载均衡

当集群中节点的数量发生变化时,以前的映射规则就可能发生变化。若是集群中每一个机器提供的服务没有差异,这不会有什么影响。但对于分布式缓存这种的系统而言,映射规则失效就意味着以前缓存的失效,若同一时刻出现大量的缓存失效,则可能会出现 “缓存雪崩”,这将会形成灾难性的后果。

要解决此问题,咱们必须在其他节点上从新分配全部现有键,这多是很是昂贵的操做,而且可能对正在运行的系统产生不利影响。固然除了从新分配全部现有键的方案以外,还有另外一种更好的方案即便用一致性哈希算法。

2、一致性哈希算法

一致性哈希算法在 1997 年由麻省理工学院提出,是一种特殊的哈希算法,在移除或者添加一个服务器时,可以尽量小地改变已存在的服务请求与处理请求服务器之间的映射关系。一致性哈希解决了简单哈希算法在分布式哈希表(Distributed Hash Table,DHT)中存在的动态伸缩等问题 。

2.1 一致性哈希算法优势

  • 可扩展性。一致性哈希算法保证了增长或减小服务器时,数据存储的改变最少,相比传统哈希算法大大节省了数据移动的开销 。
  • 更好地适应数据的快速增加。采用一致性哈希算法分布数据,当数据不断增加时,部分虚拟节点中可能包含不少数据、形成数据在虚拟节点上分布不均衡,此时能够将包含数据多的虚拟节点分裂,这种分裂仅仅是将原有的虚拟节点一分为2、不须要对所有的数据进行从新哈希和划分。

    虚拟节点分裂后,若是物理服务器的负载仍然不均衡,只需在服务器之间调整部分虚拟节点的存储分布。这样能够随数据的增加而动态的扩展物理服务器的数量,且代价远比传统哈希算法从新分布全部数据要小不少。

2.2 一致性哈希算法与哈希算法的关系

一致性哈希算法是在哈希算法基础上提出的,在动态变化的分布式环境中,哈希算法应该知足的几个条件:平衡性、单调性和分散性。

  • 平衡性:是指 hash 的结果应该平均分配到各个节点,这样从算法上解决了负载均衡问题。
  • 单调性:是指在新增或者删减节点时,不影响系统正常运行。
  • 分散性:是指数据应该分散地存放在分布式集群中的各个节点(节点本身能够有备份),没必要每一个节点都存储全部的数据。

3、一致性哈希算法原理

一致性哈希算法经过一个叫做一致性哈希环的数据结构实现。这个环的起点是 0,终点是 2^32 - 1,而且起点与终点链接,故这个环的整数分布范围是 [0, 2^32-1],以下图所示:

hash-ring.jpg

3.1 将对象放置到哈希环

假设咱们有 "semlinker"、"kakuqo"、"lolo"、"fer" 四个对象,分别简写为 o一、o二、o3 和 o4,而后使用哈希函数计算这个对象的 hash 值,值的范围是 [0, 2^32-1]:

hash-ring-hash-objects.jpg

图中对象的映射关系以下:

hash(o1) = k1; hash(o2) = k2;
hash(o3) = k3; hash(o4) = k4;

3.2 将服务器放置到哈希环

接着使用一样的哈希函数,咱们将服务器也放置到哈希环上,能够选择服务器的 IP 或主机名做为键进行哈希,这样每台服务器就能肯定其在哈希环上的位置。这里假设咱们有 3 台缓存服务器,分别为 cs一、cs2 和 cs3:

hash-ring-hash-servers.jpg

图中服务器的映射关系以下:

hash(cs1) = t1; hash(cs2) = t2; hash(cs3) = t3; # Cache Server

3.3 为对象选择服务器

将对象和服务器都放置到同一个哈希环后,在哈希环上顺时针查找距离这个对象的 hash 值最近的机器,便是这个对象所属的机器。 以 o2 对象为例,顺序针找到最近的机器是 cs2,故服务器 cs2 会缓存 o2 对象。而服务器 cs1 则缓存 o1,o3 对象,服务器 cs3 则缓存 o4 对象。

hash-ring-objects-servers.jpg

3.4 服务器增长的状况

假设因为业务须要,咱们须要增长一台服务器 cs4,通过一样的 hash 运算,该服务器最终落于 t1 和 t2 服务器之间,具体以下图所示:

hash-ring-add-server.jpg

对于上述的状况,只有 t1 和 t2 服务器之间的对象须要从新分配。在以上示例中只有 o3 对象须要从新分配,即它被从新到 cs4 服务器。在前面咱们已经分析过,若是使用简单的取模方法,当新添加服务器时可能会致使大部分缓存失效,而使用一致性哈希算法后,这种状况获得了较大的改善,由于只有少部分对象须要从新分配。

3.5 服务器减小的状况

假设 cs3 服务器出现故障致使服务下线,这时本来存储于 cs3 服务器的对象 o4,须要被从新分配至 cs2 服务器,其它对象仍存储在原有的机器上。

hash-ring-remove-server.jpg

3.6 虚拟节点

到这里一致性哈希的基本原理已经介绍完了,但对于新增服务器的状况还存在一些问题。新增的服务器 cs4 只分担了 cs1 服务器的负载,服务器 cs2 和 cs3 并无由于 cs4 服务器的加入而减小负载压力。若是 cs4 服务器的性能与原有服务器的性能一致甚至可能更高,那么这种结果并非咱们所指望的。

针对这个问题,咱们能够经过引入虚拟节点来解决负载不均衡的问题。即将每台物理服务器虚拟为一组虚拟服务器,将虚拟服务器放置到哈希环上,若是要肯定对象的服务器,需先肯定对象的虚拟服务器,再由虚拟服务器肯定物理服务器。

ch-virtual-nodes.jpg

图中 o1 和 o2 表示对象,v1 ~ v6 表示虚拟服务器,s1 ~ s3 表示物理服务器。

4、一致性哈希算法实现

这里咱们只介绍不带虚拟节点的一致性哈希算法实现:

import java.util.SortedMap;
import java.util.TreeMap;

public class ConsistentHashingWithoutVirtualNode {
    //待添加入Hash环的服务器列表
    private static String[] servers = {"192.168.0.1:8888", "192.168.0.2:8888", 
      "192.168.0.3:8888"};

    //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]);
        }
    }

    //获得应当路由到的结点
    private static String getServer(String key) {
        //获得该key的hash值
        int hash = getHash(key);
        //获得大于该Hash值的全部Map
        SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
        if (subMap.isEmpty()) {
            //若是没有比该key的hash值大的,则从第一个node开始
            Integer i = sortedMap.firstKey();
            //返回对应的服务器
            return sortedMap.get(i);
        } else {
            //第一个Key就是顺时针过去离node最近的那个结点
            Integer i = subMap.firstKey();
            //返回对应的服务器
            return subMap.get(i);
        }
    }

    //使用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;
    }

    public static void main(String[] args) {
        String[] keys = {"semlinker", "kakuqo", "fer"};
        for (int i = 0; i < keys.length; i++)
            System.out.println("[" + keys[i] + "]的hash值为" + getHash(keys[i])
                    + ", 被路由到结点[" + getServer(keys[i]) + "]");
    }

}

以上代码成功运行后,在控制台会输出如下结果:

[192.168.0.1:8888]加入集合中, 其Hash值为1326271016
[192.168.0.2:8888]加入集合中, 其Hash值为1132535844
[192.168.0.3:8888]加入集合中, 其Hash值为115798597

[semlinker]的hash值为1549041406, 被路由到结点[192.168.0.3:8888]
[kakuqo]的hash值为463104755, 被路由到结点[192.168.0.2:8888]
[fer]的hash值为1677150790, 被路由到结点[192.168.0.3:8888]

上面咱们只介绍了不带虚拟节点的一致性哈希算法实现,若是有的小伙伴对带虚拟节点的一致性哈希算法感兴趣,能够参考 一致性Hash(Consistent Hashing)原理剖析及Java实现 这篇文章。

5、总结

本文经过示例介绍了传统的哈希取模算法在分布式系统中的局限性,进而在针对该问题的解决方案中引出了一致性哈希算法。一致性哈希算法在 1997 年由麻省理工学院提出,是一种特殊的哈希算法,在移除或者添加一个服务器时,可以尽量小地改变已存在的服务请求与处理请求服务器之间的映射关系。在介绍完一致性哈希算法的做用和优势等相关知识后,咱们以图解的形式生动介绍了一致性哈希算法的原理,最后给出了不带虚拟节点的一致性哈希算法的 Java 实现。

6、参考资源

本人的全栈修仙之路订阅号,会按期分享 Angular、TypeScript、Node.js/Java 、Spring 相关文章,欢迎感兴趣的小伙伴订阅哈!

full-stack-logo

相关文章
相关标签/搜索
本站公众号
   欢迎关注本站公众号,获取更多信息