一致性hash

127 阅读8分钟

业务场景

首先我们在数据结构上将hash一般用来作为key(相当于数组的索引),那么这个key对应的value就可以直接取值。所以基于hash的查询时间复杂度一般都是常数时间复杂度。

现在假如我们需要在服务器端利用session来存储用户的会话关键信息,一般来说用户登录后,服务器端会在服务器上建立存储一个当前用户登录的关联session,存储的形式可以是<key, value>,其中key可以是用户的唯一身份认证信息+时间戳,而value可以是任意对象(根据业务场景来定)。

但是这种业务情景虽然方便了服务器来保存用户的状态,但是也占用了存储空间,当业务量大到一定量级,这样的存储占用就会十分影响系统的性能(而且单机也存不下了)。于是就可以将这个<key, value>放到缓存中间件当中,缓存中间件我们是可以横向扩容的(比如Redis、Memcached集群).那么此时问题就来了,由于我们将数据字典<key, value>存在缓存集群中。那么如果业务层想要根据key来获取value,应该去集群中的哪一台实例获取呢?如果业务层想要写入一个<key,value>记录,应该往缓存集群中的哪一台写入呢?

方法一:模运算

其实看这个方法的名字就能明白,是通过取余来确定一条<key, value>记录应该对应哪一台缓存服务器。

node_number(代表缓存服务器) = hash(key) % N # 其中 N 为缓存服务器数量。

traditional-hashing.png

图片引用自:segmentfault.com/a/119000002…

模运算的局限性

  1. 故障处理(节点减少),在分布式多节点系统中,当其中一个或者多个节点因为意外(网络、物理因素)掉线,在业务层我们肯定是希望:性能降低,但是正常的功能不受影响。那么比如此时节点1无故掉线,然后此时业务层希望写入一个hash(key) % N = 1 的记录,岂不就束手无策了?你说我们可以检测集群节点工作的状态,动态的来映射,比如这样:

    image-20220401145431966

    (注:图片引用自segmentfault.com/a/119000002…

    确实,这样解决了新增键值对的问题,但是如果业务层想取出一对旧的键值对呢?可以明显的看到,此时如果根据新的模运算去取出键值对,那么肯定是取不到的(映射关系改变了)

  2. 扩容处理(节点增加) ,同样的在某些大促、秒杀的业务场景下。我们需要对分布式缓存中间件进行扩容,那么采用简单的模运算也会带来类似的映射规则改变问题。图示如下:

    ch-four-nodes-hash.jpg

(注:图片引用自segmentfault.com/a/119000002…

总结以上在对分布式缓存容错、扩容时,简单的取模运算会导致映射规则的改变。如果集群中的每个节点都是提供一样的服务,一样的数据,那无所谓。但是对于分布式缓存这种系统,映射规则的失效,就意味着业务层读取不了缓存,如果大量的缓存都读取不到,那么就会造成缓存雪崩的情形。此时业务层的数据调用全部压倒了数据库上,很容易就把数据库打崩掉。那如果此时数据库还有其他业务,毫无疑问,其他的服务也会倍受影响。

拯救者:一致性hash算法

他来了,他来了,他骑着毛驴赶来了。一致性hash算法是基于hash和模运算的。

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

引用自:segmentfault.com/a/119000002…

圈圈原理

hash-ring.jpg

对的你没看错,一致性hash原理就是一个圈。这个叫作一致性hash环,是实现一致性hash的数据结构。这个环的起点是0,终点是2^{32} -1. 对的,就是一个mod为2^{32}的圈。(注意:这里选的mod:2^{32}-1并不是随便选择的,应该是因为ip V4的地址有32bits,通常分布式缓存中的节点的标识符可以是node的ip地址。那么这样就可以尽量的减少hash碰撞,后面有讲到)。

具体操作:

  1. 首先假设我们有多个分布式节点,用ip地址作为他们的标识符。我们使用hash算法计算各个节点的hash值(这个hash值肯定会落在上面的圈圈上,那么我们此时就算是在这个圈上为各个节点服务器找到了位置)

  2. 此时我们又假设多个键值对记录需要存到分布式缓存上,同样的我们计算键值的hash值然后根2^{32} 取余,余数的位置也就是这个键在上面圈的位置(下图所示)

    hash-ring-hash-servers.jpg

  3. 顺时针就近的将键值对存储到缓存节点中。在哈希环上顺时针查找距离这个对象的 hash 值最近的机器,即是这个对象所属的机器。上图中,k1就存在t1中,k4就存在t3中,k2就存在t2中,k3就存在t1中

    hash-ring-objects-servers.jpg

  4. 想读取怎么办呢?一样的道理,我们还是先hash(key_x) mod 2^{32}, 然后我们在哈希环上顺时针查找距离这个对象的 hash 值最近的机器,即是这个对象所属的机器,读取,ok

  5. 容灾怎么办?好说,从上面的圈中比如移除t1,那么我们此时就将原本存储在t1节点的记录拿出来,重新按照第三步存到就近的节点。相对于之前我们简单模运算中,减少节点导致映射规则改变,从而可能导致大部分缓存失效的情况,这里我们只需要重新分配少部分对象。

  6. 扩容怎么办?好说,如果我们在t1t2之间新增一个缓存节点,那么我们也只需要对键落在t1t2之间的键值对对象进行重新分配就行了。

升级模式

虚拟节点,上面我们在使用一致性hash的前提下对缓存进行增加节点,我们新增的节点只能帮忙减少前后离得最近的两老节点的负载压力。其他节点的压力并没有以为新增加了节点而减小,这并不是我们所希望的。为了解决这个问题,我们可以引入虚拟节点

img

如上图所示,我们将物理的node映射为多个在环上的虚拟节点。虚拟节点的hash可以采用节点的ip+后缀

hash(“192.168.1.109#1”),hash(“192.168.1.109#2”)

此时,环形空间中不再有物理节点node1,node2,只有虚拟节点node1-1,node1-2,node2-1,node2-2。由于虚拟节点数量较多,缓存key与虚拟节点的映射关系也变得相对均衡了。

代码实现

不带虚拟节点
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]) + "]");
    }
​
}
虚拟节点
​
package hash;
 
import java.util.LinkedList;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;
 
import org.apache.commons.lang.StringUtils;
 
/**
  * 带虚拟节点的一致性Hash算法
  */
 public class ConsistentHashingWithoutVirtualNode {
 
     //待添加入Hash环的服务器列表
     private static String[] servers = {"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"};
     
     //真实结点列表,考虑到服务器上线、下线的场景,即添加、删除的场景会比较频繁,这里使用LinkedList会更好
     private static List<String> realNodes = new LinkedList<String>();
     
     //虚拟节点,key表示虚拟节点的hash值,value表示虚拟节点的名称
     private static SortedMap<Integer, String> virtualNodes = new TreeMap<Integer, String>();
             
     //虚拟节点的数目,这里写死,为了演示需要,一个真实结点对应5个虚拟节点
     private static final int VIRTUAL_NODES = 5;
     
     static{
         //先把原始的服务器添加到真实结点列表中
         for(int i=0; i<servers.length; i++)
             realNodes.add(servers[i]);
         
         //再添加虚拟节点,遍历LinkedList使用foreach循环效率会比较高
         for (String str : realNodes){
             for(int i=0; i<VIRTUAL_NODES; i++){
                 String virtualNodeName = str + "&&VN" + String.valueOf(i);
                 int hash = getHash(virtualNodeName);
                 System.out.println("虚拟节点[" + virtualNodeName + "]被添加, hash值为" + hash);
                 virtualNodes.put(hash, virtualNodeName);
             }
         }
         System.out.println();
     }
     
     //使用FNV1_32_HASH算法计算服务器的Hash值,这里不使用重写hashCode的方法,最终效果没区别
     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;
     }
     
     //得到应当路由到的结点
     private static String getServer(String key){
        //得到该key的hash值
         int hash = getHash(key);
         // 得到大于该Hash值的所有Map
         SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash);
         String virtualNode;
         if(subMap.isEmpty()){
            //如果没有比该key的hash值大的,则从第一个node开始
            Integer i = virtualNodes.firstKey();
            //返回对应的服务器
            virtualNode = virtualNodes.get(i);
         }else{
            //第一个Key就是顺时针过去离node最近的那个结点
            Integer i = subMap.firstKey();
            //返回对应的服务器
            virtualNode = subMap.get(i);
         }
         //virtualNode虚拟节点名称要截取一下
         if(StringUtils.isNotBlank(virtualNode)){
             return virtualNode.substring(0, virtualNode.indexOf("&&"));
         }
         return null;
     }
     
     public static void main(String[] args){
         String[] keys = {"太阳", "月亮", "星星"};
         for(int i=0; i<keys.length; i++)
             System.out.println("[" + keys[i] + "]的hash值为" +
                     getHash(keys[i]) + ", 被路由到结点[" + getServer(keys[i]) + "]");
     }
 }

\