負(fù)載均衡中的一致性hash算法

hash簡(jiǎn)介

說(shuō)到底,他是一種hash算法一睁,那什么是hash算法钻弄?
hash算法是一種散列算法,常用的比如MD5者吁。抽象來(lái)說(shuō)窘俺,他是將任意長(zhǎng)度的輸入X,經(jīng)過(guò)hash算法后复凳,變成固定長(zhǎng)度的輸出Y(輸出Y稱為散列值)瘤泪,多個(gè)X可能對(duì)應(yīng)同一個(gè)Y(hash碰撞,概率極小極小極小育八,MD5的碰撞概率為2/2的64次方)对途,且從Y不能反推出X,對(duì)X的任意修改髓棋,都將導(dǎo)致輸出的Y完全不一樣实檀。
基于以上的這些特性,hash算法可以有以下這些應(yīng)用場(chǎng)景:

  • 用hash算法對(duì)網(wǎng)站的登陸密碼使用散列值保存(利用的是不可反推這一特性仲锄,這樣即便淘寶的數(shù)據(jù)庫(kù)被盜了劲妙,誰(shuí)也不會(huì)知道你到底在淘寶買了些什么...東西)

  • 對(duì)比兩個(gè)文件是否完全一樣,假如要對(duì)比的txt文件中有上萬(wàn)的字符儒喊,如何一個(gè)一個(gè)去作對(duì)比镣奋,效率很差,直接對(duì)比文件的散列值是否一致即可知道文件是否一致(利用的是輸出的散列值是固定長(zhǎng)度)

  • 負(fù)載均衡算法
    作為負(fù)載均衡算法時(shí)怀愧,輸入X是客戶端請(qǐng)求的某個(gè)唯一ID或者key侨颈,hash之后的輸出Y是一個(gè)一定范圍內(nèi)的Long類型數(shù)字余赢,然后我們對(duì)這個(gè)Long類型的數(shù)字進(jìn)行取模,取模得到的余數(shù)就是需要訪問(wèn)的節(jié)點(diǎn)唯一編號(hào)哈垢,利用取模的特性(假如集群中服務(wù)器數(shù)量為3臺(tái)妻柒,那么模數(shù)是3,可以將大量的數(shù)字耘分,映射到[0-3)這個(gè)區(qū)間段)將key均勻的映射到集群中的機(jī)器上举塔,這樣就達(dá)到了負(fù)載均衡的目的,算法公式:hash(key) % n求泰,如下圖:

    image.png

最終key1 - keyn會(huì)被均勻的分布在圖中的三個(gè)節(jié)點(diǎn)上央渣,看似已經(jīng)達(dá)到了我們想要的負(fù)載均衡的效果了,但是渴频,當(dāng)客戶端請(qǐng)求在某一時(shí)段激增的時(shí)候(比如雙十一)芽丹,我們常常需要增加服務(wù)器,雙十一過(guò)后卜朗,這些增加的服務(wù)器又需要下線拔第,那么上圖中的算法,就不足以支撐這種情況场钉,假如這時(shí)候node3需要被下線蚊俺,那么客戶端請(qǐng)求對(duì)應(yīng)的負(fù)載均衡算法就變成了hash(key) % 2,這時(shí)候客戶端所有請(qǐng)求和節(jié)點(diǎn)之間的映射關(guān)系就全變了惹悄,許多請(qǐng)求會(huì)被負(fù)載均衡到"錯(cuò)誤的"節(jié)點(diǎn)上去從而拿不到想要的數(shù)據(jù)(如果是緩存春叫,那么絕大部分的獲取緩存的請(qǐng)求都會(huì)失效,大量請(qǐng)求會(huì)打到DB)泣港,人為的去轉(zhuǎn)移數(shù)據(jù)暂殖,那只能996.ICU網(wǎng)站見(jiàn)了。為了解決這個(gè)問(wèn)題当纱,所以呛每,我們需要一致性hash算法

一致性hash算法原理

假設(shè)我們采用的hash算法的值空間為0-P,將0-P構(gòu)建成一個(gè)hash環(huán)坡氯,如下圖:


image.png

在普通的hash算法中晨横,我們僅僅對(duì)請(qǐng)求唯一標(biāo)識(shí)做了hash,并且它是一個(gè)線性的hash空間箫柳,而在一致性hash算法中手形,還會(huì)使用同樣的hash算法對(duì)服務(wù)器標(biāo)識(shí)做一次hash運(yùn)算(一般對(duì)服務(wù)器IP或者主機(jī)名做hash運(yùn)算),然后將兩種hash值映射在這個(gè)hash環(huán)(java中可以用TreeMap實(shí)現(xiàn))上面悯恍,如下圖:

image.png

一致性hash算法具備以下特性:

  1. 客戶端請(qǐng)求最終會(huì)落到他所在hash環(huán)的順時(shí)針?lè)较虻牡谝粋€(gè)節(jié)點(diǎn)上库糠,在上圖中,key3涮毫,key14瞬欧,key1024請(qǐng)求最終會(huì)落到node1上面贷屎,而key5,key76982艘虎,key18會(huì)落到node3上面唉侄。
  2. 當(dāng)集群中刪除節(jié)點(diǎn)nodex時(shí),原本應(yīng)該落在nodex上面的請(qǐng)求野建,會(huì)被轉(zhuǎn)移到nodex順時(shí)針的下一個(gè)節(jié)點(diǎn)属划,新增節(jié)點(diǎn)同理,可以看到贬墩,無(wú)論新增還是刪除一個(gè)節(jié)點(diǎn)榴嗅,受影響的都只有一個(gè)節(jié)點(diǎn)的數(shù)據(jù),如下圖:
    image.png

    image.png

    以上的一致性hash算法相比較普通的hash算法有了很大的改進(jìn)陶舞,但是依然存在問(wèn)題,以刪除節(jié)點(diǎn)為例绪励,刪除一個(gè)節(jié)點(diǎn)后肿孵,集群中大部分的請(qǐng)求key都會(huì)落到node2這個(gè)節(jié)點(diǎn)上,已經(jīng)并不是"負(fù)載均衡"了疏魏。一般的hash環(huán)空間會(huì)很大停做,而如果當(dāng)集群中節(jié)點(diǎn)數(shù)量不是很多的時(shí)候,節(jié)點(diǎn)在環(huán)上面的位置可能會(huì)擠在很小的一部分區(qū)域大莫,這樣就導(dǎo)致一大部分請(qǐng)求會(huì)落到某個(gè)節(jié)點(diǎn)上(數(shù)據(jù)傾斜)蛉腌,為了解決這個(gè)問(wèn)題,一致性hash算法引入虛擬節(jié)點(diǎn)只厘。

虛擬節(jié)點(diǎn)

對(duì)每一個(gè)服務(wù)器節(jié)點(diǎn)進(jìn)行多次hash烙丛,將生成的hash值也分布在hash環(huán)上,這些新生成的節(jié)點(diǎn)稱為虛擬節(jié)點(diǎn)羔味。假設(shè)我們用服務(wù)器IP地址來(lái)做hash河咽,那么可以在IP地址后面增加編號(hào)來(lái)做hash運(yùn)算生成虛擬節(jié)點(diǎn),增加虛擬節(jié)點(diǎn)后赋元,hash環(huán)如下圖:


image.png

從圖中看到忘蟹,沒(méi)有增加虛擬節(jié)點(diǎn)的時(shí)候,大部分的請(qǐng)求會(huì)落到node2這個(gè)節(jié)點(diǎn)上面搁凸,導(dǎo)致node2節(jié)點(diǎn)請(qǐng)求壓力過(guò)大媚值,當(dāng)我們?cè)黾犹摂M節(jié)點(diǎn)后,node1护糖,node2褥芒,node3都能均勻的接收到客戶端的請(qǐng)求。而刪除某個(gè)節(jié)點(diǎn)nodex的時(shí)候椅文,原本應(yīng)該落在nodex上面的請(qǐng)求喂很,也會(huì)被均勻的分配給其他節(jié)點(diǎn)惜颇。

java實(shí)現(xiàn)一致性hash算法

首先我們得需要一個(gè)hash算法,hash算法有很多種少辣,這里提供兩種實(shí)現(xiàn)凌摄,一種基于MD5,一種基于FNV1_32_HASH(這種其實(shí)是我抄來(lái)的漓帅,哈哈哈哈~~~~)
基于MD5:

/**
     * hash 運(yùn)算
     * @param value
     * @return
     */
    public Long hash(String value){
        MessageDigest md5;
        try {
            md5 = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("MD5 not supported", e);
        }
        md5.reset();
        byte[] keyBytes = null;
        try {
            keyBytes = value.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("Unknown string :" + value, e);
        }

        md5.update(keyBytes);
        byte[] digest = md5.digest();

        // hash code, Truncate to 32-bits
        long hashCode = ((long) (digest[3] & 0xFF) << 24)
                | ((long) (digest[2] & 0xFF) << 16)
                | ((long) (digest[1] & 0xFF) << 8)
                | (digest[0] & 0xFF);

        long truncateHashCode = hashCode & 0xffffffffL;
        return truncateHashCode;
    }

基于FNV1_32_HASH:

public class HashUtil {
    /**
     * 計(jì)算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;
    }
}

接下來(lái)我們實(shí)現(xiàn)hash環(huán)锨亏,負(fù)載均衡一般是讀多寫少(新增或者刪除節(jié)點(diǎn)畢竟少數(shù)),在讀多寫少的情況下忙干,我們?nèi)菀紫氲接枚鏄?shù)來(lái)實(shí)現(xiàn)器予,在java中一般直接可以用TreeMap來(lái)實(shí)現(xiàn),TreeMap是由紅黑樹(shù)實(shí)現(xiàn)的捐迫,很適合作為hash環(huán)的存儲(chǔ)結(jié)構(gòu)乾翔,先實(shí)現(xiàn)一個(gè)沒(méi)有虛擬節(jié)點(diǎn)的hash環(huán):

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環(huán)上的節(jié)點(diǎn)
     */
    private static SortedMap<Integer, String> sortedMap = new TreeMap<>();

    /**
     * 初始化,將所有的服務(wù)器加入Hash環(huán)中
     */
    static {
        // 使用紅黑樹(shù)實(shí)現(xiàn)施戴,插入效率比較差反浓,但是查找效率極高
        for (String group : groups) {
            int hash = HashUtil.getHash(group);
            System.out.println("[" + group + "] launched @ " + hash);
            sortedMap.put(hash, group);
        }
    }

    /**
     * 計(jì)算對(duì)應(yīng)的請(qǐng)求應(yīng)該落到哪一個(gè)節(jié)點(diǎn)上
     *
     * @param key
     * @return
     */
    private static String getServer(String key) {
        int hash = HashUtil.getHash(key);
        // 只取出所有大于該hash值的部分而不必遍歷整個(gè)Tree
        SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
        if (subMap == null || subMap.isEmpty()) {
            // hash值在最尾部,應(yīng)該映射到第一個(gè)group上
            return sortedMap.get(sortedMap.firstKey());
        }
        return subMap.get(subMap.firstKey());
    }

    public static void main(String[] args) {
        // 生成隨機(jī)數(shù)進(jìn)行測(cè)試
        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個(gè)隨機(jī)數(shù)字進(jìn)行測(cè)試赞哗,最終得到的壓力分布情況如下:

[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 @ 1764547046
group 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%)

可以看到雷则,請(qǐng)求分布負(fù)載不均衡,接下來(lái)我們引入虛擬節(jié)點(diǎn):

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"
    };

    /**
     * 真實(shí)集群列表
     */
    private static List<String> realGroups = new LinkedList<>();

    /**
     * 虛擬節(jié)點(diǎn)映射關(guān)系
     */
    private static SortedMap<Integer, String> virtualNodes = new TreeMap<>();

    private static final int VIRTUAL_NODE_NUM = 1000;

    static {
        // 先添加真實(shí)節(jié)點(diǎn)列表
        realGroups.addAll(Arrays.asList(groups));

        // 將虛擬節(jié)點(diǎn)映射到Hash環(huán)上
        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 key) {
        int hash = HashUtil.getHash(key);
        // 只取出所有大于該hash值的部分而不必遍歷整個(gè)Tree
        SortedMap<Integer, String> subMap = virtualNodes.tailMap(hash);
        String virtualNodeName;
        if (subMap == null || subMap.isEmpty()) {
            // hash值在最尾部肪笋,應(yīng)該映射到第一個(gè)group上
            virtualNodeName = virtualNodes.get(virtualNodes.firstKey());
        }else {
            virtualNodeName = subMap.get(subMap.firstKey());
        }
        return getRealNodeName(virtualNodeName);
    }

    public static void main(String[] args) {
        // 生成隨機(jī)數(shù)進(jìn)行測(cè)試
        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 +"%)");
            }
        );
    }
}

在上面的代碼中月劈,我給每個(gè)節(jié)點(diǎn)增加了1000個(gè)虛擬節(jié)點(diǎn),測(cè)試結(jié)果如下:

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%)

可以看到負(fù)載已經(jīng)基本均衡了藤乙。我們?cè)囈幌聞h除和新增節(jié)點(diǎn)的情況:

private static void refreshHashCircle() {
    // 當(dāng)集群變動(dòng)時(shí)猜揪,刷新hash環(huán),其余的集群在hash環(huán)上的位置不會(huì)發(fā)生變動(dòng)
    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();
}

測(cè)試結(jié)果如下:

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%)
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%)

我們可以看到刪除和新增節(jié)點(diǎn)湾盒,一致性hash算法依然能保持良好的負(fù)載均衡湿右。

redis中的一致性hash算法

未完,待續(xù)罚勾。毅人。。尖殃。丈莺。睡覺(jué)!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末送丰,一起剝皮案震驚了整個(gè)濱河市缔俄,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖俐载,帶你破解...
    沈念sama閱讀 217,734評(píng)論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蟹略,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡遏佣,警方通過(guò)查閱死者的電腦和手機(jī)挖炬,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,931評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)状婶,“玉大人意敛,你說(shuō)我怎么就攤上這事√懦妫” “怎么了草姻?”我有些...
    開(kāi)封第一講書人閱讀 164,133評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)稍刀。 經(jīng)常有香客問(wèn)我撩独,道長(zhǎng),這世上最難降的妖魔是什么掉丽? 我笑而不...
    開(kāi)封第一講書人閱讀 58,532評(píng)論 1 293
  • 正文 為了忘掉前任跌榔,我火速辦了婚禮,結(jié)果婚禮上捶障,老公的妹妹穿的比我還像新娘。我一直安慰自己纲刀,他們只是感情好项炼,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,585評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著示绊,像睡著了一般锭部。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上面褐,一...
    開(kāi)封第一講書人閱讀 51,462評(píng)論 1 302
  • 那天拌禾,我揣著相機(jī)與錄音,去河邊找鬼展哭。 笑死湃窍,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的匪傍。 我是一名探鬼主播您市,決...
    沈念sama閱讀 40,262評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼役衡!你這毒婦竟也來(lái)了茵休?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書人閱讀 39,153評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎榕莺,沒(méi)想到半個(gè)月后俐芯,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,587評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡钉鸯,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,792評(píng)論 3 336
  • 正文 我和宋清朗相戀三年吧史,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片亏拉。...
    茶點(diǎn)故事閱讀 39,919評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡扣蜻,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出及塘,到底是詐尸還是另有隱情莽使,我是刑警寧澤,帶...
    沈念sama閱讀 35,635評(píng)論 5 345
  • 正文 年R本政府宣布笙僚,位于F島的核電站芳肌,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏肋层。R本人自食惡果不足惜亿笤,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,237評(píng)論 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望栋猖。 院中可真熱鬧净薛,春花似錦、人聲如沸蒲拉。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 31,855評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)雌团。三九已至燃领,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間锦援,已是汗流浹背猛蔽。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 32,983評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留灵寺,地道東北人曼库。 一個(gè)月前我還...
    沈念sama閱讀 48,048評(píng)論 3 370
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像替久,于是被迫代替她去往敵國(guó)和親凉泄。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,864評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容