黑马程序员技术交流社区

标题: 【上海校区】一致性Hash在负载均衡中的炫技 [打印本页]

作者: 不二晨    时间: 2018-9-7 09:44
标题: 【上海校区】一致性Hash在负载均衡中的炫技
简介一致性Hash是一种特殊的Hash算法,由于其均衡性、持久性的映射特点,被广泛的应用于负载均衡领域,如nginx和memcached都采用了一致性Hash来作为集群负载均衡的方案。
本文将介绍一致性Hash的基本思路,并讨论其在分布式缓存集群负载均衡中的应用。同时也会进行相应的代码测试来验证其算法特性,并给出和其他负载均衡方案的一些对比。
一致性Hash算法简介在了解一致性Hash算法之前,先来讨论一下Hash本身的特点。普通的Hash函数最大的作用是散列,或者说是将一系列在形式上具有相似性质的数据,打散成随机的、均匀分布的数据。
比如,对字符串abc和abcd分别进行md5计算,得到的结果如下:


可以看到,两个在形式上非常相近的数据经过md5散列后,变成了完全随机的字符串。负载均衡正是利用这一特性,对于大量随机的请求或调用,通过一定形式的Hash将他们均匀的散列,从而实现压力的平均化。(当然,并不是只要使用了Hash就一定能够获得均匀的散列,后面会分析这一点。)
举个例子,如果我们给每个请求生成一个Key,只要使用一个非常简单的Hash算法Group = Key % N来实现请求的负载均衡,如下:


(如果将Key作为缓存的Key,对应的Group储存该Key的Value,就可以实现一个分布式的缓存系统,后文的具体例子都将基于这个场景)
不难发现,这样的Hash只要集群的数量N发生变化,之前的所有Hash映射就会全部失效。如果集群中的每个机器提供的服务没有差别,倒不会产生什么影响,但对于分布式缓存这样的系统而言,映射全部失效就意味着之前的缓存全部失效,后果将会是灾难性的。
一致性Hash通过构建环状的Hash空间代替线性Hash空间的方法解决了这个问题,如下图:


整个Hash空间被构建成一个首尾相接的环,使用一致性Hash时需要进行两次映射。
第一次,给每个节点(集群)计算Hash,然后记录它们的Hash值,这就是它们在环上的位置。
第二次,给每个Key计算Hash,然后沿着顺时针的方向找到环上的第一个节点,就是该Key储存对应的集群。
分析一下节点增加和删除时对负载均衡的影响,如下图:


可以看到,当节点被删除时,其余节点在环上的映射不会发生改变,只是原来打在对应节点上的Key现在会转移到顺时针方向的下一个节点上去。增加一个节点也是同样的,最终都只有少部分的Key发生了失效。不过发生节点变动后,整体系统的压力已经不是均衡的了,下文中提到的方法将会解决这个问题。
问题与优化最基本的一致性Hash算法直接应用于负载均衡系统,效果仍然是不理想的,存在诸多问题,下面就对这些问题进行逐个分析并寻求更好的解决方案。
数据倾斜如果节点的数量很少,而hash环空间很大(一般是 0 ~ 2^32),直接进行一致性hash上去,大部分情况下节点在环上的位置会很不均匀,挤在某个很小的区域。最终对分布式缓存造成的影响就是,集群的每个实例上储存的缓存数据量不一致,会发生严重的数据倾斜。
缓存雪崩如果每个节点在环上只有一个节点,那么可以想象,当某一集群从环中消失时,它原本所负责的任务将全部交由顺时针方向的下一个集群处理。例如,当group0退出时,它原本所负责的缓存将全部交给group1处理。这就意味着group1的访问压力会瞬间增大。设想一下,如果group1因为压力过大而崩溃,那么更大的压力又会向group2压过去,最终服务压力就像滚雪球一样越滚越大,最终导致雪崩。
引入虚拟节点解决上述两个问题最好的办法就是扩展整个环上的节点数量,因此我们引入了虚拟节点的概念。一个实际节点将会映射多个虚拟节点,这样Hash环上的空间分割就会变得均匀。
同时,引入虚拟节点还会使得节点在Hash环上的顺序随机化,这意味着当一个真实节点失效退出后,它原来所承载的压力将会均匀地分散到其他节点上去。
如下图:


代码测试现在我们尝试编写一些测试代码,来看看一致性hash的实际效果是否符合我们预期。
首先我们需要一个能够对输入进行均匀散列的Hash算法,可供选择的有很多,memcached官方使用了基于md5的KETAMA算法,但这里处于计算效率的考虑,使用了FNV1_32_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;    }}复制代码实际使用时可以根据需求调整。
接着需要使用一种数据结构来保存hash环,可以采用的方案有很多种,最简单的是采用数组或链表。但这样查找的时候需要进行排序,如果节点数量多,速度就可能变得很慢。
针对集群负载均衡状态读多写少的状态,很容易联想到使用二叉平衡树的结构去储存,实际上可以使用TreeMap(内部实现是红黑树)来作为Hash环的储存结构。
先编写一个最简单的,无虚拟节点的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 +"%)");            }        );    }}复制代码生成10000个随机数字进行测试,最终得到的压力分布情况如下:
[192.168.0.1:111] launched @ 8518713[192.168.0.2:111] launched @ 1361847097[192.168.0.3:111] launched @ 1171828661[192.168.0.4:111] launched @ 1764547046group 192.168.0.2:111: 8572(8.572%)group 192.168.0.1:111: 18693(18.693%)group 192.168.0.4:111: 17764(17.764%)group 192.168.0.3:111: 27870(27.87%)group 192.168.0.0:111: 27101(27.101%)复制代码可以看到压力还是比较不平均的,所以我们继续,引入虚拟节点:
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/100000.0D +"%)");            }        );    }}复制代码这里真实节点和虚拟节点的映射采用了字符串拼接的方式,这种方式虽然简单但很有效,memcached官方也是这么实现的。将虚拟节点的数量设置为1000,重新测试压力分布情况,结果如下:
group 192.168.0.2:111: 18354(18.354%)group 192.168.0.1:111: 20062(20.062%)group 192.168.0.4:111: 20749(20.749%)group 192.168.0.3:111: 20116(20.116%)group 192.168.0.0:111: 20719(20.719%)复制代码可以看到基本已经达到平均分布了,接着继续测试删除和增加节点给整个服务带来的影响,相关测试代码如下:
private static void refreshHashCircle() {    // 当集群变动时,刷新hash环,其余的集群在hash环上的位置不会发生变动        virtualNodes.clear();    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 void addGroup(String identifier) {        realGroups.add(identifier);    refreshHashCircle();}private static void removeGroup(String identifier) {    int i = 0;    for (String group:realGroups) {            if (group.equals(identifier)) {                realGroups.remove(i);        }        i++;    }    refreshHashCircle();}复制代码测试删除一个集群前后的压力分布如下:
running the normal test.group 192.168.0.2:111: 19144(19.144%)group 192.168.0.1:111: 20244(20.244%)group 192.168.0.4:111: 20923(20.923%)group 192.168.0.3:111: 19811(19.811%)group 192.168.0.0:111: 19878(19.878%)removed a group, run test again.group 192.168.0.2:111: 23409(23.409%)group 192.168.0.1:111: 25628(25.628%)group 192.168.0.4:111: 25583(25.583%)group 192.168.0.0:111: 25380(25.38%)复制代码同时计算一下消失的集群上的Key最终如何转移到其他集群上:
[192.168.0.1:111-192.168.0.4:111] :5255[192.168.0.1:111-192.168.0.3:111] :5090[192.168.0.1:111-192.168.0.2:111] :5069[192.168.0.1:111-192.168.0.0:111] :4938复制代码可见,删除集群后,该集群上的压力均匀地分散给了其他集群,最终整个集群仍处于负载均衡状态,符合我们的预期,最后看一下添加集群的情况。
压力分布:
running the normal test.group 192.168.0.2:111: 18890(18.89%)group 192.168.0.1:111: 20293(20.293%)group 192.168.0.4:111: 21000(21.0%)group 192.168.0.3:111: 19816(19.816%)group 192.168.0.0:111: 20001(20.001%)add a group, run test again.group 192.168.0.2:111: 15524(15.524%)group 192.168.0.7:111: 16928(16.928%)group 192.168.0.1:111: 16888(16.888%)group 192.168.0.4:111: 16965(16.965%)group 192.168.0.3:111: 16768(16.768%)group 192.168.0.0:111: 16927(16.927%)复制代码压力转移:
[192.168.0.0:111-192.168.0.7:111] :3102[192.168.0.4:111-192.168.0.7:111] :4060[192.168.0.2:111-192.168.0.7:111] :3313[192.168.0.1:111-192.168.0.7:111] :3292[192.168.0.3:111-192.168.0.7:111] :3261复制代码综上可以得出结论,在引入足够多的虚拟节点后,一致性hash还是能够比较完美地满足负载均衡需要的。
优雅缩扩容缓存服务器对于性能有着较高的要求,因此我们希望在扩容时新的集群能够较快的填充好数据并工作。但是从一个集群启动,到真正加入并可以提供服务之间还存在着不小的时间延迟,要实现更优雅的扩容,我们可以从两个方面出发:
谈完了扩容,再谈谈缩容。
对比:HashSlot了解了一致性Hash算法的特点后,我们也不难发现一些不尽人意的地方:
针对这些问题,Redis在实现自己的分布式集群方案时,设计了全新的思路:基于P2P结构的HashSlot算法,下面简单介绍一下:
最后我们可以给出Redis Cluster的系统结构图,和一致性Hash环还是有着很明显的区别的:


对比一下,HashSlot + P2P的方案解决了去中心化的问题,同时也提供了更好的动态扩展性。但相比于一致性Hash而言,其结构更加复杂,实现上也更加困难。
而在之前的分析中我们也能看出,一致性Hash方案整体上还是有着不错的表现的,因此在实际的系统应用中,可以根据开发成本和性能要求合理地选择最适合的方案。总之,两者都非常优秀,至于用哪个、怎么用,就是仁者见仁智者见智的问题了。



链接:https://juejin.im/post/5b8f93576fb9a05d11175b8d




作者: 不二晨    时间: 2018-9-13 16:18

很不错,受教了
作者: Yin灬Yan    时间: 2018-9-16 15:40
感谢分享  很有用
作者: 不二晨    时间: 2018-9-20 17:22
奈斯




欢迎光临 黑马程序员技术交流社区 (http://bbs.itheima.com/) 黑马程序员IT技术论坛 X3.2