Hash的进化之路——Consistency Hashing | 青训营笔记

116 阅读4分钟

这是我参与「第四届青训营 」笔记创作活动的第2天

问题引入

Hash算法是我们熟知的,他可以将任意长度的二进制值(整数、浮点数、字符串……)转化成固定长度的二进制值。利用Hash算法我们可以构造Hashmap等散列结构,用来实现一一映射关系,可以在O(1)O(1)时间内实现对其中Key的查找,以空间换时间!

但是现在考虑分布式的情况,有NN个节点作为分布式的存储节点,用户Put进来一个数据,这个数据(Key, Value)存放在哪里?这个时候我们可以直接利用Hash算法(Key%N)(Key \% N)就可以确定这个数据的位置了。

但是在分布式系统中,系统扩容或者宕机是经常发生的事情,如果一个节点加入或者退出这个集群,带来的节点个数的变化会导致所有的Hash映射全部失效,这个时候需要reHash这个是在Hash过程中最耗时的操作!造成了系统雪崩!是不可以容许的!

这个时候就需要将普通的的Hash算法转化为一致性Hash来解决问题。

一致性Hash的原理

一致性Hash的原理很简单!

我们将所有节点按照顺序组成一个圆环,按照设计这个环的长度应该为23212^{32}-1,但是这里为了简化,我们直接将环的长度设置为360,对应圆环的360°。

假设我们现在有4个存储节点,分别为A,B,C,D,他们的Hash值分别如图所示,每次有数据被Put进来的时候,直接选择顺时针方向最近的一个存储节点进行存储。

Untitled.png 如果我们的某个存储节点宕机了,那么只需要将数据迁移到顺时针的下一个节点就可以了。

draw_(2).png 如果我们新加入了一个节点,直接将上一个节点的部分数据迁移过来就行可以了。

draw_(3).png

基本的算法原理如上所述,但是这就是一致性Hash的全部了吗?

虚拟节点

一致性Hash的目标是实现分布式存储,其中一个很重要的概念就是负载均衡,每个存储节点的存储需要相对平衡。考虑一个只有两个存储节点的情况,势必会出现一个问题,就是此时必然造成大量数据集中到一个节点上面,极少数数据集中到另外的节点上面。

解决这个办法的实现就是使用虚拟节点,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以先确定每个物理节点关联的虚拟节点数量,然后在ip或者主机名后面增加编号。例如上面的情况,可以为每台服务器计算三个虚拟节点,于是可以分别计算 “A#1”、“A#2”、“A#3”、“B#1”、“B#2”、“B#3”的哈希值,于是形成六个虚拟节点:

draw_(4).png

这样既可以减少数据倾斜的问题!

一致性Hash的具体实现

一个简单的一致性Hash的实现

利用Comparable接口可以便于Collections.sort()进行排序

import lombok.Data;

import java.util.ArrayList;
import java.util.Collections;

/**
 * @author Dysprosium
 * @title: ConsistencyHashing
 * @projectName zookeeper
 * @description: TODO
 * @date 2022-07-2911:02
 */
public class ConsistencyHashing {

    // 封装Node子类
    // 使用Comparable接口可以直接设置排序规则, 调用Collections.sort()可以直接实现排序
    @Data
    private class Node implements Comparable<Node> {
        private String Address;
        private Integer hash;

        public Node(String address) {
            this.Address = address;
            this.hash = address.hashCode();
        }

        @Override
        public int compareTo(Node o) {
            return this.hash.compareTo(o.hash);
        }
        
    }

    // 存储节点列表
    ArrayList<Node> nodeList;

    /**
     * 初始化节点数据,并对其进行排序
     */
    public void initData() {
        ArrayList<Node> nodeList = new ArrayList<>();
        nodeList.add(new Node("172.168.0.1:8001"));
        nodeList.add(new Node("192.163.0.1:8002"));
        nodeList.add(new Node("192.168.0.3:8003"));
        nodeList.add(new Node("192.168.7.1:8004"));
        nodeList.add(new Node("142.168.0.1:8005"));

        this.nodeList = nodeList;
        System.out.println("排序前");
        this.nodeList.forEach(System.out::println);

        Collections.sort(this.nodeList);
        System.out.println("排序后");
        this.nodeList.forEach(System.out::println);
    }

    /**
     * 以Key, Value键值对的形式向存储节点中添加数据
     * @param key 键
     * @param value 值
     */
    public void addData(String key, String value) {
        int hash = key.hashCode();
        String host = null;
        for (int i = 0; i < this.nodeList.toArray().length - 1; i++) {
            if (hash > this.nodeList.get(i).getHash() && hash < this.nodeList.get(i+1).getHash()) {
                host = this.nodeList.get(i+1).getAddress();
            }
        }
        if (host == null) host = this.nodeList.get(0).getAddress();
        System.out.println("key " + key + " value " + value + " hash " + hash + " save to " + host);
    }
    
    public static void main(String[] args) {
        ConsistencyHashing consistencyHashing = new ConsistencyHashing();
        consistencyHashing.initData();
        consistencyHashing.addData("key", "value");
        consistencyHashing.addData("hello", "world");
        consistencyHashing.addData("dysprosium", "cs");
    }
}

这个时候我们所说的数据倾斜的问题就发生了!所有的数据全部进入了192.168.7.1:8004这个节点!

Untitled 1.png

hashcode()的问题!

JDK自带的hashcode()方法,让我们将字符串转化为hash值得时候分布过于紧密,在存入 key 为 Sring 类型时计算的 hashCode 会同样结果,导致所有的数据,都指向了同一台 ip 地址的服务器!

让我们换一种加密方式!

利用hutool包下SecureUtil里的md5算法将我们的地址转化为16进制字符串,再通过转化为BigInteger的方法进行比较:

import cn.hutool.core.util.ObjectUtil;
import lombok.Data;

import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.core.util.HexUtil;
/**
 * @author Dysprosium
 * @title: ConsistencyHashing
 * @projectName zookeeper
 * @description: TODO
 * @date 2022-07-2911:02
 */
public class ConsistencyHashing {

    // 封装Node子类
    // 使用Comparable接口可以直接设置排序规则, 调用Collections.sort()可以直接实现排序
    @Data
    private class Node implements Comparable<Node> {
        private String Address;
        private Integer hash;
        private BigInteger md5;
//        private BigInteger sha1;

        public Node(String address) {
            this.Address = address;
            this.hash = address.hashCode();
            this.md5 = HexUtil.toBigInteger(SecureUtil.md5(address));
//            this.sha1 = HexUtil.toBigInteger(SecureUtil.sha1(address));
        }

        @Override
        public int compareTo(Node o) {
            return this.md5.compareTo(o.md5);
        }

    }

    // 存储节点列表
    ArrayList<Node> nodeList;

    /**
     * 初始化节点数据,并对其进行排序
     */
    public void initData() {
        ArrayList<Node> nodeList = new ArrayList<>();
        nodeList.add(new Node("172.168.0.1:8001"));
        nodeList.add(new Node("192.163.0.1:8002"));
        nodeList.add(new Node("192.168.0.3:8003"));
        nodeList.add(new Node("192.168.7.1:8004"));
        nodeList.add(new Node("142.168.0.1:8005"));

        this.nodeList = nodeList;
        System.out.println("排序前");
        this.nodeList.forEach(System.out::println);

        Collections.sort(this.nodeList);
        System.out.println("排序后");
        this.nodeList.forEach(System.out::println);

    }

    /**
     * 以Key, Value键值对的形式向存储节点中添加数据
     * @param key 键
     * @param value 值
     */
    public void addData(String key, String value) {
        BigInteger hash = HexUtil.toBigInteger(SecureUtil.md5(key));;
        String host = null;
        for (int i = 0; i < this.nodeList.toArray().length - 1; i++) {
            if (hash.compareTo(this.nodeList.get(i).getMd5()) == 1 && hash.compareTo(this.nodeList.get(i+1).getMd5()) == -1) {
                host = this.nodeList.get(i+1).getAddress();
            }
        }
        if (host == null) host = this.nodeList.get(0).getAddress();
        System.out.println("key " + key + " value " + value + " hash " + hash + " save to " + host);
    }

    public static void main(String[] args) {
        ConsistencyHashing consistencyHashing = new ConsistencyHashing();
        consistencyHashing.initData();
        consistencyHashing.addData("key", "value");
        consistencyHashing.addData("hello", "world");
        consistencyHashing.addData("dysprosium", "cs");
    }

}

Untitled 2.png 这个时候我们的hash过于紧密的问题就得到了解决!

当然你也可以选用其他的加密方式,在hutool包的SecureUtil中给出了以下几个摘要加密算法:

MD2("MD2"),
MD5("MD5"),
SHA1("SHA-1"),
SHA256("SHA-256"),
SHA384("SHA-384"),
SHA512("SHA-512");