一致性hash算法(hash环)

一、引言

近期做的项目中正好用到了hash环实现了负载均衡:

使用hash环实现的负载均衡不同于nginx等实现的负载均衡,nginx负载均衡有轮训算法,同样的一个客户端,在多次请求时,每次访问的服务器节点可能都不一样,但是对于有些场景来说,肯定是不行的;

最经典的就是memcached,redis这类分布式kv缓存(当然我说的不是简单的一主两从三哨兵这样的简单部署),是真的会分布在不同的节点上的那种;

我们项目中是工业互联网项目,有设备连接,连接session维护,这些相当于每个节点都是有状态的,我设备明明连接在这台节点上,我们得很多信息也得在这个节点处理的,最优的方案肯定是我一下子就能定位到固定机器,而且每次请求到达,都能到同一个节点上去处理;

这里,我们就使用了一致性hash算法(hash环),找到对应的服务地址,然后使用 grpc 定向调用;


二、什么是一致性hash(hash环)——以经典的分布式缓存为例

1、首先,我们了解一下什么是“普通hash”?

普通hash:一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值;

这样举例就很好理解了:

把[1、3、7、8、14、16]变换到固定长度5的输出,那么就是我们使用mod取模算法,得到的输出结果就是:[1、3、2、3、4、1];

这就是最简单的hash算法,而其中1和3都出现了2次,这种就是“hash碰撞”;

Hash表(散列表):根据散列函数和冲突处理将一组关键字分配在连续的地址空间内,并以散列值记录在表中的存储位置,这种表成为散列表。

2、使用普通hash在分布式系统缓存数据?

hash算法被普遍用在很多地方,但是普通hash用在分布式环境中会有一些问题,假设有5个cache主机:cacheA、cacheB、cacheC、cacheD、cacheE

当程序进行hash时,首先每个节点要根据自己的唯一参数哈希出一个值来(如根据ip进行哈希)

    主机哈希完成后形成的哈希值如下(使用最简单的hash公式:ip/N,N为机器总数)

  •     cacheA       0

  •     cacheB       1

  •     cacheC       2

  •     cacheD       3

  •     cacheE       4

现在我们有了需要缓存的数据(key,value),我们为了让这组数据肯定落在这5台机器上,我们对key进行hash运算(hash(key)/N),这里只能是N,多了可能会落不到这5台机器上;少了就有机器一直没有数据;

现在问题来了,如果其中一台机器down掉了,我们hash运算公式还是(hash(key)/N),这时候所有的数据算出来都会跟之前不一样,后果是什么?到对应的机器上找缓存时,找不到了,缓存全部失效!!!

这就是为什么说:普通hash用在分布式环境中会存在问题!

3、什么是一致性hash?

假定有一个hash函数,其值空间为(0 ~ 2^32-1)。也就是说,其hash值是个32位无整型数字,这些数字组成一个环(完整的hash空间被构成了收尾相连的环,所有的hash运算最终肯定会落在这个环上,这就是hash环)。

使用hash环,需要涉及到2次hash运算:

(1)把所有机器编号hash到这个环上——对机器进行hash(比如根据机器的ip),算出每台机器在这个环上的位置;

(2)把key也hash到这个环上——再对key进行hash,算出该key在环上的位置

然后从这个位置往前走(顺时针跑),遇到的第一台机器就是该key对应的机器,就把该(key, value) 存储到该机器上。

最终效果为:

37.jpg

下次,取的时候,也根据相同的方法,找到对应的节点,自然就找到了正确的机器;

这时候,我们再考虑当某台机器down机后,比如Cache服务器2宕机了,那么先前的Cache1和Cache2之间key值全部会由Cache3承担缓存业务;

同样,增加一台机器,只需要将影响范围内的key值hash任务分摊到新的机器上;

无论如何,受影响得也只是少部分数据,且如果涉及数据迁移,也只需要迁移一部分;


三、数据倾斜及解决方案——虚拟节点

1、什么是数据倾斜问题?

就想上面描述的,当增加或者减少某个节点时,受影响得只会是一个节点,这样的话,hash环上的Cache机器对应的节点肯定会变得不再均匀,对应的结果就是有些机器负荷很大,有些机器负荷很小,这就产生了“数据倾斜”问题;

2、解决思路:

我们假设,现在我们不是只有5台Cache服务器,而是1000台Cache服务器,当这1000台服务器随机散列在这个hash环上时,如果此时随机down掉20%的机器,hash环的倾斜问题还像刚刚5台时候那么严重吗?

大难肯定是No,所以当我们得基数越大时,抵抗“数据倾斜”的能力就越强;

3、解决方案:

我们不可能真的搞1000台Cache服务器,我们只有5台,那怎么办,我们可以用这5台虚拟出1000台机器出来呀(正式机器和虚拟机器之间存在一定的映射关系,可以找到);然后这1000台虚拟的机器就对应着hash环上的1000个虚拟节点;

此时,再有(key, value)需要缓存到机器的时候,使用hash(key)计算hash,然后找到顺时针第一个“虚拟节点”,再由“虚拟节点”找到“真实节点”,就是真正要处理的机器,这样就解决了“数据倾斜”的问题!


四、代码测试准备——寻找合适的hash算法

1、首先,我们需要寻找一个“能够对输入进行均匀散列的hash算法”,正巧,String有一个hashCode()方法,不妨试一试:

public static void main(String[] args)
{
    System.out.println("192.168.0.0:111的哈希值:" + "192.168.0.0:1111".hashCode());
    System.out.println("192.168.0.1:111的哈希值:" + "192.168.0.1:1111".hashCode());
    System.out.println("192.168.0.2:111的哈希值:" + "192.168.0.2:1111".hashCode());
    System.out.println("192.168.0.3:111的哈希值:" + "192.168.0.3:1111".hashCode());
    System.out.println("192.168.0.4:111的哈希值:" + "192.168.0.4:1111".hashCode());
}

测试结果:

38.jpg

这个就问题大了,[0,232-1]的区间之中,5个HashCode值却只分布在这么小小的一个区间,什么概念?[0,232-1]中有4294967296个数字,而我们的区间只有114516604,从概率学上讲这将导致97%待路由的服务器都被路由到"192.168.0.0"这个集群点上,简直是糟糕透了!

因此,String重写的hashCode()方法在一致性Hash算法中没有任何实用价值,得找个算法重新计算HashCode。

这种重新计算均匀散列Hash值的算法有很多,比如CRC32_HASH、FNV1_32_HASH、KETAMA_HASH等,其中KETAMA_HASH是默认的MemCache推荐的一致性Hash算法,用别的Hash算法也可以,比如FNV1_32_HASH算法的计算效率就会高一些。

我们将选择“FNV1_32_HASH”hash算法!


五、代码测试虚拟节点方案“抗数据倾斜”能力

1、使用FNV1_32_HASH算法计算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;
    }
}

2、先编写一个没有“虚拟节点”的hash环测试:

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环上的节点
     */
    private static SortedMap<Integer, String> sortedMap = new TreeMap<>();

    /**
     * 初始化,将所有的服务器加入Hash环中
     */
    static {
        // 使用红黑树实现,插入效率比较差,但是查找效率极高
        for (String group : groups) {
            int hash = HashUtil.getHash(group);
            System.out.println("[" + group + "] launched @ " + hash);
            sortedMap.put(hash, group);
        }
    }

    /**
     * 计算对应的widget加载在哪个group上
     *
     * @param widgetKey
     * @return
     */
    private static String getServer(String widgetKey) {
        int hash = HashUtil.getHash(widgetKey);
        // 只取出所有大于该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) {
        // 生成随机数进行测试
        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 + "%)");
        });
    }
}

测试结果:

39.jpg

显然,压力分布得根本不均匀,数据倾斜严重;

3、再编写一个使用“虚拟节点”的hash环测试:

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<>();
    /**
     * 虚拟节点映射关系
     */
    private static SortedMap<Integer, String> virtualNodes = new TreeMap<>();
    private static final int VIRTUAL_NODE_NUM = 1000;

    static {
        // 先添加真实节点列表
        realGroups.addAll(Arrays.asList(groups));
        // 将虚拟节点映射到Hash环上
        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 widgetKey) {
        int hash = HashUtil.getHash(widgetKey);
        // 只取出所有大于该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) {
        // 生成随机数进行测试
        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 / 1000.0D + "%)");
        });
    }
}

再看看测试结果:

40.jpg

很明显,这次的测试结果很理想,分布基本趋于均匀,达到了负载均衡的目的!


六、ZooKeeper的引入

上面我们提到了服务器的机器增加、减少,问题是客户端怎么知道呢?

一种笨办法就是手动的,当服务器机器增加、减少时候,重新配置客户端,重启客户端。

我们项目采用的方案是,引入了Zookeeper,服务器的节点列表注册到Zookeeper上面,使用客户端专门监听Zookeeper。当发现有新的Server实例加入时,会将实例作为参数,调用服务中的onServerAdded(ServerInstance server)方法,此方法将会为此server添加virtualNodesSize数量的虚拟节点到hash环上(我们项目为每个真实节点配置16个虚拟节点);

减少操作操作类似;

当然,不用Zookeeper,采用其他的分布式协调方案也可以,只要能实现服务注册发现通知机制即可。

jiguiquan@163.com

文章作者信息...

留下你的评论

*评论支持代码高亮<pre class="prettyprint linenums">代码</pre>

相关推荐