前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >一致性哈希算法,在分布式开发中你必须会写,来看完整代码

一致性哈希算法,在分布式开发中你必须会写,来看完整代码

作者头像
架构师修炼
发布2020-07-17 12:35:13
1.2K0
发布2020-07-17 12:35:13
举报

今天我想先给大家科普下一致性哈希算法这块,因为我下一篇文章关于缓存的高可用需要用到这个,但是又不能直接在里面写太多的代码以及关于一致性hash原理的解读,这样会失去对于缓存高可用的理解而且会造成文章很长,有担心有些朋友还没接触过一致性哈希算法,所以,我就将它单独拎出来讲一下。

01

什么是一致性哈希

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

02

一致性哈希算法一般用来干什么

一般我们在项目的负载均衡上要求资源被均匀的分配到所有的服务器节点上,同时,还需要对资源的请求能迅速的路由到具体的节点,例如:

  1. 我们在做RPC服务的时候,会经常部署多台服务器,然而有时有这样的需求就是,我们希望将同一类型的请求路由到同一台机器上,这个时候就可以用一致性hash算法来实现。
  2. MemCache集群,要求存储数据均匀的放到集群中的各个节点上,访问这些数据时能快速的路由到集群中对应存放该数据的节点上;并且要求增删节点对整个集群的影响很小,不至于有大的动荡造成整体负载的不稳定,这个时候也是可以用一致性hash算法。

03

一致性哈希算法原理解析

一致性哈希算法核心思想就是,先维护出一个2的32次方整数环【0,2^32-1】,然后将每个节点的计算hash值放到环上。下面通过一个例子来看看 ;

现在有三个节点分别是Node0、Node1、Node3,我们要将多个资源尽可能均匀的分配到这三个节点中,该怎么做呢?

依据一致性hash算法思想,我们需要将资源key进行hash运算,得到的hash值在环上顺时针查找,找到离它最近的节点也就是第一个大于或等于它的节点,这样资源就和节点建立了映射关系。

04

为何用环来存储节点,还有顺时针查找?

我们要向分配节点第一想到的办法就是取余算法。即现在有3个节点,资源key=7,7%3=1,则选择Node1,key=5,5%3= 2,则选择Node2,key=3,3%3=0,则选择Node0。虽然简单,但有个缺点,如果节点数增加或减少,就会有大量的key不命中,造成请求压力转移,可能对系统整体有很大的影响,甚至发生宕机危险。

而一致性哈希算法增加或减少节点,只会引起很少部分的key不会命中,如下图,增加一个Node4节点,则只会将部分的key值从Node1移到Node4,对集群影响很小。

代码如何实现?

如上,我们已经知道了一致性哈希的原理了也知道它的作用了,那我们该怎么去写代码实现呢?下面我们以java为例写一个一致性哈希实现算法。

  1. 首先我们得怎么构造这个2的32次方的hash环,当然方法有很多,我这里就直接推荐使用TreeMap这个数据结构,因为TreeMap底层是使用了红黑树结构来存储实体对象的,时间复杂度在O(logN),效率较高。
  2. 我们在选择Hash算法上也需要选好,要尽可能的打散开,如果考虑简单的String.HashCode()方法,这个算法的缺点是,相似的字符串如N1(10.0.0.0:91001),N2(10.0.0.0:91002),N3(10.0.0.0:91003),哈希值也很相近,造成的结果是节点在Hash环上分布很紧密,导致大部分Key值落到了N0上,节点资源分布不均。一般我们采用FNV1_32_HASH、KETAMA_HASH等算法,KETAMA_HASH是MemCache集群默认的实现方法,这些算法效果要好得多,会使N0,N1,N2的Hash值更均匀的分布在环上。

那我们先来看看KETAMA_HASH算法实现一致性哈希算法的代码:

import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Map;
import java.util.TreeMap;

public class ConsistentHashLoadBalance1 {

   private TreeMap<Long, String> realNodes = new TreeMap();
   private String[] nodes;

   public ConsistentHashLoadBalance1(String[] nodes){
   this.nodes = Arrays.copyOf(nodes, nodes.length);
   initalization();
   }

   /**
   * 初始化哈希环
   * 循环计算每个node名称的哈希值,将其放入treeMap
   */
   private void initalization(){
   for (String nodeName: nodes) {
   realNodes.put(hash(nodeName, 0), nodeName);
   }
   }

   /**
   * 根据资源key选择返回相应的节点名称
   * @param key
   * @return 节点名称
   */
   public String selectNode(String key){
   Long hashOfKey = hash(key, 0);
   if (! realNodes.containsKey(hashOfKey)) {
   //ceilingEntry()的作用是得到比hashOfKey大的第一个Entry
   Map.Entry<Long, String> entry = realNodes.ceilingEntry(hashOfKey);
   if (entry != null)
   return entry.getValue();
   else
   return nodes[0];
   }else
   return realNodes.get(hashOfKey);
   }

   private Long hash(String nodeName, int number) {
   byte[] digest = md5(nodeName);
   return (((long) (digest[3 + number * 4] & 0xFF) << 24)
   | ((long) (digest[2 + number * 4] & 0xFF) << 16)
   | ((long) (digest[1 + number * 4] & 0xFF) << 8)
   | (digest[number * 4] & 0xFF))
   & 0xFFFFFFFFL;
   }

   /**
   * md5加密
   *
   * @param str
   * @return
   */
   public byte[] md5(String str) {
   try {
   MessageDigest md = MessageDigest.getInstance("MD5");
   md.reset();
   md.update(str.getBytes("UTF-8"));
   return md.digest();
   } catch (NoSuchAlgorithmException e) {
   e.printStackTrace();
   return null;
   } catch (UnsupportedEncodingException e) {
   e.printStackTrace();
   return null;
   }
   }

   private void printTreeNode(){
   if (realNodes != null && ! realNodes.isEmpty()){
   realNodes.forEach((hashKey, node) ->
   System.out.println(
   new StringBuffer(node)
   .append(" ==> ")
   .append(hashKey)
   )
   );
   }else
   System.out.println("Cycle is Empty");
   }

   public static void main(String[] args){
   String[] nodes = new String[]{"192.168.13.1:8080", "192.168.13.2:8080", "192.168.13.3:8080", "192.168.13.4:8080"};
   ConsistentHashLoadBalanceNoVirtualNode consistentHash = new ConsistentHashLoadBalanceNoVirtualNode(nodes);
   consistentHash.printTreeNode();
   }
}

我们来看看输出结果,可以看出,hash结果值还是很开阔的。

192.168.13.2:8080 ==> 596465258

192.168.13.4:8080 ==> 1785851105

192.168.13.1:8080 ==> 2249838119

192.168.13.3:8080 ==> 3292932255

现在我们使用KETAMA_HASH哈希算法,帮我们解决了hash值分布不均匀的问题,但是,目前我们还有个问题,如下图,在Node3节点尚未加入集群之前,数据是均匀分布在{Node0,Node1,Node2}三个节点上的,现在增加了Node3节点后,Node1到Node3节点中间的所有资源从Node2迁移到了Node3上。这样,Node0,Node1存储的资源多,Node2,Node3存储的资源少,资源分布就不均了。

那我们该怎么解决这种问题呢?这里我们就要引入一个叫虚拟节点的概念,其实很简单,就是比方说我现在将真实的节点Node0映射成100个虚拟节点放在环上,同这100个虚拟节点根据KETAMA_HASH哈希环匹配的资源都存到真实节点Node0上,当集群增加节点Node3时,在Hash环上增加Node3拆分的100个虚拟节点,这新增的100个虚拟节点更均匀的分布在了哈希环上,可能承担了{Node0,Node1,Node2}每个节点的部分资源,资源分布仍然保持均匀。

每个真实节点应该拆分成多少个虚拟节点?数量要合适才能保证负载分布的均匀,有一个大致的规律,如下图所示,Y轴表示真实节点的数目,X轴表示需拆分的虚拟节点数目:

真实节点越少,所需阐发的虚拟节点越多,比如有10个真实节点,每个节点所需拆分的虚拟节点个数可能是100~200个,才能达到真正的负载均衡。

下面,我们的代码就需要改造了,需要加入虚拟节点来映射:

import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.LinkedList;
import java.util.Map;
import java.util.TreeMap;


public class ConsistentHashLoadBalance {

   private TreeMap<Long, String> virtualNodes = new TreeMap<>();
   private LinkedList<String> nodes;
   //每个真实节点对应的虚拟节点数
   private final int replicCnt;

   public ConsistentHashLoadBalance(LinkedList<String> nodes, int replicCnt){
   this.nodes = nodes;
   this.replicCnt = replicCnt;
   initalization();
   }

   /**
   * 初始化哈希环
   * 循环计算每个node名称的哈希值,将其放入treeMap
   */
   private void initalization(){
   for (String nodeName: nodes) {
   for (int i = 0; i < replicCnt/4; i++) {
   String virtualNodeName = getNodeNameByIndex(nodeName, i);
   for (int j = 0; j < 4; j++) {
   virtualNodes.put(hash(virtualNodeName, j), nodeName);
   }
   }
   }
   }

   private String getNodeNameByIndex(String nodeName, int index){
   return new StringBuffer(nodeName)
   .append("&&")
   .append(index)
   .toString();
   }

   /**
   * 根据资源key选择返回相应的节点名称
   * @param key
   * @return 节点名称
   */
   public String selectNode(String key){
   Long hashOfKey = hash(key, 0);
   if (! virtualNodes.containsKey(hashOfKey)) {
   Map.Entry<Long, String> entry = virtualNodes.ceilingEntry(hashOfKey);
   if (entry != null)
   return entry.getValue();
   else
   return nodes.getFirst();
   }else
   return virtualNodes.get(hashOfKey);
   }

   private Long hash(String nodeName, int number) {
   byte[] digest = md5(nodeName);
   return (((long) (digest[3 + number * 4] & 0xFF) << 24)
   | ((long) (digest[2 + number * 4] & 0xFF) << 16)
   | ((long) (digest[1 + number * 4] & 0xFF) << 8)
   | (digest[number * 4] & 0xFF))
   & 0xFFFFFFFFL;
   }

   /**
   * md5加密
   *
   * @param str
   * @return
   */
   public byte[] md5(String str) {
   try {
   MessageDigest md = MessageDigest.getInstance("MD5");
   md.reset();
   md.update(str.getBytes("UTF-8"));
   return md.digest();
   } catch (NoSuchAlgorithmException e) {
   e.printStackTrace();
   return null;
   } catch (UnsupportedEncodingException e) {
   e.printStackTrace();
   return null;
   }
   }

   public void addNode(String node){
   nodes.add(node);
   String virtualNodeName = getNodeNameByIndex(node, 0);
   for (int i = 0; i < replicCnt/4; i++) {
   for (int j = 0; j < 4; j++) {
   virtualNodes.put(hash(virtualNodeName, j), node);
   }
   }
   }

   public void removeNode(String node){
   nodes.remove(node);
   String virtualNodeName = getNodeNameByIndex(node, 0);
   for (int i = 0; i < replicCnt/4; i++) {
   for (int j = 0; j < 4; j++) {
   virtualNodes.remove(hash(virtualNodeName, j), node);
   }
   }
   }

   private void printTreeNode(){
   if (virtualNodes != null && ! virtualNodes.isEmpty()){
   virtualNodes.forEach((hashKey, node) ->
   System.out.println(
   new StringBuffer(node)
   .append(" ==> ")
   .append(hashKey)
   )
   );
   }else
   System.out.println("Cycle is Empty");
   }

   public static void main(String[] args){
   LinkedList<String> nodes = new LinkedList<>();
   nodes.add("192.168.13.1:8080");
   nodes.add("192.168.13.2:8080");
   nodes.add("192.168.13.3:8080");
   nodes.add("192.168.13.4:8080");
   ConsistentHashLoadBalance consistentHash = new ConsistentHashLoadBalance(nodes, 160);
   consistentHash.printTreeNode();
   }
}

看看输出结果(后面还有):

192.168.13.3:8080 ==> 9681570

192.168.13.1:8080 ==> 9770234

192.168.13.3:8080 ==> 10655171

192.168.13.1:8080 ==> 29484412

192.168.13.1:8080 ==> 32476931

192.168.13.1:8080 ==> 41184104

192.168.13.4:8080 ==> 56379665

192.168.13.2:8080 ==> 58341869

192.168.13.4:8080 ==> 60613368

。。。。

总结,今天我们将如何进行资源均摊引入了一致性哈希算法,并且分享了其原理以及作用,同时,针对增加或减少节点的情况下,会造成资源不均匀且容易发生雪崩的情况,特此在一致性哈希算法中加入了虚拟节点进行了改造,最后通过真实代码的方式展示了我们的一致性hash算法该怎么写。相信这样下一篇文章就很容易了哈。希望对大家有帮助,这样我们下一篇的缓存高可用我觉得大家就好理解了。

如果大家喜欢,或是对大家有所帮助就关注我,我会一直分享业界流行技术方案,让我们共同学习共同进步。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-12-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 架构师修炼 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 代码如何实现?
相关产品与服务
负载均衡
负载均衡(Cloud Load Balancer,CLB)提供安全快捷的流量分发服务,访问流量经由 CLB 可以自动分配到云中的多台后端服务器上,扩展系统的服务能力并消除单点故障。负载均衡支持亿级连接和千万级并发,可轻松应对大流量访问,满足业务需求。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档